diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java index e2f175512bf..f6ca4dad059 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdviceGenerator.java @@ -11,26 +11,26 @@ */ public interface AdviceGenerator { - @Nonnull - CallSiteResult generate(@Nonnull CallSiteSpecification callSite); + @Nonnull + CallSiteResult generate(@Nonnull CallSiteSpecification callSite); - final class CallSiteResult extends BaseValidationContext { + final class CallSiteResult extends BaseValidationContext { - private final CallSiteSpecification specification; + private final CallSiteSpecification specification; - private final File file; + private final File file; - public CallSiteResult(@Nonnull final CallSiteSpecification specification, final File file) { - this.specification = specification; - this.file = file; - } + public CallSiteResult(@Nonnull final CallSiteSpecification specification, final File file) { + this.specification = specification; + this.file = file; + } - public CallSiteSpecification getSpecification() { - return specification; - } + public CallSiteSpecification getSpecification() { + return specification; + } - public File getFile() { - return file; + public File getFile() { + return file; + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java index 3636f05b6e3..b339e5d8131 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/AdvicePointcutParser.java @@ -12,21 +12,21 @@ */ public interface AdvicePointcutParser { - @Nonnull - MethodType parse(@Nonnull String signature); + @Nonnull + MethodType parse(@Nonnull String signature); - class SignatureParsingError extends HasErrorsException { + class SignatureParsingError extends HasErrorsException { - public SignatureParsingError(@Nonnull final HasErrors errors) { - super(errors); - } + public SignatureParsingError(@Nonnull final HasErrors errors) { + super(errors); + } - public SignatureParsingError(@Nonnull final Collection errors) { - super(errors); - } + public SignatureParsingError(@Nonnull final Collection errors) { + super(errors); + } - public SignatureParsingError(@Nonnull final Failure... errors) { - super(errors); + public SignatureParsingError(@Nonnull final Failure... errors) { + super(errors); + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java index 20a4fa8561d..6024be8a275 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/CallSiteReporter.java @@ -15,68 +15,68 @@ public interface CallSiteReporter { - void report(List results, boolean error); + void report(List results, boolean error); - static Set getReporter(PluginApplication.Configuration configuration) { - Set reporters = new HashSet<>(); - for (String type : configuration.getReporters()) { - if ("CONSOLE".equals(type)) { - reporters.add(new ConsoleReporter()); - } else if ("ERROR_CONSOLE".equals(type)) { - reporters.add(new ErrorConsoleReporter()); - } else { - throw new IllegalArgumentException("Reporter of type '" + type + "' not supported"); - } + static Set getReporter(PluginApplication.Configuration configuration) { + Set reporters = new HashSet<>(); + for (String type : configuration.getReporters()) { + if ("CONSOLE".equals(type)) { + reporters.add(new ConsoleReporter()); + } else if ("ERROR_CONSOLE".equals(type)) { + reporters.add(new ErrorConsoleReporter()); + } else { + throw new IllegalArgumentException("Reporter of type '" + type + "' not supported"); + } + } + return reporters; } - return reporters; - } - abstract class FreemarkerReporter implements CallSiteReporter { - private final String templateName; + abstract class FreemarkerReporter implements CallSiteReporter { + private final String templateName; - protected FreemarkerReporter(final String templateName) { - this.templateName = templateName; - } + protected FreemarkerReporter(final String templateName) { + this.templateName = templateName; + } - protected void write(final List results, final Writer writer) { - try { - final Configuration cfg = new Configuration(Configuration.VERSION_2_3_30); - cfg.setClassLoaderForTemplateLoading(Thread.currentThread().getContextClassLoader(), "csi"); - cfg.setDefaultEncoding("UTF-8"); - final Map input = new HashMap<>(); - input.put("results", results); - final Template template = cfg.getTemplate(templateName); - template.process(input, writer); - } catch (final Exception e) { - throw new RuntimeException(e); - } + protected void write(final List results, final Writer writer) { + try { + final Configuration cfg = new Configuration(Configuration.VERSION_2_3_30); + cfg.setClassLoaderForTemplateLoading(Thread.currentThread().getContextClassLoader(), "csi"); + cfg.setDefaultEncoding("UTF-8"); + final Map input = new HashMap<>(); + input.put("results", results); + final Template template = cfg.getTemplate(templateName); + template.process(input, writer); + } catch (final Exception e) { + throw new RuntimeException(e); + } + } } - } - class ConsoleReporter extends FreemarkerReporter { - protected ConsoleReporter() { - super("console.ftl"); - } + class ConsoleReporter extends FreemarkerReporter { + protected ConsoleReporter() { + super("console.ftl"); + } - @Override - public void report(final List results, final boolean error) { - final PrintStream stream = error ? System.err : System.out; - final StringWriter writer = new StringWriter(); - write(results, writer); - stream.println(writer); + @Override + public void report(final List results, final boolean error) { + final PrintStream stream = error ? System.err : System.out; + final StringWriter writer = new StringWriter(); + write(results, writer); + stream.println(writer); + } } - } - class ErrorConsoleReporter extends FreemarkerReporter { - protected ErrorConsoleReporter() { - super("console.ftl"); - } + class ErrorConsoleReporter extends FreemarkerReporter { + protected ErrorConsoleReporter() { + super("console.ftl"); + } - @Override - public void report(final List results, final boolean error) { - if (error) { - write(results, new PrintWriter(System.err)); - } + @Override + public void report(final List results, final boolean error) { + if (error) { + write(results, new PrintWriter(System.err)); + } + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Extension.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Extension.java index 16a5de1660e..f163bd453fd 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Extension.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Extension.java @@ -10,9 +10,9 @@ public interface Extension { - boolean appliesTo(@Nonnull CallSiteSpecification spec); + boolean appliesTo(@Nonnull CallSiteSpecification spec); - void apply(@Nonnull Configuration configuration, @Nonnull CallSiteResult result) throws Exception; + void apply(@Nonnull Configuration configuration, @Nonnull CallSiteResult result) throws Exception; - List EXTENSIONS = Collections.singletonList(new IastExtension()); + List EXTENSIONS = Collections.singletonList(new IastExtension()); } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java index fb6ab718bbc..c3f011807d3 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/HasErrors.java @@ -13,139 +13,134 @@ public interface HasErrors { - List getErrors(); + List getErrors(); - void addError(@Nonnull Failure failure); + void addError(@Nonnull Failure failure); - default void addError(@Nonnull final ErrorCode error, final Object... args) { - addError(new Failure(error, args)); - } - - default void addError( - @Nonnull final Throwable cause, @Nonnull final ErrorCode error, final Object... args) { - addError(new Failure(cause, error, args)); - } - - default boolean isSuccess() { - return !getErrors().isEmpty(); - } - - final class Failure { - private final ErrorCode error; - private final Object[] params; - private final Throwable cause; - - public Failure(@Nonnull final ErrorCode error, @Nonnull final Object... params) { - this.error = error; - this.params = params; - this.cause = null; + default void addError(@Nonnull final ErrorCode error, final Object... args) { + addError(new Failure(error, args)); } - public Failure( - @Nonnull final Throwable cause, - @Nonnull final ErrorCode error, - @Nonnull final Object... params) { - this.error = error; - this.params = params; - this.cause = cause; + default void addError(@Nonnull final Throwable cause, @Nonnull final ErrorCode error, final Object... args) { + addError(new Failure(cause, error, args)); } - public ErrorCode getErrorCode() { - return error; + default boolean isSuccess() { + return !getErrors().isEmpty(); } - public Object[] getParams() { - return params; - } + final class Failure { + private final ErrorCode error; + private final Object[] params; + private final Throwable cause; - public Throwable getCause() { - return cause; - } + public Failure(@Nonnull final ErrorCode error, @Nonnull final Object... params) { + this.error = error; + this.params = params; + this.cause = null; + } - public String getCauseString() { - if (cause == null) { - return null; - } - StringWriter writer = new StringWriter(); - cause.printStackTrace(new PrintWriter(writer)); - return writer.toString(); - } + public Failure( + @Nonnull final Throwable cause, @Nonnull final ErrorCode error, @Nonnull final Object... params) { + this.error = error; + this.params = params; + this.cause = cause; + } - public String getMessage() { - return error.apply(params); - } + public ErrorCode getErrorCode() { + return error; + } - @Override - public String toString() { - return error.name(); - } - } + public Object[] getParams() { + return params; + } - class HasErrorsImpl implements HasErrors { + public Throwable getCause() { + return cause; + } - private final List errors; + public String getCauseString() { + if (cause == null) { + return null; + } + StringWriter writer = new StringWriter(); + cause.printStackTrace(new PrintWriter(writer)); + return writer.toString(); + } - public HasErrorsImpl(@Nonnull final Collection errors) { - this.errors = new ArrayList<>(errors); - } + public String getMessage() { + return error.apply(params); + } - public HasErrorsImpl(@Nonnull final Failure... errors) { - this(Arrays.asList(errors)); + @Override + public String toString() { + return error.name(); + } } - public List getErrors() { - return errors; - } + class HasErrorsImpl implements HasErrors { - @Override - public boolean isSuccess() { - return errors.isEmpty(); - } + private final List errors; - @Override - public void addError(@Nonnull final Failure failure) { - errors.add(failure); - } - } + public HasErrorsImpl(@Nonnull final Collection errors) { + this.errors = new ArrayList<>(errors); + } - class HasErrorsException extends RuntimeException implements HasErrors { - private final HasErrors errors; + public HasErrorsImpl(@Nonnull final Failure... errors) { + this(Arrays.asList(errors)); + } - public HasErrorsException(@Nonnull final HasErrors errors) { - super(buildMessage(errors), firstCause(errors)); - this.errors = errors; - } + public List getErrors() { + return errors; + } - public HasErrorsException(@Nonnull final Collection errors) { - this(new HasErrorsImpl(errors)); - } + @Override + public boolean isSuccess() { + return errors.isEmpty(); + } - public HasErrorsException(@Nonnull final Failure... errors) { - this(new HasErrorsImpl(errors)); + @Override + public void addError(@Nonnull final Failure failure) { + errors.add(failure); + } } - @Override - public List getErrors() { - return errors.getErrors(); - } + class HasErrorsException extends RuntimeException implements HasErrors { + private final HasErrors errors; - @Override - public void addError(@Nonnull final Failure failure) { - errors.addError(failure); - } + public HasErrorsException(@Nonnull final HasErrors errors) { + super(buildMessage(errors), firstCause(errors)); + this.errors = errors; + } - private static String buildMessage(@Nonnull final HasErrors errors) { - return errors.getErrors().stream() - .map(Failure::getMessage) - .collect(Collectors.joining(" | ")); - } + public HasErrorsException(@Nonnull final Collection errors) { + this(new HasErrorsImpl(errors)); + } + + public HasErrorsException(@Nonnull final Failure... errors) { + this(new HasErrorsImpl(errors)); + } - private static Throwable firstCause(@Nonnull final HasErrors errors) { - return errors.getErrors().stream() - .map(Failure::getCause) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); + @Override + public List getErrors() { + return errors.getErrors(); + } + + @Override + public void addError(@Nonnull final Failure failure) { + errors.addError(failure); + } + + private static String buildMessage(@Nonnull final HasErrors errors) { + return errors.getErrors().stream().map(Failure::getMessage).collect(Collectors.joining(" | ")); + } + + private static Throwable firstCause(@Nonnull final HasErrors errors) { + return errors.getErrors().stream() + .map(Failure::getCause) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java index 341c66f15ad..8714bb9b219 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/PluginApplication.java @@ -26,153 +26,144 @@ public class PluginApplication { - public static void main(final String[] args) { - try { - final Path parameters = getParameters(args); - final Configuration configuration = getConfiguration(parameters); - final List specs = searchForCallSites(configuration); - final List result = - specs.stream().map(generateAdviceClosure(configuration)).collect(Collectors.toList()); - final boolean failed = result.stream().anyMatch(it -> !it.isSuccess()); - printReport(configuration, result, failed); - System.exit(failed ? 1 : 0); - } catch (final RuntimeException e) { - e.printStackTrace(System.err); - System.exit(1); + public static void main(final String[] args) { + try { + final Path parameters = getParameters(args); + final Configuration configuration = getConfiguration(parameters); + final List specs = searchForCallSites(configuration); + final List result = + specs.stream().map(generateAdviceClosure(configuration)).collect(Collectors.toList()); + final boolean failed = result.stream().anyMatch(it -> !it.isSuccess()); + printReport(configuration, result, failed); + System.exit(failed ? 1 : 0); + } catch (final RuntimeException e) { + e.printStackTrace(System.err); + System.exit(1); + } } - } - - private static Function generateAdviceClosure( - final Configuration configuration) { - final AdviceGenerator adviceGenerator = getAdviceGenerator(configuration); - return spec -> { - final CallSiteResult result = adviceGenerator.generate(spec); - if (result.isSuccess()) { - Extension.EXTENSIONS.stream() - .filter(ext -> ext.appliesTo(spec)) - .forEach( - ext -> { - try { - ext.apply(configuration, result); - } catch (final Throwable e) { - result.addError(e, ErrorCode.EXTENSION_ERROR, ext.getClass()); - } + + private static Function generateAdviceClosure( + final Configuration configuration) { + final AdviceGenerator adviceGenerator = getAdviceGenerator(configuration); + return spec -> { + final CallSiteResult result = adviceGenerator.generate(spec); + if (result.isSuccess()) { + Extension.EXTENSIONS.stream().filter(ext -> ext.appliesTo(spec)).forEach(ext -> { + try { + ext.apply(configuration, result); + } catch (final Throwable e) { + result.addError(e, ErrorCode.EXTENSION_ERROR, ext.getClass()); + } }); - } - return result; - }; - } - - private static void printReport( - final Configuration configuration, final List result, final boolean failed) { - CallSiteReporter.getReporter(configuration) - .forEach(reporter -> reporter.report(result, failed)); - } - - private static List searchForCallSites(final Configuration configuration) { - try { - final SpecificationBuilder builder = specificationBuilder(); - final List result = new ArrayList<>(); - final Pattern pattern = Pattern.compile(".*" + configuration.suffix + "\\.class$"); - Files.walkFileTree( - configuration.classesFolder, - new SimpleFileVisitor() { - public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) { - if (Files.isRegularFile(file) - && pattern.matcher(file.getFileName().toString()).matches()) { - builder.build(file.toFile()).ifPresent(result::add); - } - return FileVisitResult.CONTINUE; } - }); - return result; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private static AdviceGenerator getAdviceGenerator(final Configuration configuration) { - final URL[] urls = - configuration.classPath.stream().map(CallSiteUtils::toURL).toArray(URL[]::new); - final ClassLoader loader = new URLClassLoader(urls); - final TypeResolver resolver = typeResolver(loader); - return adviceGenerator(configuration.targetFolder.toFile(), resolver); - } - - private static Configuration getConfiguration(final Path parameters) { - try { - final List lines = Files.readAllLines(parameters); - final Path projectFolder = Paths.get(lines.get(0)); - final Path classesFolder = Paths.get(lines.get(1)); - final Path targetFolder = Paths.get(lines.get(2)); - final String suffix = lines.get(3).trim(); - final List reporters = Arrays.asList(lines.get(4).trim().split(",")); - final List classPaths = - lines.stream().skip(5).map(Paths::get).collect(Collectors.toList()); - return new Configuration( - projectFolder, classesFolder, targetFolder, classPaths, suffix, reporters); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private static Path getParameters(final String[] args) { - if (args.length != 1) { - throw new IllegalArgumentException( - "The application expected a single parameter with the configuration"); - } - final Path parameters = Paths.get(args[0]); - if (!Files.exists(parameters)) { - throw new IllegalArgumentException("File '" + parameters + "' not found%n"); - } - return parameters; - } - - public static class Configuration { - private final Path srcFolder; - private final Path classesFolder; - private final Path targetFolder; - private final List classPath; - private final String suffix; - private final List reporters; - - public Configuration( - final Path srcFolder, - final Path classesFolder, - final Path targetFolder, - final List classPath, - final String suffix, - final List reporters) { - this.srcFolder = srcFolder; - this.classesFolder = classesFolder; - this.targetFolder = targetFolder; - this.classPath = classPath; - this.suffix = suffix; - this.reporters = reporters; + return result; + }; } - public Path getSrcFolder() { - return srcFolder; + private static void printReport( + final Configuration configuration, final List result, final boolean failed) { + CallSiteReporter.getReporter(configuration).forEach(reporter -> reporter.report(result, failed)); } - public Path getClassesFolder() { - return classesFolder; + private static List searchForCallSites(final Configuration configuration) { + try { + final SpecificationBuilder builder = specificationBuilder(); + final List result = new ArrayList<>(); + final Pattern pattern = Pattern.compile(".*" + configuration.suffix + "\\.class$"); + Files.walkFileTree(configuration.classesFolder, new SimpleFileVisitor() { + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) { + if (Files.isRegularFile(file) + && pattern.matcher(file.getFileName().toString()).matches()) { + builder.build(file.toFile()).ifPresent(result::add); + } + return FileVisitResult.CONTINUE; + } + }); + return result; + } catch (IOException e) { + throw new RuntimeException(e); + } } - public Path getTargetFolder() { - return targetFolder; + private static AdviceGenerator getAdviceGenerator(final Configuration configuration) { + final URL[] urls = + configuration.classPath.stream().map(CallSiteUtils::toURL).toArray(URL[]::new); + final ClassLoader loader = new URLClassLoader(urls); + final TypeResolver resolver = typeResolver(loader); + return adviceGenerator(configuration.targetFolder.toFile(), resolver); } - public List getClassPath() { - return classPath; + private static Configuration getConfiguration(final Path parameters) { + try { + final List lines = Files.readAllLines(parameters); + final Path projectFolder = Paths.get(lines.get(0)); + final Path classesFolder = Paths.get(lines.get(1)); + final Path targetFolder = Paths.get(lines.get(2)); + final String suffix = lines.get(3).trim(); + final List reporters = Arrays.asList(lines.get(4).trim().split(",")); + final List classPaths = lines.stream().skip(5).map(Paths::get).collect(Collectors.toList()); + return new Configuration(projectFolder, classesFolder, targetFolder, classPaths, suffix, reporters); + } catch (IOException e) { + throw new RuntimeException(e); + } } - public String getSuffix() { - return suffix; + private static Path getParameters(final String[] args) { + if (args.length != 1) { + throw new IllegalArgumentException("The application expected a single parameter with the configuration"); + } + final Path parameters = Paths.get(args[0]); + if (!Files.exists(parameters)) { + throw new IllegalArgumentException("File '" + parameters + "' not found%n"); + } + return parameters; } - public List getReporters() { - return reporters; + public static class Configuration { + private final Path srcFolder; + private final Path classesFolder; + private final Path targetFolder; + private final List classPath; + private final String suffix; + private final List reporters; + + public Configuration( + final Path srcFolder, + final Path classesFolder, + final Path targetFolder, + final List classPath, + final String suffix, + final List reporters) { + this.srcFolder = srcFolder; + this.classesFolder = classesFolder; + this.targetFolder = targetFolder; + this.classPath = classPath; + this.suffix = suffix; + this.reporters = reporters; + } + + public Path getSrcFolder() { + return srcFolder; + } + + public Path getClassesFolder() { + return classesFolder; + } + + public Path getTargetFolder() { + return targetFolder; + } + + public List getClassPath() { + return classPath; + } + + public String getSuffix() { + return suffix; + } + + public List getReporters() { + return reporters; + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java index 1fc40be8bf5..c5056d9d5e8 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/SpecificationBuilder.java @@ -14,6 +14,6 @@ */ public interface SpecificationBuilder { - @Nonnull - Optional build(@Nonnull File classFile); + @Nonnull + Optional build(@Nonnull File classFile); } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java index 666a460a3d1..460f9407dfd 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/TypeResolver.java @@ -10,24 +10,24 @@ public interface TypeResolver extends TypeSolver { - @Nonnull - Class resolveType(@Nonnull Type type) throws ResolutionException; + @Nonnull + Class resolveType(@Nonnull Type type) throws ResolutionException; - @Nonnull - Executable resolveMethod(@Nonnull MethodType method) throws ResolutionException; + @Nonnull + Executable resolveMethod(@Nonnull MethodType method) throws ResolutionException; - class ResolutionException extends HasErrorsException { + class ResolutionException extends HasErrorsException { - public ResolutionException(@Nonnull final HasErrors errors) { - super(errors); - } + public ResolutionException(@Nonnull final HasErrors errors) { + super(errors); + } - public ResolutionException(@Nonnull final Collection errors) { - super(errors); - } + public ResolutionException(@Nonnull final Collection errors) { + super(errors); + } - public ResolutionException(@Nonnull final Failure... errors) { - super(errors); + public ResolutionException(@Nonnull final Failure... errors) { + super(errors); + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java index 9c8570caf49..f5ad45b436f 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/Validatable.java @@ -4,5 +4,5 @@ public interface Validatable { - void validate(@Nonnull ValidationContext context); + void validate(@Nonnull ValidationContext context); } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java index 719d77cc406..9962815a3bb 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/ValidationContext.java @@ -5,23 +5,23 @@ import javax.annotation.Nonnull; public interface ValidationContext extends HasErrors { - E getContextProperty(@Nonnull String name); + E getContextProperty(@Nonnull String name); - void addContextProperty(@Nonnull String name, Object object); + void addContextProperty(@Nonnull String name, Object object); - class BaseValidationContext extends HasErrorsImpl implements ValidationContext { + class BaseValidationContext extends HasErrorsImpl implements ValidationContext { - private final Map context = new HashMap<>(); + private final Map context = new HashMap<>(); - @SuppressWarnings("unchecked") - @Override - public final E getContextProperty(@Nonnull final String name) { - return (E) context.get(name); - } + @SuppressWarnings("unchecked") + @Override + public final E getContextProperty(@Nonnull final String name) { + return (E) context.get(name); + } - @Override - public final void addContextProperty(@Nonnull final String name, final Object object) { - context.put(name, object); + @Override + public final void addContextProperty(@Nonnull final String name, final Object object) { + context.put(name, object); + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AdviceGeneratorImpl.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AdviceGeneratorImpl.java index d517834c3a6..3746cbb0165 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AdviceGeneratorImpl.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AdviceGeneratorImpl.java @@ -70,396 +70,367 @@ @SuppressWarnings("OptionalGetWithoutIsPresent") public class AdviceGeneratorImpl implements AdviceGenerator { - private final File targetFolder; - private final AdvicePointcutParser pointcutParser; - private final TypeResolver typeResolver; + private final File targetFolder; + private final AdvicePointcutParser pointcutParser; + private final TypeResolver typeResolver; - public AdviceGeneratorImpl( - @Nonnull final File targetFolder, @Nonnull final AdvicePointcutParser pointcutParser) { - this(targetFolder, pointcutParser, typeResolver()); - } + public AdviceGeneratorImpl(@Nonnull final File targetFolder, @Nonnull final AdvicePointcutParser pointcutParser) { + this(targetFolder, pointcutParser, typeResolver()); + } - public AdviceGeneratorImpl( - @Nonnull final File targetFolder, - @Nonnull final AdvicePointcutParser pointcutParser, - @Nonnull final TypeResolver typeResolver) { - this.targetFolder = targetFolder; - this.pointcutParser = pointcutParser; - this.typeResolver = typeResolver; - } + public AdviceGeneratorImpl( + @Nonnull final File targetFolder, + @Nonnull final AdvicePointcutParser pointcutParser, + @Nonnull final TypeResolver typeResolver) { + this.targetFolder = targetFolder; + this.pointcutParser = pointcutParser; + this.typeResolver = typeResolver; + } - @Override - @Nonnull - public CallSiteResult generate(@Nonnull final CallSiteSpecification spec) { - final Type callSitesType = - Type.getType(String.format("L%ss;", spec.getClazz().getInternalName())); - final File callSitesFile = new File(targetFolder, callSitesType.getInternalName() + ".java"); - final CallSiteResult result = new CallSiteResult(spec, callSitesFile); - result.addContextProperty(TYPE_RESOLVER, typeResolver); - try { - spec.validate(result); - if (result.isSuccess()) { - final CompilationUnit javaClass = buildJavaClass(spec, callSitesType, callSitesFile); - final ClassOrInterfaceDeclaration mainType = getPrimaryType(javaClass); - final BlockStmt acceptBody = new BlockStmt(); - mainType - .addMethod("accept", PUBLIC) - .setType(void.class) - .setBody(acceptBody) - .addAnnotation(Override.class.getName()) - .setParameters( - new NodeList<>(new Parameter().setName("container").setType("Container"))); - if (spec.getEnabled() != null) { - addEnabledCheck(mainType, spec.getEnabled()); - } - addHelpersInvocation(spec.getHelpers(), acceptBody); - for (final AdviceSpecification advice : spec.getAdvices()) { - try { - advice.parseSignature(pointcutParser); - advice.validate(result); + @Override + @Nonnull + public CallSiteResult generate(@Nonnull final CallSiteSpecification spec) { + final Type callSitesType = + Type.getType(String.format("L%ss;", spec.getClazz().getInternalName())); + final File callSitesFile = new File(targetFolder, callSitesType.getInternalName() + ".java"); + final CallSiteResult result = new CallSiteResult(spec, callSitesFile); + result.addContextProperty(TYPE_RESOLVER, typeResolver); + try { + spec.validate(result); if (result.isSuccess()) { - addAdviceLambda(advice, acceptBody); + final CompilationUnit javaClass = buildJavaClass(spec, callSitesType, callSitesFile); + final ClassOrInterfaceDeclaration mainType = getPrimaryType(javaClass); + final BlockStmt acceptBody = new BlockStmt(); + mainType.addMethod("accept", PUBLIC) + .setType(void.class) + .setBody(acceptBody) + .addAnnotation(Override.class.getName()) + .setParameters(new NodeList<>( + new Parameter().setName("container").setType("Container"))); + if (spec.getEnabled() != null) { + addEnabledCheck(mainType, spec.getEnabled()); + } + addHelpersInvocation(spec.getHelpers(), acceptBody); + for (final AdviceSpecification advice : spec.getAdvices()) { + try { + advice.parseSignature(pointcutParser); + advice.validate(result); + if (result.isSuccess()) { + addAdviceLambda(advice, acceptBody); + } + } catch (Throwable e) { + handleThrowable(result, e); + } + } + javaClass.getStorage().get().save(); } - } catch (Throwable e) { + } catch (Throwable e) { handleThrowable(result, e); - } } - javaClass.getStorage().get().save(); - } - } catch (Throwable e) { - handleThrowable(result, e); - } - if (!result.isSuccess()) { - deleteFile(callSitesFile); + if (!result.isSuccess()) { + deleteFile(callSitesFile); + } + return result; } - return result; - } - private static CompilationUnit buildJavaClass( - final CallSiteSpecification spec, final Type type, final File javaFile) { - final CompilationUnit javaClass = new CompilationUnit(); - javaClass.setStorage(javaFile.toPath()); - final String packageName = getPackageName(spec.getClazz()); - if (packageName != null) { - javaClass.setPackageDeclaration(getPackageName(spec.getClazz())); + private static CompilationUnit buildJavaClass( + final CallSiteSpecification spec, final Type type, final File javaFile) { + final CompilationUnit javaClass = new CompilationUnit(); + javaClass.setStorage(javaFile.toPath()); + final String packageName = getPackageName(spec.getClazz()); + if (packageName != null) { + javaClass.setPackageDeclaration(getPackageName(spec.getClazz())); + } + javaClass.addImport(OPCODES_FQDN); + javaClass.addImport(HANDLE_FQDN); + javaClass.addImport(CALL_SITES_FQCN); + javaClass.addImport(CALL_SITE_ADVICE_FQCN + ".*"); + final ClassOrInterfaceDeclaration javaType = callSitesType(javaClass, spec, type); + addAutoServiceAnnotation(javaType, spec); + return javaClass; } - javaClass.addImport(OPCODES_FQDN); - javaClass.addImport(HANDLE_FQDN); - javaClass.addImport(CALL_SITES_FQCN); - javaClass.addImport(CALL_SITE_ADVICE_FQCN + ".*"); - final ClassOrInterfaceDeclaration javaType = callSitesType(javaClass, spec, type); - addAutoServiceAnnotation(javaType, spec); - return javaClass; - } - private static ClassOrInterfaceDeclaration callSitesType( - final CompilationUnit javaClass, final CallSiteSpecification callSite, final Type advice) { - final ClassOrInterfaceDeclaration type = new ClassOrInterfaceDeclaration(); - type.setModifier(PUBLIC, true); - type.setName(getClassName(advice)); - type.addImplementedType(CALL_SITES_CLASS); - for (final Type spi : callSite.getSpi()) { - if (!CALL_SITES_FQCN.equals(spi.getClassName())) { - javaClass.addImport(spi.getClassName()); - type.addImplementedType(getClassName(spi, false)); - } + private static ClassOrInterfaceDeclaration callSitesType( + final CompilationUnit javaClass, final CallSiteSpecification callSite, final Type advice) { + final ClassOrInterfaceDeclaration type = new ClassOrInterfaceDeclaration(); + type.setModifier(PUBLIC, true); + type.setName(getClassName(advice)); + type.addImplementedType(CALL_SITES_CLASS); + for (final Type spi : callSite.getSpi()) { + if (!CALL_SITES_FQCN.equals(spi.getClassName())) { + javaClass.addImport(spi.getClassName()); + type.addImplementedType(getClassName(spi, false)); + } + } + javaClass.addType(type); + return type; } - javaClass.addType(type); - return type; - } - private static void addAutoServiceAnnotation( - final ClassOrInterfaceDeclaration javaClass, final CallSiteSpecification callSite) { - final NormalAnnotationExpr autoService = new NormalAnnotationExpr(); - autoService.setName(AUTO_SERVICE_FQDN); - final Type[] spiTypes = callSite.getSpi(); - final List spiExprs = new ArrayList<>(spiTypes.length); - for (final Type spi : spiTypes) { - spiExprs.add(new ClassExpr(new ClassOrInterfaceType().setName(getClassName(spi, false)))); + private static void addAutoServiceAnnotation( + final ClassOrInterfaceDeclaration javaClass, final CallSiteSpecification callSite) { + final NormalAnnotationExpr autoService = new NormalAnnotationExpr(); + autoService.setName(AUTO_SERVICE_FQDN); + final Type[] spiTypes = callSite.getSpi(); + final List spiExprs = new ArrayList<>(spiTypes.length); + for (final Type spi : spiTypes) { + spiExprs.add(new ClassExpr(new ClassOrInterfaceType().setName(getClassName(spi, false)))); + } + autoService.addPair("value", new ArrayInitializerExpr().setValues(new NodeList<>(spiExprs))); + javaClass.addAnnotation(autoService); } - autoService.addPair("value", new ArrayInitializerExpr().setValues(new NodeList<>(spiExprs))); - javaClass.addAnnotation(autoService); - } - private void addAdviceLambda( - @Nonnull final AdviceSpecification spec, @Nonnull final BlockStmt body) { - final MethodType pointCut = spec.getPointcut(); - final BlockStmt adviceBody = new BlockStmt(); - final Expression advice; - final String type; - if (spec.isInvokeDynamic()) { - advice = invokeDynamicAdviceSignature(adviceBody); - } else { - advice = invokeAdviceSignature(adviceBody); - } - if (spec instanceof BeforeSpecification) { - type = "BEFORE"; - writeStackOperations(spec, adviceBody); - writeAdviceMethodCall(spec, adviceBody); - writeOriginalMethodCall(spec, adviceBody); - } else if (spec instanceof AfterSpecification) { - type = "AFTER"; - writeStackOperations(spec, adviceBody); - writeOriginalMethodCall(spec, adviceBody); - writeAdviceMethodCall(spec, adviceBody); - } else { - type = "AROUND"; - writeAdviceMethodCall(spec, adviceBody); + private void addAdviceLambda(@Nonnull final AdviceSpecification spec, @Nonnull final BlockStmt body) { + final MethodType pointCut = spec.getPointcut(); + final BlockStmt adviceBody = new BlockStmt(); + final Expression advice; + final String type; + if (spec.isInvokeDynamic()) { + advice = invokeDynamicAdviceSignature(adviceBody); + } else { + advice = invokeAdviceSignature(adviceBody); + } + if (spec instanceof BeforeSpecification) { + type = "BEFORE"; + writeStackOperations(spec, adviceBody); + writeAdviceMethodCall(spec, adviceBody); + writeOriginalMethodCall(spec, adviceBody); + } else if (spec instanceof AfterSpecification) { + type = "AFTER"; + writeStackOperations(spec, adviceBody); + writeOriginalMethodCall(spec, adviceBody); + writeAdviceMethodCall(spec, adviceBody); + } else { + type = "AROUND"; + writeAdviceMethodCall(spec, adviceBody); + } + body.addStatement(new MethodCallExpr() + .setScope(new NameExpr("container")) + .setName("addAdvice") + .setArguments(new NodeList<>( + new FieldAccessExpr() + .setScope(new TypeExpr(new ClassOrInterfaceType().setName(ADVICE_TYPE_CLASS))) + .setName(type), + new StringLiteralExpr(pointCut.getOwner().getInternalName()), + new StringLiteralExpr(pointCut.getMethodName()), + new StringLiteralExpr(pointCut.getMethodType().getDescriptor()), + advice))); } - body.addStatement( - new MethodCallExpr() - .setScope(new NameExpr("container")) - .setName("addAdvice") - .setArguments( - new NodeList<>( - new FieldAccessExpr() - .setScope( - new TypeExpr(new ClassOrInterfaceType().setName(ADVICE_TYPE_CLASS))) - .setName(type), - new StringLiteralExpr(pointCut.getOwner().getInternalName()), - new StringLiteralExpr(pointCut.getMethodName()), - new StringLiteralExpr(pointCut.getMethodType().getDescriptor()), - advice))); - } - private static void addHelpersInvocation(final Type[] helpers, final BlockStmt body) { - if (helpers != null && helpers.length > 0) { - final List helperTypes = - Arrays.stream(helpers) - .map(type -> new StringLiteralExpr(type.getClassName())) - .collect(Collectors.toList()); - body.addStatement( - new MethodCallExpr() - .setScope(new NameExpr("container")) - .setName("addHelpers") - .setArguments(new NodeList<>(helperTypes))); + private static void addHelpersInvocation(final Type[] helpers, final BlockStmt body) { + if (helpers != null && helpers.length > 0) { + final List helperTypes = Arrays.stream(helpers) + .map(type -> new StringLiteralExpr(type.getClassName())) + .collect(Collectors.toList()); + body.addStatement(new MethodCallExpr() + .setScope(new NameExpr("container")) + .setName("addHelpers") + .setArguments(new NodeList<>(helperTypes))); + } } - } - private static void addEnabledCheck( - final ClassOrInterfaceDeclaration type, final Enabled enabled) { - type.addImplementedType(HAS_ENABLED_PROPERTY_CLASS); + private static void addEnabledCheck(final ClassOrInterfaceDeclaration type, final Enabled enabled) { + type.addImplementedType(HAS_ENABLED_PROPERTY_CLASS); - final MethodType method = enabled.getMethod(); - final String ownerPackage = getPackageName(method.getOwner()); - final String ownerClassName = getClassName(method.getOwner(), false); - final List parameters = - enabled.getArguments().stream().map(StringLiteralExpr::new).collect(Collectors.toList()); + final MethodType method = enabled.getMethod(); + final String ownerPackage = getPackageName(method.getOwner()); + final String ownerClassName = getClassName(method.getOwner(), false); + final List parameters = + enabled.getArguments().stream().map(StringLiteralExpr::new).collect(Collectors.toList()); - final Expression enabledCheckExpression = - new MethodCallExpr() - .setScope(new NameExpr(ownerPackage + "." + ownerClassName)) - .setName(method.getMethodName()) - .setArguments(new NodeList<>(parameters)); + final Expression enabledCheckExpression = new MethodCallExpr() + .setScope(new NameExpr(ownerPackage + "." + ownerClassName)) + .setName(method.getMethodName()) + .setArguments(new NodeList<>(parameters)); - type.addMethod("isEnabled", PUBLIC) - .setType(boolean.class) - .addAnnotation(Override.class) - .setBody( - new BlockStmt().addStatement(new ReturnStmt().setExpression(enabledCheckExpression))); - } + type.addMethod("isEnabled", PUBLIC) + .setType(boolean.class) + .addAnnotation(Override.class) + .setBody(new BlockStmt().addStatement(new ReturnStmt().setExpression(enabledCheckExpression))); + } - private static void writeStackOperations(final AdviceSpecification advice, final BlockStmt body) { - final boolean instanceMethod = !advice.isStaticPointcut(); - final AllArgsSpecification allArgsSpec = advice.findAllArguments(); - if (allArgsSpec == null && advice.isPositionalArguments()) { - final List parameterIndicesValues = - advice - .getArguments() - .map(argSpec -> intLiteral(argSpec.getIndex())) - .collect(Collectors.toList()); - final VariableDeclarator parameterIndices = - new VariableDeclarator() - .setName("parameterIndices") - .setType(new ArrayType(new PrimitiveType(PrimitiveType.Primitive.INT))) - .setInitializer( - new ArrayCreationExpr() - .setElementType(int.class) - .setInitializer( - new ArrayInitializerExpr() - .setValues(new NodeList<>(parameterIndicesValues)))); - body.addStatement(new VariableDeclarationExpr().addVariable(parameterIndices)); - final MethodCallExpr dupMethod = new MethodCallExpr().setScope(new NameExpr("handler")); - if (advice.includeThis()) { - dupMethod.setName("dupInvoke"); - dupMethod.addArgument(new NameExpr("owner")); - dupMethod.addArgument(new NameExpr("descriptor")); - dupMethod.addArgument(new NameExpr(parameterIndices.getNameAsString())); - } else { - dupMethod.setName("dupParameters"); - dupMethod.addArgument(new NameExpr("descriptor")); - dupMethod.addArgument(new NameExpr("parameterIndices")); - dupMethod.addArgument(instanceMethod ? new NameExpr("owner") : new NullLiteralExpr()); - } - body.addStatement(dupMethod); - } else { - final MethodCallExpr dupMethod = new MethodCallExpr().setScope(new NameExpr("handler")); - if (advice.includeThis()) { - dupMethod.setName("dupInvoke"); - dupMethod.addArgument(new NameExpr("owner")); - dupMethod.addArgument(new NameExpr("descriptor")); - } else { - dupMethod.setName("dupParameters"); - dupMethod.addArgument(new NameExpr("descriptor")); - } - String mode = "COPY"; - if (allArgsSpec != null) { - if (advice instanceof AfterSpecification) { - mode = advice.isConstructor() ? "PREPEND_ARRAY_CTOR" : "PREPEND_ARRAY"; + private static void writeStackOperations(final AdviceSpecification advice, final BlockStmt body) { + final boolean instanceMethod = !advice.isStaticPointcut(); + final AllArgsSpecification allArgsSpec = advice.findAllArguments(); + if (allArgsSpec == null && advice.isPositionalArguments()) { + final List parameterIndicesValues = advice.getArguments() + .map(argSpec -> intLiteral(argSpec.getIndex())) + .collect(Collectors.toList()); + final VariableDeclarator parameterIndices = new VariableDeclarator() + .setName("parameterIndices") + .setType(new ArrayType(new PrimitiveType(PrimitiveType.Primitive.INT))) + .setInitializer(new ArrayCreationExpr() + .setElementType(int.class) + .setInitializer( + new ArrayInitializerExpr().setValues(new NodeList<>(parameterIndicesValues)))); + body.addStatement(new VariableDeclarationExpr().addVariable(parameterIndices)); + final MethodCallExpr dupMethod = new MethodCallExpr().setScope(new NameExpr("handler")); + if (advice.includeThis()) { + dupMethod.setName("dupInvoke"); + dupMethod.addArgument(new NameExpr("owner")); + dupMethod.addArgument(new NameExpr("descriptor")); + dupMethod.addArgument(new NameExpr(parameterIndices.getNameAsString())); + } else { + dupMethod.setName("dupParameters"); + dupMethod.addArgument(new NameExpr("descriptor")); + dupMethod.addArgument(new NameExpr("parameterIndices")); + dupMethod.addArgument(instanceMethod ? new NameExpr("owner") : new NullLiteralExpr()); + } + body.addStatement(dupMethod); } else { - mode = "APPEND_ARRAY"; + final MethodCallExpr dupMethod = new MethodCallExpr().setScope(new NameExpr("handler")); + if (advice.includeThis()) { + dupMethod.setName("dupInvoke"); + dupMethod.addArgument(new NameExpr("owner")); + dupMethod.addArgument(new NameExpr("descriptor")); + } else { + dupMethod.setName("dupParameters"); + dupMethod.addArgument(new NameExpr("descriptor")); + } + String mode = "COPY"; + if (allArgsSpec != null) { + if (advice instanceof AfterSpecification) { + mode = advice.isConstructor() ? "PREPEND_ARRAY_CTOR" : "PREPEND_ARRAY"; + } else { + mode = "APPEND_ARRAY"; + } + } + dupMethod.addArgument(new FieldAccessExpr() + .setScope(new TypeExpr(new ClassOrInterfaceType().setName(STACK_DUP_MODE_CLASS))) + .setName(mode)); + body.addStatement(dupMethod); } - } - dupMethod.addArgument( - new FieldAccessExpr() - .setScope(new TypeExpr(new ClassOrInterfaceType().setName(STACK_DUP_MODE_CLASS))) - .setName(mode)); - body.addStatement(dupMethod); } - } - private static void writeAdviceMethodCall( - final AdviceSpecification advice, final BlockStmt body) { - final MethodType method = advice.getAdvice(); - if (advice instanceof AroundSpecification && advice.isInvokeDynamic()) { - final Expression newHandle = - new ObjectCreationExpr() - .setType("Handle") - .addArgument(opCode("H_INVOKESTATIC")) - .addArgument(new StringLiteralExpr(method.getOwner().getInternalName())) - .addArgument(new StringLiteralExpr(method.getMethodName())) - .addArgument(new StringLiteralExpr(method.getMethodType().getDescriptor())) - .addArgument(new BooleanLiteralExpr(false)); - final MethodCallExpr invokeDynamic = - new MethodCallExpr() - .setScope(new NameExpr("handler")) - .setName("invokeDynamic") - .addArgument(new NameExpr("name")) - .addArgument(new NameExpr("descriptor")) - .addArgument(newHandle) - .addArgument(new NameExpr("bootstrapMethodArguments")); - body.addStatement(invokeDynamic); - } else { - if (advice.isInvokeDynamic() && advice.findInvokeDynamicConstants() != null) { - // we should add the boostrap method constants before the method call - final MethodCallExpr loadConstantArray = - new MethodCallExpr() - .setScope(new NameExpr("handler")) - .setName("loadConstantArray") - .addArgument(new NameExpr("bootstrapMethodArguments")); - body.addStatement(loadConstantArray); - } - final MethodCallExpr invokeStatic = - new MethodCallExpr() - .setScope(new NameExpr("handler")) - .setName("advice") - .addArgument(new StringLiteralExpr(method.getOwner().getInternalName())) - .addArgument(new StringLiteralExpr(method.getMethodName())) - .addArgument(new StringLiteralExpr(method.getMethodType().getDescriptor())); - body.addStatement(invokeStatic); - } - if (requiresCast(advice)) { - final MethodType pointcut = advice.getPointcut(); - final Type expectedReturn = - pointcut.isConstructor() ? pointcut.getOwner() : pointcut.getMethodType().getReturnType(); - if (!expectedReturn.equals(method.getMethodType().getReturnType())) { - body.addStatement( - new MethodCallExpr() - .setScope(new NameExpr("handler")) - .setName("instruction") - .addArgument(opCode("CHECKCAST")) - .addArgument(new StringLiteralExpr(expectedReturn.getInternalName()))); - } + private static void writeAdviceMethodCall(final AdviceSpecification advice, final BlockStmt body) { + final MethodType method = advice.getAdvice(); + if (advice instanceof AroundSpecification && advice.isInvokeDynamic()) { + final Expression newHandle = new ObjectCreationExpr() + .setType("Handle") + .addArgument(opCode("H_INVOKESTATIC")) + .addArgument(new StringLiteralExpr(method.getOwner().getInternalName())) + .addArgument(new StringLiteralExpr(method.getMethodName())) + .addArgument(new StringLiteralExpr(method.getMethodType().getDescriptor())) + .addArgument(new BooleanLiteralExpr(false)); + final MethodCallExpr invokeDynamic = new MethodCallExpr() + .setScope(new NameExpr("handler")) + .setName("invokeDynamic") + .addArgument(new NameExpr("name")) + .addArgument(new NameExpr("descriptor")) + .addArgument(newHandle) + .addArgument(new NameExpr("bootstrapMethodArguments")); + body.addStatement(invokeDynamic); + } else { + if (advice.isInvokeDynamic() && advice.findInvokeDynamicConstants() != null) { + // we should add the boostrap method constants before the method call + final MethodCallExpr loadConstantArray = new MethodCallExpr() + .setScope(new NameExpr("handler")) + .setName("loadConstantArray") + .addArgument(new NameExpr("bootstrapMethodArguments")); + body.addStatement(loadConstantArray); + } + final MethodCallExpr invokeStatic = new MethodCallExpr() + .setScope(new NameExpr("handler")) + .setName("advice") + .addArgument(new StringLiteralExpr(method.getOwner().getInternalName())) + .addArgument(new StringLiteralExpr(method.getMethodName())) + .addArgument(new StringLiteralExpr(method.getMethodType().getDescriptor())); + body.addStatement(invokeStatic); + } + if (requiresCast(advice)) { + final MethodType pointcut = advice.getPointcut(); + final Type expectedReturn = pointcut.isConstructor() + ? pointcut.getOwner() + : pointcut.getMethodType().getReturnType(); + if (!expectedReturn.equals(method.getMethodType().getReturnType())) { + body.addStatement(new MethodCallExpr() + .setScope(new NameExpr("handler")) + .setName("instruction") + .addArgument(opCode("CHECKCAST")) + .addArgument(new StringLiteralExpr(expectedReturn.getInternalName()))); + } + } } - } - private static boolean requiresCast(final AdviceSpecification advice) { - if (advice instanceof AroundSpecification) { - return true; // around always replaces the original method call - } - if (advice instanceof AfterSpecification) { - return !advice.isInvokeDynamic(); // dynamic invokes original method call returns CallSite + private static boolean requiresCast(final AdviceSpecification advice) { + if (advice instanceof AroundSpecification) { + return true; // around always replaces the original method call + } + if (advice instanceof AfterSpecification) { + return !advice.isInvokeDynamic(); // dynamic invokes original method call returns CallSite + } + return false; } - return false; - } - private static void writeOriginalMethodCall( - final AdviceSpecification advice, final BlockStmt body) { - final MethodCallExpr invoke = new MethodCallExpr().setScope(new NameExpr("handler")); - if (advice.isInvokeDynamic()) { - invoke - .setName("invokeDynamic") - .addArgument(new NameExpr("name")) - .addArgument(new NameExpr("descriptor")) - .addArgument(new NameExpr("bootstrapMethodHandle")) - .addArgument(new NameExpr("bootstrapMethodArguments")); - } else { - invoke - .setName("method") - .addArgument(new NameExpr("opcode")) - .addArgument(new NameExpr("owner")) - .addArgument(new NameExpr("name")) - .addArgument(new NameExpr("descriptor")) - .addArgument(new NameExpr("isInterface")); + private static void writeOriginalMethodCall(final AdviceSpecification advice, final BlockStmt body) { + final MethodCallExpr invoke = new MethodCallExpr().setScope(new NameExpr("handler")); + if (advice.isInvokeDynamic()) { + invoke.setName("invokeDynamic") + .addArgument(new NameExpr("name")) + .addArgument(new NameExpr("descriptor")) + .addArgument(new NameExpr("bootstrapMethodHandle")) + .addArgument(new NameExpr("bootstrapMethodArguments")); + } else { + invoke.setName("method") + .addArgument(new NameExpr("opcode")) + .addArgument(new NameExpr("owner")) + .addArgument(new NameExpr("name")) + .addArgument(new NameExpr("descriptor")) + .addArgument(new NameExpr("isInterface")); + } + body.addStatement(invoke); } - body.addStatement(invoke); - } - private static Expression invokeAdviceSignature(final BlockStmt body) { - return new LambdaExpr( - new NodeList<>( - new Parameter().setName("handler").setType(METHOD_HANDLER_CLASS), - new Parameter().setName("opcode").setType(int.class), - new Parameter().setName("owner").setType(String.class), - new Parameter().setName("name").setType(String.class), - new Parameter().setName("descriptor").setType(String.class), - new Parameter().setName("isInterface").setType(boolean.class)), - body); - } + private static Expression invokeAdviceSignature(final BlockStmt body) { + return new LambdaExpr( + new NodeList<>( + new Parameter().setName("handler").setType(METHOD_HANDLER_CLASS), + new Parameter().setName("opcode").setType(int.class), + new Parameter().setName("owner").setType(String.class), + new Parameter().setName("name").setType(String.class), + new Parameter().setName("descriptor").setType(String.class), + new Parameter().setName("isInterface").setType(boolean.class)), + body); + } - private static Expression invokeDynamicAdviceSignature(final BlockStmt body) { - return new LambdaExpr( - new NodeList<>( - new Parameter().setName("handler").setType(METHOD_HANDLER_CLASS), - new Parameter().setName("name").setType(String.class), - new Parameter().setName("descriptor").setType(String.class), - new Parameter().setName("bootstrapMethodHandle").setType(HANDLE_FQDN), - new Parameter() - .setVarArgs(true) - .setType(Object.class) - .setName("bootstrapMethodArguments")), - body); - } + private static Expression invokeDynamicAdviceSignature(final BlockStmt body) { + return new LambdaExpr( + new NodeList<>( + new Parameter().setName("handler").setType(METHOD_HANDLER_CLASS), + new Parameter().setName("name").setType(String.class), + new Parameter().setName("descriptor").setType(String.class), + new Parameter().setName("bootstrapMethodHandle").setType(HANDLE_FQDN), + new Parameter().setVarArgs(true).setType(Object.class).setName("bootstrapMethodArguments")), + body); + } - private static Expression opCode(final String opCode) { - return new FieldAccessExpr() - .setScope(new TypeExpr(new ClassOrInterfaceType().setName("Opcodes"))) - .setName(opCode); - } + private static Expression opCode(final String opCode) { + return new FieldAccessExpr() + .setScope(new TypeExpr(new ClassOrInterfaceType().setName("Opcodes"))) + .setName(opCode); + } - private static void handleThrowable( - @Nonnull final ValidationContext container, @Nonnull final Throwable t) { - if (t instanceof HasErrors) { - ((HasErrors) t).getErrors().forEach(container::addError); - } else { - container.addError(t, ErrorCode.UNCAUGHT_ERROR); + private static void handleThrowable(@Nonnull final ValidationContext container, @Nonnull final Throwable t) { + if (t instanceof HasErrors) { + ((HasErrors) t).getErrors().forEach(container::addError); + } else { + container.addError(t, ErrorCode.UNCAUGHT_ERROR); + } } - } - private static String getPackageName(final Type type) { - final String className = type.getClassName(); - final int index = type.getClassName().lastIndexOf('.'); - return index >= 0 ? className.substring(0, index) : null; - } + private static String getPackageName(final Type type) { + final String className = type.getClassName(); + final int index = type.getClassName().lastIndexOf('.'); + return index >= 0 ? className.substring(0, index) : null; + } - private static String getClassName(final Type type) { - return getClassName(type, true); - } + private static String getClassName(final Type type) { + return getClassName(type, true); + } - private static String getClassName(final Type type, final boolean definition) { - final String className = type.getClassName(); - final int index = type.getClassName().lastIndexOf('.'); - final String result = index >= 0 ? className.substring(index + 1) : className; - return definition ? result : result.replace('$', '.'); - } + private static String getClassName(final Type type, final boolean definition) { + final String className = type.getClassName(); + final int index = type.getClassName().lastIndexOf('.'); + final String result = index >= 0 ? className.substring(index + 1) : className; + return definition ? result : result.replace('$', '.'); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java index 00a93721f51..fc4ea958c8e 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilder.java @@ -55,250 +55,240 @@ */ public class AsmSpecificationBuilder implements SpecificationBuilder { - @Override - @Nonnull - public Optional build(@Nonnull final File file) { - try (final InputStream stream = Files.newInputStream(file.toPath())) { - final ClassReader reader = new ClassReader(stream); - final SpecificationVisitor visitor = new SpecificationVisitor(); - reader.accept(visitor, SKIP_CODE | SKIP_FRAMES | SKIP_DEBUG); - return visitor.getResult(); - } catch (IOException e) { - throw new RuntimeException(e); + @Override + @Nonnull + public Optional build(@Nonnull final File file) { + try (final InputStream stream = Files.newInputStream(file.toPath())) { + final ClassReader reader = new ClassReader(stream); + final SpecificationVisitor visitor = new SpecificationVisitor(); + reader.accept(visitor, SKIP_CODE | SKIP_FRAMES | SKIP_DEBUG); + return visitor.getResult(); + } catch (IOException e) { + throw new RuntimeException(e); + } } - } - private static class SpecificationVisitor extends ClassVisitor { + private static class SpecificationVisitor extends ClassVisitor { - private static final String CALL_SITE = classNameToDescriptor(CALL_SITE_ANNOTATION); + private static final String CALL_SITE = classNameToDescriptor(CALL_SITE_ANNOTATION); - private Type clazz; - private boolean isCallSite; - private final List advices = new ArrayList<>(); - private final Set helpers = new HashSet<>(); - private final Set spi = new HashSet<>(); - private List enabled = new ArrayList<>(); - private CallSiteSpecification result; + private Type clazz; + private boolean isCallSite; + private final List advices = new ArrayList<>(); + private final Set helpers = new HashSet<>(); + private final Set spi = new HashSet<>(); + private List enabled = new ArrayList<>(); + private CallSiteSpecification result; - public SpecificationVisitor() { - super(ASM_API_VERSION); - } + public SpecificationVisitor() { + super(ASM_API_VERSION); + } - @Override - public void visit( - final int version, - final int access, - final String name, - final String signature, - final String superName, - final String[] interfaces) { - clazz = classNameToType(name); - } + @Override + public void visit( + final int version, + final int access, + final String name, + final String signature, + final String superName, + final String[] interfaces) { + clazz = classNameToType(name); + } - @Override - public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) { - isCallSite = CALL_SITE.equals(descriptor); - if (isCallSite) { - helpers.add(clazz); - return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) { + isCallSite = CALL_SITE.equals(descriptor); + if (isCallSite) { + helpers.add(clazz); + return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public AnnotationVisitor visitArray(final String name) { - if ("spi".equals(name)) { - return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public void visit(final String name, final Object value) { - spi.add((Type) value); - } - }; - } else if ("helpers".equals(name)) { - return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public void visit(final String name, final Object value) { - helpers.add((Type) value); - } - }; - } else if ("enabled".equals(name)) { - return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public void visit(final String name, final Object value) { - enabled.add((String) value); - } - }; + @Override + public AnnotationVisitor visitArray(final String name) { + if ("spi".equals(name)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String name, final Object value) { + spi.add((Type) value); + } + }; + } else if ("helpers".equals(name)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String name, final Object value) { + helpers.add((Type) value); + } + }; + } else if ("enabled".equals(name)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String name, final Object value) { + enabled.add((String) value); + } + }; + } + return null; + } + }; } return null; - } - }; - } - return null; - } + } - @Override - public MethodVisitor visitMethod( - final int access, - final String name, - final String descriptor, - final String signature, - final String[] exceptions) { - if (isCallSite) { - return new AdviceMethodVisitor(this, name, Type.getMethodType(descriptor)); - } - return null; - } + @Override + public MethodVisitor visitMethod( + final int access, + final String name, + final String descriptor, + final String signature, + final String[] exceptions) { + if (isCallSite) { + return new AdviceMethodVisitor(this, name, Type.getMethodType(descriptor)); + } + return null; + } - @Override - public void visitEnd() { - if (isCallSite) { - result = new CallSiteSpecification(clazz, advices, spi, enabled, helpers); - } - } + @Override + public void visitEnd() { + if (isCallSite) { + result = new CallSiteSpecification(clazz, advices, spi, enabled, helpers); + } + } - public Optional getResult() { - return Optional.ofNullable(result); + public Optional getResult() { + return Optional.ofNullable(result); + } } - } - private static class AdviceMethodVisitor extends MethodVisitor { + private static class AdviceMethodVisitor extends MethodVisitor { - private static final Map ADVICE_BUILDERS = new HashMap<>(); + private static final Map ADVICE_BUILDERS = new HashMap<>(); - private static final Set REPEATABLE_ADVICES = new HashSet<>(); + private static final Set REPEATABLE_ADVICES = new HashSet<>(); - private static final Map PARAMETER_BUILDERS = - new HashMap<>(); + private static final Map PARAMETER_BUILDERS = new HashMap<>(); - static { - ADVICE_BUILDERS.put(classNameToDescriptor(BEFORE_ANNOTATION), BeforeSpecification::new); - ADVICE_BUILDERS.put(classNameToDescriptor(AROUND_ANNOTATION), AroundSpecification::new); - ADVICE_BUILDERS.put(classNameToDescriptor(AFTER_ANNOTATION), AfterSpecification::new); - REPEATABLE_ADVICES.add(classNameToDescriptor(BEFORE_ARRAY_ANNOTATION)); - REPEATABLE_ADVICES.add(classNameToDescriptor(AROUND_ARRAY_ANNOTATION)); - REPEATABLE_ADVICES.add(classNameToDescriptor(AFTER_ARRAY_ANNOTATION)); - PARAMETER_BUILDERS.put(classNameToDescriptor(THIS_ANNOTATION), ThisSpecification::new); - PARAMETER_BUILDERS.put( - classNameToDescriptor(ARGUMENT_ANNOTATION), ArgumentSpecification::new); - PARAMETER_BUILDERS.put(classNameToDescriptor(RETURN_ANNOTATION), ReturnSpecification::new); - PARAMETER_BUILDERS.put(classNameToDescriptor(ALL_ARGS_ANNOTATION), AllArgsSpecification::new); - PARAMETER_BUILDERS.put( - classNameToDescriptor(INVOKE_DYNAMIC_CONSTANTS_ANNOTATION), - InvokeDynamicConstantsSpecification::new); - } + static { + ADVICE_BUILDERS.put(classNameToDescriptor(BEFORE_ANNOTATION), BeforeSpecification::new); + ADVICE_BUILDERS.put(classNameToDescriptor(AROUND_ANNOTATION), AroundSpecification::new); + ADVICE_BUILDERS.put(classNameToDescriptor(AFTER_ANNOTATION), AfterSpecification::new); + REPEATABLE_ADVICES.add(classNameToDescriptor(BEFORE_ARRAY_ANNOTATION)); + REPEATABLE_ADVICES.add(classNameToDescriptor(AROUND_ARRAY_ANNOTATION)); + REPEATABLE_ADVICES.add(classNameToDescriptor(AFTER_ARRAY_ANNOTATION)); + PARAMETER_BUILDERS.put(classNameToDescriptor(THIS_ANNOTATION), ThisSpecification::new); + PARAMETER_BUILDERS.put(classNameToDescriptor(ARGUMENT_ANNOTATION), ArgumentSpecification::new); + PARAMETER_BUILDERS.put(classNameToDescriptor(RETURN_ANNOTATION), ReturnSpecification::new); + PARAMETER_BUILDERS.put(classNameToDescriptor(ALL_ARGS_ANNOTATION), AllArgsSpecification::new); + PARAMETER_BUILDERS.put( + classNameToDescriptor(INVOKE_DYNAMIC_CONSTANTS_ANNOTATION), + InvokeDynamicConstantsSpecification::new); + } - private final SpecificationVisitor spec; - private final MethodType advice; - private final Map parameters = new HashMap<>(); - private final Map> adviceData = - new HashMap<>(); + private final SpecificationVisitor spec; + private final MethodType advice; + private final Map parameters = new HashMap<>(); + private final Map> adviceData = new HashMap<>(); - public AdviceMethodVisitor( - @Nonnull final SpecificationVisitor spec, - @Nonnull final String method, - @Nonnull final Type methodType) { - super(ASM_API_VERSION); - this.spec = spec; - advice = new MethodType(spec.clazz, method, methodType); - } + public AdviceMethodVisitor( + @Nonnull final SpecificationVisitor spec, + @Nonnull final String method, + @Nonnull final Type methodType) { + super(ASM_API_VERSION); + this.spec = spec; + advice = new MethodType(spec.clazz, method, methodType); + } - @Override - public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) { - AdviceSpecificationCtor ctor = ADVICE_BUILDERS.get(descriptor); - if (ctor != null) { - final List list = - adviceData.computeIfAbsent(ctor, c -> new ArrayList<>()); - final AdviceSpecificationData data = new AdviceSpecificationData(); - list.add(data); - return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public void visit(final String key, final Object value) { - if ("value".equals(key)) { - data.signature = (String) value; - } else if ("invokeDynamic".equals(key)) { - data.invokeDynamic = (boolean) value; + @Override + public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) { + AdviceSpecificationCtor ctor = ADVICE_BUILDERS.get(descriptor); + if (ctor != null) { + final List list = adviceData.computeIfAbsent(ctor, c -> new ArrayList<>()); + final AdviceSpecificationData data = new AdviceSpecificationData(); + list.add(data); + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String key, final Object value) { + if ("value".equals(key)) { + data.signature = (String) value; + } else if ("invokeDynamic".equals(key)) { + data.invokeDynamic = (boolean) value; + } + } + }; } - } - }; - } - if (REPEATABLE_ADVICES.contains(descriptor)) { - return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public AnnotationVisitor visitArray(final String name) { - if ("value".equals(name)) { - return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public AnnotationVisitor visitAnnotation( - final String name, final String descriptor) { - return AdviceMethodVisitor.this.visitAnnotation(descriptor, true); - } - }; + if (REPEATABLE_ADVICES.contains(descriptor)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public AnnotationVisitor visitArray(final String name) { + if ("value".equals(name)) { + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public AnnotationVisitor visitAnnotation(final String name, final String descriptor) { + return AdviceMethodVisitor.this.visitAnnotation(descriptor, true); + } + }; + } + return null; + } + }; } return null; - } - }; - } - return null; - } + } - @Override - public AnnotationVisitor visitParameterAnnotation( - final int parameter, final String descriptor, final boolean visible) { - if (!adviceData.isEmpty()) { - final ParameterSpecificationCtor parameterCtor = PARAMETER_BUILDERS.get(descriptor); - if (parameterCtor != null) { - ParameterSpecification parameterSpec = parameterCtor.build(); - if (parameterSpec instanceof ArgumentSpecification) { - final long index = - parameters.values().stream() - .filter(it -> it instanceof ArgumentSpecification) - .count(); - ((ArgumentSpecification) parameterSpec) - .setIndex((int) index); // can change in annotation visitor - } - parameters.put(parameter, parameterSpec); + @Override + public AnnotationVisitor visitParameterAnnotation( + final int parameter, final String descriptor, final boolean visible) { + if (!adviceData.isEmpty()) { + final ParameterSpecificationCtor parameterCtor = PARAMETER_BUILDERS.get(descriptor); + if (parameterCtor != null) { + ParameterSpecification parameterSpec = parameterCtor.build(); + if (parameterSpec instanceof ArgumentSpecification) { + final long index = parameters.values().stream() + .filter(it -> it instanceof ArgumentSpecification) + .count(); + ((ArgumentSpecification) parameterSpec) + .setIndex((int) index); // can change in annotation visitor + } + parameters.put(parameter, parameterSpec); - return new AnnotationVisitor(ASM_API_VERSION) { - @Override - public void visit(final String key, final Object value) { - if ("includeThis".equals(key) && parameterSpec instanceof AllArgsSpecification) { - final AllArgsSpecification allArgs = (AllArgsSpecification) parameterSpec; - allArgs.setIncludeThis((boolean) value); - } else if ("value".equals(key) && parameterSpec instanceof ArgumentSpecification) { - ((ArgumentSpecification) parameterSpec).setIndex((Integer) value); - } + return new AnnotationVisitor(ASM_API_VERSION) { + @Override + public void visit(final String key, final Object value) { + if ("includeThis".equals(key) && parameterSpec instanceof AllArgsSpecification) { + final AllArgsSpecification allArgs = (AllArgsSpecification) parameterSpec; + allArgs.setIncludeThis((boolean) value); + } else if ("value".equals(key) && parameterSpec instanceof ArgumentSpecification) { + ((ArgumentSpecification) parameterSpec).setIndex((Integer) value); + } + } + }; + } } - }; + return null; } - } - return null; - } - @Override - public void visitEnd() { - adviceData.forEach( - (adviceCtor, list) -> - list.stream() - .map( - data -> - adviceCtor.build(advice, parameters, data.signature, data.invokeDynamic)) - .forEach(spec.advices::add)); + @Override + public void visitEnd() { + adviceData.forEach((adviceCtor, list) -> list.stream() + .map(data -> adviceCtor.build(advice, parameters, data.signature, data.invokeDynamic)) + .forEach(spec.advices::add)); + } } - } - @FunctionalInterface - private interface AdviceSpecificationCtor { - AdviceSpecification build( - @Nonnull MethodType advice, - @Nonnull Map parameters, - @Nonnull String signature, - boolean invokeDynamic); - } + @FunctionalInterface + private interface AdviceSpecificationCtor { + AdviceSpecification build( + @Nonnull MethodType advice, + @Nonnull Map parameters, + @Nonnull String signature, + boolean invokeDynamic); + } - @FunctionalInterface - private interface ParameterSpecificationCtor { - ParameterSpecification build(); - } + @FunctionalInterface + private interface ParameterSpecificationCtor { + ParameterSpecification build(); + } - private static class AdviceSpecificationData { - private String signature; - private boolean invokeDynamic; - } + private static class AdviceSpecificationData { + private String signature; + private boolean invokeDynamic; + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java index 2e7d22c96d6..6868535498b 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteFactory.java @@ -9,30 +9,30 @@ public abstract class CallSiteFactory { - private CallSiteFactory() {} + private CallSiteFactory() {} - public static AdviceGenerator adviceGenerator(final File targetFolder) { - return adviceGenerator(targetFolder, typeResolver()); - } + public static AdviceGenerator adviceGenerator(final File targetFolder) { + return adviceGenerator(targetFolder, typeResolver()); + } - public static AdviceGenerator adviceGenerator( - @Nonnull final File targetFolder, @Nonnull final TypeResolver typeResolver) { - return new AdviceGeneratorImpl(targetFolder, pointcutParser(), typeResolver); - } + public static AdviceGenerator adviceGenerator( + @Nonnull final File targetFolder, @Nonnull final TypeResolver typeResolver) { + return new AdviceGeneratorImpl(targetFolder, pointcutParser(), typeResolver); + } - public static SpecificationBuilder specificationBuilder() { - return new AsmSpecificationBuilder(); - } + public static SpecificationBuilder specificationBuilder() { + return new AsmSpecificationBuilder(); + } - public static AdvicePointcutParser pointcutParser() { - return new RegexpAdvicePointcutParser(); - } + public static AdvicePointcutParser pointcutParser() { + return new RegexpAdvicePointcutParser(); + } - public static TypeResolver typeResolver() { - return typeResolver(Thread.currentThread().getContextClassLoader()); - } + public static TypeResolver typeResolver() { + return typeResolver(Thread.currentThread().getContextClassLoader()); + } - public static TypeResolver typeResolver(@Nonnull final ClassLoader... classpath) { - return new TypeResolverPool(classpath); - } + public static TypeResolver typeResolver(@Nonnull final ClassLoader... classpath) { + return new TypeResolverPool(classpath); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java index 7ce110d33e8..c68cf2c69d3 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/CallSiteSpecification.java @@ -30,675 +30,645 @@ /** Description of a class annotated with {@link datadog.trace.agent.tooling.csi.CallSite} */ public class CallSiteSpecification implements Validatable { - private final Type clazz; - private final List advices; - private final Type[] spi; - private final Enabled enabled; - private final Type[] helpers; - - public CallSiteSpecification( - @Nonnull final Type clazz, - @Nonnull final List advices, - @Nonnull final Set spi, - @Nonnull final List enabled, - @Nonnull final Set helpers) { - this.clazz = clazz; - this.advices = advices; - this.spi = spi.toArray(new Type[0]); - this.enabled = enabled.isEmpty() ? null : new Enabled(enabled); - this.helpers = helpers.toArray(new Type[0]); - } - - @Override - public void validate(@Nonnull final ValidationContext context) { - final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); - try { - for (Type spiType : spi) { - Class spiClass = typeResolver.resolveType(spiType); - if (!spiClass.isInterface()) { - context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE, spiClass); - } else { - if (spiClass.getDeclaredMethods().length > 0) { - context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_EMPTY, spiClass); - } - } - } - } catch (ResolutionException e) { - e.getErrors().forEach(context::addError); - } - if (enabled != null) { - try { - final Method enabledMethod = (Method) typeResolver.resolveMethod(enabled.getMethod()); - final int modifiers = enabledMethod.getModifiers(); - if (!Modifier.isPublic(modifiers) || !Modifier.isStatic(modifiers)) { - context.addError(ErrorCode.CALL_SITE_ENABLED_SHOULD_BE_STATIC_AND_PUBLIC, enabledMethod); - } - } catch (ResolutionException e) { - e.getErrors().forEach(context::addError); - } - } - if (advices.isEmpty()) { - context.addError(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS); - } - } - - public Type getClazz() { - return clazz; - } - - public Type[] getSpi() { - return spi; - } - - public Enabled getEnabled() { - return enabled; - } - - public Type[] getHelpers() { - return helpers; - } - - public List getAdvices() { - return advices; - } - - /** - * Description of a method annotated with {@link datadog.trace.agent.tooling.csi.CallSite.After}, - * {@link datadog.trace.agent.tooling.csi.CallSite.Before} or {@link - * datadog.trace.agent.tooling.csi.CallSite.Around} - */ - public abstract static class AdviceSpecification implements Validatable { - - protected final MethodType advice; - private final Map parameters; - protected final String signature; - protected final boolean invokeDynamic; - protected MethodType pointcut; - protected Executable pointcutMethod; - - public AdviceSpecification( - @Nonnull final MethodType advice, - @Nonnull final Map parameters, - @Nonnull final String signature, - final boolean invokeDynamic) { - this.advice = advice; - this.parameters = new TreeMap<>(parameters); - this.signature = signature; - this.invokeDynamic = invokeDynamic; + private final Type clazz; + private final List advices; + private final Type[] spi; + private final Enabled enabled; + private final Type[] helpers; + + public CallSiteSpecification( + @Nonnull final Type clazz, + @Nonnull final List advices, + @Nonnull final Set spi, + @Nonnull final List enabled, + @Nonnull final Set helpers) { + this.clazz = clazz; + this.advices = advices; + this.spi = spi.toArray(new Type[0]); + this.enabled = enabled.isEmpty() ? null : new Enabled(enabled); + this.helpers = helpers.toArray(new Type[0]); } @Override public void validate(@Nonnull final ValidationContext context) { - validatePointcut(context); - validateAdvice(context); - validateCompatibility(context); - } - - protected void validateCompatibility(final ValidationContext context) { - try { - final Type[] adviceArgumentTypes = advice.getMethodType().getArgumentTypes(); - final Set pointcutParameters = new HashSet<>(); - for (int i = 0; i < pointcut.getMethodType().getArgumentTypes().length; i++) { - pointcutParameters.add(i); - } - validateAllArgsSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); - if (isInvokeDynamic()) { - validateInvokeDynamicConstCompatibility(context, adviceArgumentTypes, pointcutParameters); - if (this instanceof AroundSpecification) { - validateArgumentSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); - } - } else { - validateAdviceReturnTypeCompatibility(context); - validateThisSpecCompatibility(context, adviceArgumentTypes); - validateArgumentSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); - validateReturnSpecCompatibility(context, adviceArgumentTypes); - } - } catch (ResolutionException e) { - e.getErrors().forEach(context::addError); - } + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + try { + for (Type spiType : spi) { + Class spiClass = typeResolver.resolveType(spiType); + if (!spiClass.isInterface()) { + context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE, spiClass); + } else { + if (spiClass.getDeclaredMethods().length > 0) { + context.addError(ErrorCode.CALL_SITE_SPI_SHOULD_BE_EMPTY, spiClass); + } + } + } + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + if (enabled != null) { + try { + final Method enabledMethod = (Method) typeResolver.resolveMethod(enabled.getMethod()); + final int modifiers = enabledMethod.getModifiers(); + if (!Modifier.isPublic(modifiers) || !Modifier.isStatic(modifiers)) { + context.addError(ErrorCode.CALL_SITE_ENABLED_SHOULD_BE_STATIC_AND_PUBLIC, enabledMethod); + } + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + } + if (advices.isEmpty()) { + context.addError(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS); + } } - private void validateArgumentSpecCompatibility( - final ValidationContext context, - final Type[] adviceArgumentTypes, - final Set pointcutParameters) { - withParameter( - ArgumentSpecification.class, - (i, spec) -> { - final Type argType = pointcut.getMethodType().getArgumentTypes()[spec.index]; - final Type advice = adviceArgumentTypes[i]; - if (!pointcutParameters.remove(spec.index)) { - context.addError(ErrorCode.ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS); - } - validateCompatibility( - context, argType, advice, ErrorCode.ADVICE_METHOD_PARAM_NOT_COMPATIBLE, i); - }); + public Type getClazz() { + return clazz; } - private void validateReturnSpecCompatibility( - final ValidationContext context, final Type[] adviceArgumentTypes) { - withParameter( - ReturnSpecification.class, - (i, spec) -> { - final Type rType = - pointcut.isConstructor() - ? pointcut.getOwner() - : pointcut.getMethodType().getReturnType(); - final Type advice = adviceArgumentTypes[i]; - validateCompatibility( - context, rType, advice, ErrorCode.ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE, i); - }); + public Type[] getSpi() { + return spi; } - private void validateThisSpecCompatibility( - final ValidationContext context, final Type[] adviceArgumentTypes) { - withParameter( - ThisSpecification.class, - (i, spec) -> { - final Type owner = pointcut.getOwner(); - final Type advice = adviceArgumentTypes[i]; - validateCompatibility( - context, owner, advice, ErrorCode.ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE, i); - }); + public Enabled getEnabled() { + return enabled; } - protected void validateAdviceReturnTypeCompatibility(final ValidationContext context) { - if (!advice.isVoidReturn()) { - final Type pointcutType = - pointcut.isConstructor() - ? pointcut.getOwner() - : pointcut.getMethodType().getReturnType(); - final Type adviceType = advice.getMethodType().getReturnType(); - validateCompatibility( - context, pointcutType, adviceType, ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE, -1); - } + public Type[] getHelpers() { + return helpers; } - private void validateInvokeDynamicConstCompatibility( - final ValidationContext context, - final Type[] adviceArgumentTypes, - final Set pointcutParameters) { - withParameter( - InvokeDynamicConstantsSpecification.class, - (i, spec) -> { - final Type type = Types.OBJECT_ARRAY; - final Type advice = adviceArgumentTypes[i]; - pointcutParameters.clear(); - validateCompatibility( - context, - type, - advice, - ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NOT_COMPATIBLE, - i); - }); + public List getAdvices() { + return advices; } - private void validateAllArgsSpecCompatibility( - final ValidationContext context, - final Type[] adviceArgumentTypes, - final Set pointcutParameters) { - withParameter( - AllArgsSpecification.class, - (i, spec) -> { - final Type type = Types.OBJECT_ARRAY; - final Type advice = adviceArgumentTypes[i]; - pointcutParameters.clear(); - validateCompatibility( - context, type, advice, ErrorCode.ADVICE_METHOD_PARAM_ALL_ARGS_NOT_COMPATIBLE, i); - }); - } + /** + * Description of a method annotated with {@link datadog.trace.agent.tooling.csi.CallSite.After}, + * {@link datadog.trace.agent.tooling.csi.CallSite.Before} or {@link + * datadog.trace.agent.tooling.csi.CallSite.Around} + */ + public abstract static class AdviceSpecification implements Validatable { - protected void validateCompatibility( - final ValidationContext context, - final Type pointcutType, - final Type adviceType, - final ErrorCode errorCode, - final int index) { - final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); - if (!typeResolver - .resolveType(adviceType) - .isAssignableFrom(typeResolver.resolveType(pointcutType))) { - context.addError(errorCode, pointcutType, adviceType, index); - } - } + protected final MethodType advice; + private final Map parameters; + protected final String signature; + protected final boolean invokeDynamic; + protected MethodType pointcut; + protected Executable pointcutMethod; - protected void validateAdvice(@Nonnull final ValidationContext context) { - try { - validateAdviceParameters(context); - final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); - final Method executable = (Method) typeResolver.resolveMethod(advice); - final int access = executable.getModifiers(); - if (!Modifier.isPublic(access) || !Modifier.isStatic(access)) { - context.addError(ErrorCode.ADVICE_METHOD_NOT_STATIC_AND_PUBLIC, this); - } - } catch (ResolutionException e) { - e.getErrors().forEach(context::addError); - } - } + public AdviceSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + this.advice = advice; + this.parameters = new TreeMap<>(parameters); + this.signature = signature; + this.invokeDynamic = invokeDynamic; + } - protected void validateAdviceParameters(@Nonnull final ValidationContext context) { - final Type[] adviceArguments = advice.getMethodType().getArgumentTypes(); - boolean thisFound = false, - returnFound = false, - allArgsFound = false, - argumentFound = false, - dynamicConstantsFound = false; - for (int i = 0; i < adviceArguments.length; i++) { - ParameterSpecification spec = parameters.get(i); - if (spec == null) { - context.addError(ErrorCode.ADVICE_PARAMETER_NOT_ANNOTATED, i); - } else { - if (spec instanceof ThisSpecification) { - validateThisSpec(context, thisFound, i); - thisFound = true; - } else if (spec instanceof ReturnSpecification) { - validateReturnSpec(context, returnFound, i); - returnFound = true; - } else if (spec instanceof AllArgsSpecification) { - validateAllArgsSpec(context, allArgsFound, argumentFound, i); - allArgsFound = true; - } else if (spec instanceof InvokeDynamicConstantsSpecification) { - validateInvokeDynamicConstSpec(context, dynamicConstantsFound, i); - dynamicConstantsFound = true; - } else { - validateArgumentSpec(context, allArgsFound, i); - argumentFound = true; - } - } - } - } + @Override + public void validate(@Nonnull final ValidationContext context) { + validatePointcut(context); + validateAdvice(context); + validateCompatibility(context); + } - private void validateArgumentSpec( - final ValidationContext context, final boolean allArgsFound, final int i) { - if (allArgsFound) { - context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED, i); - } - if (isInvokeDynamic() && !(this instanceof AroundSpecification)) { - context.addError(ErrorCode.ADVICE_PARAMETER_ON_INVOKE_DYNAMIC, i); - } - } + protected void validateCompatibility(final ValidationContext context) { + try { + final Type[] adviceArgumentTypes = advice.getMethodType().getArgumentTypes(); + final Set pointcutParameters = new HashSet<>(); + for (int i = 0; i < pointcut.getMethodType().getArgumentTypes().length; i++) { + pointcutParameters.add(i); + } + validateAllArgsSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); + if (isInvokeDynamic()) { + validateInvokeDynamicConstCompatibility(context, adviceArgumentTypes, pointcutParameters); + if (this instanceof AroundSpecification) { + validateArgumentSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); + } + } else { + validateAdviceReturnTypeCompatibility(context); + validateThisSpecCompatibility(context, adviceArgumentTypes); + validateArgumentSpecCompatibility(context, adviceArgumentTypes, pointcutParameters); + validateReturnSpecCompatibility(context, adviceArgumentTypes); + } + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + } - private void validateInvokeDynamicConstSpec( - final ValidationContext context, final boolean dynamicConstantsFound, final int i) { - if (dynamicConstantsFound) { - context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_DUPLICATED, i); - } - if (!isInvokeDynamic()) { - context.addError( - ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC, i); - } - if (!(this instanceof AfterSpecification)) { - context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE, i); - } - final Type[] arguments = advice.getMethodType().getArgumentTypes(); - if (i != arguments.length - 1) { - context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST, i); - } - } + private void validateArgumentSpecCompatibility( + final ValidationContext context, + final Type[] adviceArgumentTypes, + final Set pointcutParameters) { + withParameter(ArgumentSpecification.class, (i, spec) -> { + final Type argType = pointcut.getMethodType().getArgumentTypes()[spec.index]; + final Type advice = adviceArgumentTypes[i]; + if (!pointcutParameters.remove(spec.index)) { + context.addError(ErrorCode.ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS); + } + validateCompatibility(context, argType, advice, ErrorCode.ADVICE_METHOD_PARAM_NOT_COMPATIBLE, i); + }); + } - private void validateAllArgsSpec( - final ValidationContext context, - final boolean allArgsFound, - final boolean argumentFound, - final int i) { - if (allArgsFound) { - context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_DUPLICATED, i); - } - if (argumentFound) { - context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED, i); - } - } + private void validateReturnSpecCompatibility( + final ValidationContext context, final Type[] adviceArgumentTypes) { + withParameter(ReturnSpecification.class, (i, spec) -> { + final Type rType = pointcut.isConstructor() + ? pointcut.getOwner() + : pointcut.getMethodType().getReturnType(); + final Type advice = adviceArgumentTypes[i]; + validateCompatibility(context, rType, advice, ErrorCode.ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE, i); + }); + } - private void validateReturnSpec( - final ValidationContext context, final boolean returnFound, final int i) { - if (returnFound) { - context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_DUPLICATED, i); - } - final Type[] arguments = advice.getMethodType().getArgumentTypes(); - if (i != arguments.length - 1) { - if (isInvokeDynamic()) { - if (i != arguments.length - 2 - && !(parameters.get(arguments.length - 1) - instanceof InvokeDynamicConstantsSpecification)) {} - - } else { - context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST, i); - } - } - if (!(this instanceof AfterSpecification)) { - context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_NON_AFTER_ADVICE, i); - } - } + private void validateThisSpecCompatibility(final ValidationContext context, final Type[] adviceArgumentTypes) { + withParameter(ThisSpecification.class, (i, spec) -> { + final Type owner = pointcut.getOwner(); + final Type advice = adviceArgumentTypes[i]; + validateCompatibility(context, owner, advice, ErrorCode.ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE, i); + }); + } - private void validateThisSpec( - final ValidationContext context, final boolean thisFound, final int i) { - if (thisFound) { - context.addError(ErrorCode.ADVICE_PARAMETER_THIS_DUPLICATED, i); - } - if (i != 0) { - context.addError(ErrorCode.ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST, i); - } - if (isStaticPointcut()) { - context.addError(ErrorCode.ADVICE_PARAMETER_THIS_ON_STATIC_METHOD, i); - } - if (isInvokeDynamic()) { - context.addError(ErrorCode.ADVICE_PARAMETER_THIS_ON_INVOKE_DYNAMIC, i); - } - } + protected void validateAdviceReturnTypeCompatibility(final ValidationContext context) { + if (!advice.isVoidReturn()) { + final Type pointcutType = pointcut.isConstructor() + ? pointcut.getOwner() + : pointcut.getMethodType().getReturnType(); + final Type adviceType = advice.getMethodType().getReturnType(); + validateCompatibility( + context, pointcutType, adviceType, ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE, -1); + } + } - protected void validatePointcut(@Nonnull final ValidationContext context) { - if (pointcut != null) { - try { - final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); - if (pointcut.isConstructor() && !pointcut.isVoidReturn()) { - context.addError(ErrorCode.ADVICE_POINTCUT_CONSTRUCTOR_NOT_VOID, pointcut); - } - pointcutMethod = typeResolver.resolveMethod(pointcut); - } catch (ResolutionException e) { - e.getErrors().forEach(context::addError); + private void validateInvokeDynamicConstCompatibility( + final ValidationContext context, + final Type[] adviceArgumentTypes, + final Set pointcutParameters) { + withParameter(InvokeDynamicConstantsSpecification.class, (i, spec) -> { + final Type type = Types.OBJECT_ARRAY; + final Type advice = adviceArgumentTypes[i]; + pointcutParameters.clear(); + validateCompatibility( + context, type, advice, ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NOT_COMPATIBLE, i); + }); } - } - } - public void parseSignature(@Nonnull final AdvicePointcutParser parser) { - pointcut = parser.parse(signature); - } + private void validateAllArgsSpecCompatibility( + final ValidationContext context, + final Type[] adviceArgumentTypes, + final Set pointcutParameters) { + withParameter(AllArgsSpecification.class, (i, spec) -> { + final Type type = Types.OBJECT_ARRAY; + final Type advice = adviceArgumentTypes[i]; + pointcutParameters.clear(); + validateCompatibility(context, type, advice, ErrorCode.ADVICE_METHOD_PARAM_ALL_ARGS_NOT_COMPATIBLE, i); + }); + } - public MethodType getAdvice() { - return advice; - } + protected void validateCompatibility( + final ValidationContext context, + final Type pointcutType, + final Type adviceType, + final ErrorCode errorCode, + final int index) { + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + if (!typeResolver.resolveType(adviceType).isAssignableFrom(typeResolver.resolveType(pointcutType))) { + context.addError(errorCode, pointcutType, adviceType, index); + } + } - public MethodType getPointcut() { - return pointcut; - } + protected void validateAdvice(@Nonnull final ValidationContext context) { + try { + validateAdviceParameters(context); + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + final Method executable = (Method) typeResolver.resolveMethod(advice); + final int access = executable.getModifiers(); + if (!Modifier.isPublic(access) || !Modifier.isStatic(access)) { + context.addError(ErrorCode.ADVICE_METHOD_NOT_STATIC_AND_PUBLIC, this); + } + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + } - public String getSignature() { - return signature; - } + protected void validateAdviceParameters(@Nonnull final ValidationContext context) { + final Type[] adviceArguments = advice.getMethodType().getArgumentTypes(); + boolean thisFound = false, + returnFound = false, + allArgsFound = false, + argumentFound = false, + dynamicConstantsFound = false; + for (int i = 0; i < adviceArguments.length; i++) { + ParameterSpecification spec = parameters.get(i); + if (spec == null) { + context.addError(ErrorCode.ADVICE_PARAMETER_NOT_ANNOTATED, i); + } else { + if (spec instanceof ThisSpecification) { + validateThisSpec(context, thisFound, i); + thisFound = true; + } else if (spec instanceof ReturnSpecification) { + validateReturnSpec(context, returnFound, i); + returnFound = true; + } else if (spec instanceof AllArgsSpecification) { + validateAllArgsSpec(context, allArgsFound, argumentFound, i); + allArgsFound = true; + } else if (spec instanceof InvokeDynamicConstantsSpecification) { + validateInvokeDynamicConstSpec(context, dynamicConstantsFound, i); + dynamicConstantsFound = true; + } else { + validateArgumentSpec(context, allArgsFound, i); + argumentFound = true; + } + } + } + } - public boolean isStaticPointcut() { - return pointcutMethod != null && Modifier.isStatic(pointcutMethod.getModifiers()); - } + private void validateArgumentSpec(final ValidationContext context, final boolean allArgsFound, final int i) { + if (allArgsFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED, i); + } + if (isInvokeDynamic() && !(this instanceof AroundSpecification)) { + context.addError(ErrorCode.ADVICE_PARAMETER_ON_INVOKE_DYNAMIC, i); + } + } - public boolean isInvokeDynamic() { - return invokeDynamic; - } + private void validateInvokeDynamicConstSpec( + final ValidationContext context, final boolean dynamicConstantsFound, final int i) { + if (dynamicConstantsFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_DUPLICATED, i); + } + if (!isInvokeDynamic()) { + context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC, i); + } + if (!(this instanceof AfterSpecification)) { + context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE, i); + } + final Type[] arguments = advice.getMethodType().getArgumentTypes(); + if (i != arguments.length - 1) { + context.addError(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST, i); + } + } - /* Whether not all of the pointcut arguments are consumed or they're not - * consumed in sequential order or there are no pointcut arguments at all */ - public boolean isPositionalArguments() { - if (parameters.isEmpty()) { - return true; - } - if (pointcut.getMethodType().getArgumentTypes().length != getArguments().count()) { - return true; - } - - Iterator iterator = getArguments().iterator(); - int i = 0; - while (iterator.hasNext()) { - ArgumentSpecification spec = iterator.next(); - if (spec.getIndex() != i) { - return true; - } - i++; - } - return false; - } + private void validateAllArgsSpec( + final ValidationContext context, final boolean allArgsFound, final boolean argumentFound, final int i) { + if (allArgsFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_DUPLICATED, i); + } + if (argumentFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED, i); + } + } - public boolean includeThis() { - if (findThis() != null) { - return true; - } - final AllArgsSpecification allArgs = findAllArguments(); - return allArgs != null && allArgs.includeThis; - } + private void validateReturnSpec(final ValidationContext context, final boolean returnFound, final int i) { + if (returnFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_DUPLICATED, i); + } + final Type[] arguments = advice.getMethodType().getArgumentTypes(); + if (i != arguments.length - 1) { + if (isInvokeDynamic()) { + if (i != arguments.length - 2 + && !(parameters.get(arguments.length - 1) + instanceof InvokeDynamicConstantsSpecification)) {} + + } else { + context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST, i); + } + } + if (!(this instanceof AfterSpecification)) { + context.addError(ErrorCode.ADVICE_PARAMETER_RETURN_NON_AFTER_ADVICE, i); + } + } - public ThisSpecification findThis() { - return findParameter(ThisSpecification.class); - } + private void validateThisSpec(final ValidationContext context, final boolean thisFound, final int i) { + if (thisFound) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_DUPLICATED, i); + } + if (i != 0) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST, i); + } + if (isStaticPointcut()) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_ON_STATIC_METHOD, i); + } + if (isInvokeDynamic()) { + context.addError(ErrorCode.ADVICE_PARAMETER_THIS_ON_INVOKE_DYNAMIC, i); + } + } - public ReturnSpecification findReturn() { - return findParameter(ReturnSpecification.class); - } + protected void validatePointcut(@Nonnull final ValidationContext context) { + if (pointcut != null) { + try { + final TypeResolver typeResolver = context.getContextProperty(TYPE_RESOLVER); + if (pointcut.isConstructor() && !pointcut.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_POINTCUT_CONSTRUCTOR_NOT_VOID, pointcut); + } + pointcutMethod = typeResolver.resolveMethod(pointcut); + } catch (ResolutionException e) { + e.getErrors().forEach(context::addError); + } + } + } - public AllArgsSpecification findAllArguments() { - return findParameter(AllArgsSpecification.class); - } + public void parseSignature(@Nonnull final AdvicePointcutParser parser) { + pointcut = parser.parse(signature); + } - public InvokeDynamicConstantsSpecification findInvokeDynamicConstants() { - return findParameter(InvokeDynamicConstantsSpecification.class); - } + public MethodType getAdvice() { + return advice; + } - private void withParameter( - final Class spec, final BiConsumer consumer) { - parameters.entrySet().stream() - .filter(entry -> spec.isInstance(entry.getValue())) - .forEach(entry -> consumer.accept(entry.getKey(), spec.cast(entry.getValue()))); - } + public MethodType getPointcut() { + return pointcut; + } - private E findParameter(final Class spec) { - return parameters.values().stream() - .filter(spec::isInstance) - .map(spec::cast) - .findFirst() - .orElse(null); - } + public String getSignature() { + return signature; + } - public boolean isConstructor() { - return pointcut.isConstructor(); - } + public boolean isStaticPointcut() { + return pointcutMethod != null && Modifier.isStatic(pointcutMethod.getModifiers()); + } - public Stream getArguments() { - return parameters.values().stream() - .filter(it -> it instanceof ArgumentSpecification) - .map(it -> (ArgumentSpecification) it); - } - } - - public static final class BeforeSpecification extends AdviceSpecification { - public BeforeSpecification( - @Nonnull final MethodType advice, - @Nonnull final Map parameters, - @Nonnull final String signature, - final boolean invokeDynamic) { - super(advice, parameters, signature, invokeDynamic); - } + public boolean isInvokeDynamic() { + return invokeDynamic; + } - @Override - protected void validateAdvice(@Nonnull final ValidationContext context) { - if (!advice.isVoidReturn()) { - context.addError(ErrorCode.ADVICE_BEFORE_SHOULD_RETURN_VOID, advice); - } - if (findReturn() != null) { - context.addError(ErrorCode.ADVICE_BEFORE_SHOULD_NOT_CONTAIN_RETURN); - } - if (pointcut.isConstructor() && includeThis()) { - context.addError(ErrorCode.ADVICE_BEFORE_CTOR_SHOULD_NOT_CONTAIN_THIS); - } - super.validateAdvice(context); - } + /* Whether not all of the pointcut arguments are consumed or they're not + * consumed in sequential order or there are no pointcut arguments at all */ + public boolean isPositionalArguments() { + if (parameters.isEmpty()) { + return true; + } + if (pointcut.getMethodType().getArgumentTypes().length + != getArguments().count()) { + return true; + } - @Override - public String toString() { - return "@CallSite.Before(" + signature + ")"; - } - } - - public static final class AroundSpecification extends AdviceSpecification { - public AroundSpecification( - @Nonnull final MethodType advice, - @Nonnull final Map parameters, - @Nonnull final String signature, - final boolean invokeDynamic) { - super(advice, parameters, signature, invokeDynamic); - } + Iterator iterator = getArguments().iterator(); + int i = 0; + while (iterator.hasNext()) { + ArgumentSpecification spec = iterator.next(); + if (spec.getIndex() != i) { + return true; + } + i++; + } + return false; + } - @Override - protected void validateAdvice(@Nonnull final ValidationContext context) { - if (advice.isVoidReturn()) { - context.addError(ErrorCode.ADVICE_AROUND_SHOULD_NOT_RETURN_VOID); - } - if (findReturn() != null) { - context.addError(ErrorCode.ADVICE_AROUND_SHOULD_NOT_CONTAIN_RETURN); - } - super.validateAdvice(context); - } + public boolean includeThis() { + if (findThis() != null) { + return true; + } + final AllArgsSpecification allArgs = findAllArguments(); + return allArgs != null && allArgs.includeThis; + } - @Override - protected void validatePointcut(@Nonnull final ValidationContext context) { - if (pointcut.isConstructor()) { - context.addError(ErrorCode.ADVICE_AROUND_POINTCUT_CTOR); - } - super.validatePointcut(context); - } + public ThisSpecification findThis() { + return findParameter(ThisSpecification.class); + } - @Override - public String toString() { - return "@CallSite.Around(" + signature + ")"; - } - } - - public static final class AfterSpecification extends AdviceSpecification { - public AfterSpecification( - @Nonnull final MethodType advice, - @Nonnull final Map parameters, - @Nonnull final String signature, - final boolean invokeDynamic) { - super(advice, parameters, signature, invokeDynamic); - } + public ReturnSpecification findReturn() { + return findParameter(ReturnSpecification.class); + } - @Override - protected void validateAdvice(@Nonnull final ValidationContext context) { - if (shouldNotUseReturn(pointcut)) { - if (!advice.isVoidReturn()) { - context.addError(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_RETURN_VOID); + public AllArgsSpecification findAllArguments() { + return findParameter(AllArgsSpecification.class); } - if (findReturn() != null) { - context.addError(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_NOT_HAVE_RETURN); + + public InvokeDynamicConstantsSpecification findInvokeDynamicConstants() { + return findParameter(InvokeDynamicConstantsSpecification.class); } - } else { - if (advice.isVoidReturn()) { - context.addError(ErrorCode.ADVICE_AFTER_SHOULD_NOT_RETURN_VOID); + + private void withParameter( + final Class spec, final BiConsumer consumer) { + parameters.entrySet().stream() + .filter(entry -> spec.isInstance(entry.getValue())) + .forEach(entry -> consumer.accept(entry.getKey(), spec.cast(entry.getValue()))); } - if (findReturn() == null) { - context.addError(ErrorCode.ADVICE_AFTER_SHOULD_HAVE_RETURN); + + private E findParameter(final Class spec) { + return parameters.values().stream() + .filter(spec::isInstance) + .map(spec::cast) + .findFirst() + .orElse(null); } - } - if (!pointcut.isConstructor()) { - if (!isStaticPointcut() && !includeThis()) { - context.addError(ErrorCode.ADVICE_AFTER_SHOULD_HAVE_THIS); + + public boolean isConstructor() { + return pointcut.isConstructor(); } - } else { - if (findAllArguments() == null) { - context.addError(ErrorCode.ADVICE_AFTER_CONSTRUCTOR_ALL_ARGUMENTS); + + public Stream getArguments() { + return parameters.values().stream() + .filter(it -> it instanceof ArgumentSpecification) + .map(it -> (ArgumentSpecification) it); } - } - super.validateAdvice(context); } - private boolean shouldNotUseReturn(final MethodType type) { - return !type.isConstructor() && type.isVoidReturn(); - } + public static final class BeforeSpecification extends AdviceSpecification { + public BeforeSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + super(advice, parameters, signature, invokeDynamic); + } - @Override - public String toString() { - return "@CallSite.After(" + signature + ")"; - } - } - - /** - * Description of a method parameter annotated with: {@link - * datadog.trace.agent.tooling.csi.CallSite.This}, {@link - * datadog.trace.agent.tooling.csi.CallSite.Argument}, {@link - * datadog.trace.agent.tooling.csi.CallSite.AllArguments}, {@link - * datadog.trace.agent.tooling.csi.CallSite.InvokeDynamicConstants}, {@link - * datadog.trace.agent.tooling.csi.CallSite.Return} - */ - public abstract static class ParameterSpecification {} - - public static final class ThisSpecification extends ParameterSpecification { - @Override - public String toString() { - return "@This"; - } - } + @Override + protected void validateAdvice(@Nonnull final ValidationContext context) { + if (!advice.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_BEFORE_SHOULD_RETURN_VOID, advice); + } + if (findReturn() != null) { + context.addError(ErrorCode.ADVICE_BEFORE_SHOULD_NOT_CONTAIN_RETURN); + } + if (pointcut.isConstructor() && includeThis()) { + context.addError(ErrorCode.ADVICE_BEFORE_CTOR_SHOULD_NOT_CONTAIN_THIS); + } + super.validateAdvice(context); + } - public static final class ReturnSpecification extends ParameterSpecification { - @Override - public String toString() { - return "@Return"; + @Override + public String toString() { + return "@CallSite.Before(" + signature + ")"; + } } - } - public static final class AllArgsSpecification extends ParameterSpecification { + public static final class AroundSpecification extends AdviceSpecification { + public AroundSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + super(advice, parameters, signature, invokeDynamic); + } - private boolean includeThis; + @Override + protected void validateAdvice(@Nonnull final ValidationContext context) { + if (advice.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_AROUND_SHOULD_NOT_RETURN_VOID); + } + if (findReturn() != null) { + context.addError(ErrorCode.ADVICE_AROUND_SHOULD_NOT_CONTAIN_RETURN); + } + super.validateAdvice(context); + } - public boolean isIncludeThis() { - return includeThis; - } + @Override + protected void validatePointcut(@Nonnull final ValidationContext context) { + if (pointcut.isConstructor()) { + context.addError(ErrorCode.ADVICE_AROUND_POINTCUT_CTOR); + } + super.validatePointcut(context); + } - public void setIncludeThis(boolean includeThis) { - this.includeThis = includeThis; + @Override + public String toString() { + return "@CallSite.Around(" + signature + ")"; + } } - @Override - public String toString() { - return "@AllArguments(includeThis=" + includeThis + ")"; - } - } + public static final class AfterSpecification extends AdviceSpecification { + public AfterSpecification( + @Nonnull final MethodType advice, + @Nonnull final Map parameters, + @Nonnull final String signature, + final boolean invokeDynamic) { + super(advice, parameters, signature, invokeDynamic); + } - public static final class ArgumentSpecification extends ParameterSpecification { + @Override + protected void validateAdvice(@Nonnull final ValidationContext context) { + if (shouldNotUseReturn(pointcut)) { + if (!advice.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_RETURN_VOID); + } + if (findReturn() != null) { + context.addError(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_NOT_HAVE_RETURN); + } + } else { + if (advice.isVoidReturn()) { + context.addError(ErrorCode.ADVICE_AFTER_SHOULD_NOT_RETURN_VOID); + } + if (findReturn() == null) { + context.addError(ErrorCode.ADVICE_AFTER_SHOULD_HAVE_RETURN); + } + } + if (!pointcut.isConstructor()) { + if (!isStaticPointcut() && !includeThis()) { + context.addError(ErrorCode.ADVICE_AFTER_SHOULD_HAVE_THIS); + } + } else { + if (findAllArguments() == null) { + context.addError(ErrorCode.ADVICE_AFTER_CONSTRUCTOR_ALL_ARGUMENTS); + } + } + super.validateAdvice(context); + } - private int index; + private boolean shouldNotUseReturn(final MethodType type) { + return !type.isConstructor() && type.isVoidReturn(); + } - public int getIndex() { - return index; + @Override + public String toString() { + return "@CallSite.After(" + signature + ")"; + } } - public void setIndex(final int index) { - this.index = index; + /** + * Description of a method parameter annotated with: {@link + * datadog.trace.agent.tooling.csi.CallSite.This}, {@link + * datadog.trace.agent.tooling.csi.CallSite.Argument}, {@link + * datadog.trace.agent.tooling.csi.CallSite.AllArguments}, {@link + * datadog.trace.agent.tooling.csi.CallSite.InvokeDynamicConstants}, {@link + * datadog.trace.agent.tooling.csi.CallSite.Return} + */ + public abstract static class ParameterSpecification {} + + public static final class ThisSpecification extends ParameterSpecification { + @Override + public String toString() { + return "@This"; + } } - @Override - public String toString() { - return "@Argument(" + index + ")"; + public static final class ReturnSpecification extends ParameterSpecification { + @Override + public String toString() { + return "@Return"; + } } - } - public static final class InvokeDynamicConstantsSpecification extends ParameterSpecification { - @Override - public String toString() { - return "@InvokeDynamicConstants"; - } - } - - public static final class Enabled { - private final MethodType method; - private final List arguments; - - public Enabled(final List enabled) { - this.arguments = enabled.size() <= 2 ? emptyList() : enabled.subList(2, enabled.size()); - this.method = - new MethodType( - classNameToType(enabled.get(0)), - enabled.get(1), - Type.getMethodType(Types.BOOLEAN, stringTypeArray(arguments.size()))); + public static final class AllArgsSpecification extends ParameterSpecification { + + private boolean includeThis; + + public boolean isIncludeThis() { + return includeThis; + } + + public void setIncludeThis(boolean includeThis) { + this.includeThis = includeThis; + } + + @Override + public String toString() { + return "@AllArguments(includeThis=" + includeThis + ")"; + } } - public MethodType getMethod() { - return method; + public static final class ArgumentSpecification extends ParameterSpecification { + + private int index; + + public int getIndex() { + return index; + } + + public void setIndex(final int index) { + this.index = index; + } + + @Override + public String toString() { + return "@Argument(" + index + ")"; + } } - public List getArguments() { - return arguments; + public static final class InvokeDynamicConstantsSpecification extends ParameterSpecification { + @Override + public String toString() { + return "@InvokeDynamicConstants"; + } } - private Type[] stringTypeArray(final int size) { - final Type[] types = new Type[size]; - Arrays.fill(types, Types.STRING); - return types; + public static final class Enabled { + private final MethodType method; + private final List arguments; + + public Enabled(final List enabled) { + this.arguments = enabled.size() <= 2 ? emptyList() : enabled.subList(2, enabled.size()); + this.method = new MethodType( + classNameToType(enabled.get(0)), + enabled.get(1), + Type.getMethodType(Types.BOOLEAN, stringTypeArray(arguments.size()))); + } + + public MethodType getMethod() { + return method; + } + + public List getArguments() { + return arguments; + } + + private Type[] stringTypeArray(final int size) { + final Type[] types = new Type[size]; + Arrays.fill(types, Types.STRING); + return types; + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java index d3205dfd0d3..cf488e45e21 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParser.java @@ -23,120 +23,110 @@ */ public class RegexpAdvicePointcutParser implements AdvicePointcutParser { - private static final Pattern ADVICE_SIGNATURE_PATTERN = - Pattern.compile("^(?\\S*)\\s+(?\\S*)\\.(?\\S*)\\s*\\((?.*)\\)$"); - private static final char ARRAY_DESCRIPTOR = '['; - private static final Map PRIMITIVE_TYPES = new HashMap<>(9); + private static final Pattern ADVICE_SIGNATURE_PATTERN = + Pattern.compile("^(?\\S*)\\s+(?\\S*)\\.(?\\S*)\\s*\\((?.*)\\)$"); + private static final char ARRAY_DESCRIPTOR = '['; + private static final Map PRIMITIVE_TYPES = new HashMap<>(9); - static { - PRIMITIVE_TYPES.put("byte", Type.BYTE_TYPE); - PRIMITIVE_TYPES.put("char", Type.CHAR_TYPE); - PRIMITIVE_TYPES.put("double", Type.DOUBLE_TYPE); - PRIMITIVE_TYPES.put("float", Type.FLOAT_TYPE); - PRIMITIVE_TYPES.put("int", Type.INT_TYPE); - PRIMITIVE_TYPES.put("long", Type.LONG_TYPE); - PRIMITIVE_TYPES.put("short", Type.SHORT_TYPE); - PRIMITIVE_TYPES.put("boolean", Type.BOOLEAN_TYPE); - PRIMITIVE_TYPES.put("void", Type.VOID_TYPE); - } - - @Override - @Nonnull - public MethodType parse(@Nonnull final String signature) { - final Matcher matcher = ADVICE_SIGNATURE_PATTERN.matcher(signature.trim()); - if (!matcher.matches()) { - final String pattern = ADVICE_SIGNATURE_PATTERN.pattern(); - throw new SignatureParsingError( - new Failure(ErrorCode.POINTCUT_SIGNATURE_INVALID, signature, pattern)); - } - final HasErrors errors = new HasErrorsImpl(); - final Type target = parseTarget(signature, matcher, errors); - final Type returnType = parseReturn(signature, matcher, errors); - final Type[] argTypes = parseArguments(signature, matcher, errors); - if (!errors.isSuccess()) { - throw new SignatureParsingError(errors); + static { + PRIMITIVE_TYPES.put("byte", Type.BYTE_TYPE); + PRIMITIVE_TYPES.put("char", Type.CHAR_TYPE); + PRIMITIVE_TYPES.put("double", Type.DOUBLE_TYPE); + PRIMITIVE_TYPES.put("float", Type.FLOAT_TYPE); + PRIMITIVE_TYPES.put("int", Type.INT_TYPE); + PRIMITIVE_TYPES.put("long", Type.LONG_TYPE); + PRIMITIVE_TYPES.put("short", Type.SHORT_TYPE); + PRIMITIVE_TYPES.put("boolean", Type.BOOLEAN_TYPE); + PRIMITIVE_TYPES.put("void", Type.VOID_TYPE); } - final String method = matcher.group("method").trim(); - assert target != null && returnType != null; - return new MethodType(target, method, Type.getMethodType(returnType, argTypes)); - } - private static Type parseTarget( - @Nonnull final String signature, - @Nonnull final Matcher matcher, - @Nonnull final HasErrors errors) { - final String typeName = matcher.group("type"); - try { - return parseType(typeName); - } catch (Throwable e) { - errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, typeName); - return null; + @Override + @Nonnull + public MethodType parse(@Nonnull final String signature) { + final Matcher matcher = ADVICE_SIGNATURE_PATTERN.matcher(signature.trim()); + if (!matcher.matches()) { + final String pattern = ADVICE_SIGNATURE_PATTERN.pattern(); + throw new SignatureParsingError(new Failure(ErrorCode.POINTCUT_SIGNATURE_INVALID, signature, pattern)); + } + final HasErrors errors = new HasErrorsImpl(); + final Type target = parseTarget(signature, matcher, errors); + final Type returnType = parseReturn(signature, matcher, errors); + final Type[] argTypes = parseArguments(signature, matcher, errors); + if (!errors.isSuccess()) { + throw new SignatureParsingError(errors); + } + final String method = matcher.group("method").trim(); + assert target != null && returnType != null; + return new MethodType(target, method, Type.getMethodType(returnType, argTypes)); } - } - private static Type parseReturn( - @Nonnull final String signature, - @Nonnull final Matcher matcher, - @Nonnull final HasErrors errors) { - final String returnTypeName = matcher.group("return").trim(); - try { - return parseType(returnTypeName); - } catch (Throwable e) { - errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, returnTypeName); - return null; + private static Type parseTarget( + @Nonnull final String signature, @Nonnull final Matcher matcher, @Nonnull final HasErrors errors) { + final String typeName = matcher.group("type"); + try { + return parseType(typeName); + } catch (Throwable e) { + errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, typeName); + return null; + } } - } - private static Type[] parseArguments( - @Nonnull final String signature, - @Nonnull final Matcher matcher, - @Nonnull final HasErrors errors) { - final String argsGroup = matcher.group("args"); - final String names = argsGroup == null ? "" : argsGroup.trim(); - if (names.isEmpty()) { - return new Type[0]; + private static Type parseReturn( + @Nonnull final String signature, @Nonnull final Matcher matcher, @Nonnull final HasErrors errors) { + final String returnTypeName = matcher.group("return").trim(); + try { + return parseType(returnTypeName); + } catch (Throwable e) { + errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, returnTypeName); + return null; + } } - String[] argNames = names.split(","); - Type[] result = new Type[argNames.length]; - for (int i = 0; i < argNames.length; i++) { - String argTypeName = argNames[i]; - try { - result[i] = parseType(argTypeName.trim()); - } catch (Throwable e) { - errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, argTypeName); - } - } - return result; - } - private static Type parseType(final String name) { - if (name == null || name.isEmpty()) { - throw new IllegalArgumentException("Type name cannot be null or empty"); + private static Type[] parseArguments( + @Nonnull final String signature, @Nonnull final Matcher matcher, @Nonnull final HasErrors errors) { + final String argsGroup = matcher.group("args"); + final String names = argsGroup == null ? "" : argsGroup.trim(); + if (names.isEmpty()) { + return new Type[0]; + } + String[] argNames = names.split(","); + Type[] result = new Type[argNames.length]; + for (int i = 0; i < argNames.length; i++) { + String argTypeName = argNames[i]; + try { + result[i] = parseType(argTypeName.trim()); + } catch (Throwable e) { + errors.addError(e, ErrorCode.POINTCUT_SIGNATURE_INVALID_TYPE, signature, argTypeName); + } + } + return result; } - int startOfArray = name.indexOf(ARRAY_DESCRIPTOR); - if (startOfArray >= 0) { - final Type arrayType = parseType(name.substring(0, startOfArray)); - String arrayDeclaration = name.substring(startOfArray); - int dimension = - (int) - arrayDeclaration - .chars() - .filter(it -> it == ARRAY_DESCRIPTOR) - .count(); // assumes array notation is well-formed - String elementType = - arrayType.getSort() == Type.OBJECT - ? classNameToDescriptor(arrayType.getClassName()) - : arrayType.getInternalName(); - return Type.getType(repeat(ARRAY_DESCRIPTOR, dimension) + elementType); + + private static Type parseType(final String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("Type name cannot be null or empty"); + } + int startOfArray = name.indexOf(ARRAY_DESCRIPTOR); + if (startOfArray >= 0) { + final Type arrayType = parseType(name.substring(0, startOfArray)); + String arrayDeclaration = name.substring(startOfArray); + int dimension = (int) arrayDeclaration + .chars() + .filter(it -> it == ARRAY_DESCRIPTOR) + .count(); // assumes array notation is well-formed + String elementType = arrayType.getSort() == Type.OBJECT + ? classNameToDescriptor(arrayType.getClassName()) + : arrayType.getInternalName(); + return Type.getType(repeat(ARRAY_DESCRIPTOR, dimension) + elementType); + } + return classNameOrPrimitiveToType(name); } - return classNameOrPrimitiveToType(name); - } - public static Type classNameOrPrimitiveToType(final String name) { - if (name == null || name.isEmpty()) { - return null; + public static Type classNameOrPrimitiveToType(final String name) { + if (name == null || name.isEmpty()) { + return null; + } + final Type type = PRIMITIVE_TYPES.get(name); + return type != null ? type : classNameToType(name); } - final Type type = PRIMITIVE_TYPES.get(name); - return type != null ? type : classNameToType(name); - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java index f1d5159a46a..a1653a8205b 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/TypeResolverPool.java @@ -21,139 +21,137 @@ public class TypeResolverPool implements TypeResolver { - private final List classpath; - private final Map> resolvedTypes = new HashMap<>(); - private final Map resolvedMethods = new HashMap<>(); + private final List classpath; + private final Map> resolvedTypes = new HashMap<>(); + private final Map resolvedMethods = new HashMap<>(); - public TypeResolverPool() { - this(Thread.currentThread().getContextClassLoader()); - } - - public TypeResolverPool(@Nonnull final ClassLoader... classpath) { - this.classpath = Arrays.asList(classpath); - resolvedTypes.put(Type.BYTE_TYPE, byte.class); - resolvedTypes.put(Type.CHAR_TYPE, char.class); - resolvedTypes.put(Type.DOUBLE_TYPE, double.class); - resolvedTypes.put(Type.FLOAT_TYPE, float.class); - resolvedTypes.put(Type.INT_TYPE, int.class); - resolvedTypes.put(Type.LONG_TYPE, long.class); - resolvedTypes.put(Type.SHORT_TYPE, short.class); - resolvedTypes.put(Type.BOOLEAN_TYPE, boolean.class); - resolvedTypes.put(Type.VOID_TYPE, void.class); - } - - @Override - @Nonnull - public Class resolveType(@Nonnull final Type type) { - return resolvedTypes.computeIfAbsent(type, this::resolveNewType); - } - - private Class resolveNewType(@Nonnull final Type type) { - if (type.getSort() == Type.METHOD) { - throw new IllegalArgumentException(type + " is a method"); + public TypeResolverPool() { + this(Thread.currentThread().getContextClassLoader()); } - Throwable cause = null; - final String className = getResolvableClassName(type); - for (ClassLoader classLoader : classpath) { - try { - return Class.forName(className, true, classLoader); - } catch (Throwable t) { - cause = t; - } + + public TypeResolverPool(@Nonnull final ClassLoader... classpath) { + this.classpath = Arrays.asList(classpath); + resolvedTypes.put(Type.BYTE_TYPE, byte.class); + resolvedTypes.put(Type.CHAR_TYPE, char.class); + resolvedTypes.put(Type.DOUBLE_TYPE, double.class); + resolvedTypes.put(Type.FLOAT_TYPE, float.class); + resolvedTypes.put(Type.INT_TYPE, int.class); + resolvedTypes.put(Type.LONG_TYPE, long.class); + resolvedTypes.put(Type.SHORT_TYPE, short.class); + resolvedTypes.put(Type.BOOLEAN_TYPE, boolean.class); + resolvedTypes.put(Type.VOID_TYPE, void.class); } - if (cause == null) { - throw new ResolutionException(new Failure(ErrorCode.UNRESOLVED_TYPE, type)); - } else { - throw new ResolutionException(new Failure(cause, ErrorCode.UNRESOLVED_TYPE, type)); + + @Override + @Nonnull + public Class resolveType(@Nonnull final Type type) { + return resolvedTypes.computeIfAbsent(type, this::resolveNewType); } - } - private String getResolvableClassName(final Type type) { - switch (type.getSort()) { - case Type.ARRAY: - Type element = type.getElementType(); - String elementClassName = - element.getSort() == Type.OBJECT - ? "L" + element.getClassName() + ";" - : element.getInternalName(); - return repeat('[', type.getDimensions()) + elementClassName; - case Type.OBJECT: - return type.getClassName(); - default: - throw new IllegalArgumentException( - "Primitive types should have already been resolved " + type); + private Class resolveNewType(@Nonnull final Type type) { + if (type.getSort() == Type.METHOD) { + throw new IllegalArgumentException(type + " is a method"); + } + Throwable cause = null; + final String className = getResolvableClassName(type); + for (ClassLoader classLoader : classpath) { + try { + return Class.forName(className, true, classLoader); + } catch (Throwable t) { + cause = t; + } + } + if (cause == null) { + throw new ResolutionException(new Failure(ErrorCode.UNRESOLVED_TYPE, type)); + } else { + throw new ResolutionException(new Failure(cause, ErrorCode.UNRESOLVED_TYPE, type)); + } } - } - @Override - @Nonnull - public Executable resolveMethod(@Nonnull final MethodType method) { - return resolvedMethods.computeIfAbsent(method, this::resolveNewMethod); - } + private String getResolvableClassName(final Type type) { + switch (type.getSort()) { + case Type.ARRAY: + Type element = type.getElementType(); + String elementClassName = element.getSort() == Type.OBJECT + ? "L" + element.getClassName() + ";" + : element.getInternalName(); + return repeat('[', type.getDimensions()) + elementClassName; + case Type.OBJECT: + return type.getClassName(); + default: + throw new IllegalArgumentException("Primitive types should have already been resolved " + type); + } + } - private Executable resolveNewMethod(@Nonnull final MethodType method) { - final Class owner = resolveType(method.getOwner()); - final Type[] argumentTypes = method.getMethodType().getArgumentTypes(); - final Class[] arguments = new Class[argumentTypes.length]; - for (int i = 0; i < argumentTypes.length; i++) { - arguments[i] = resolveType(argumentTypes[i]); + @Override + @Nonnull + public Executable resolveMethod(@Nonnull final MethodType method) { + return resolvedMethods.computeIfAbsent(method, this::resolveNewMethod); } - try { - if (method.isConstructor()) { - try { - return owner.getDeclaredConstructor(arguments); - } catch (final NoSuchMethodException e) { - return owner.getConstructor(arguments); + + private Executable resolveNewMethod(@Nonnull final MethodType method) { + final Class owner = resolveType(method.getOwner()); + final Type[] argumentTypes = method.getMethodType().getArgumentTypes(); + final Class[] arguments = new Class[argumentTypes.length]; + for (int i = 0; i < argumentTypes.length; i++) { + arguments[i] = resolveType(argumentTypes[i]); } - } else { try { - return owner.getDeclaredMethod(method.getMethodName(), arguments); - } catch (final NoSuchMethodException e) { - return owner.getMethod(method.getMethodName(), arguments); + if (method.isConstructor()) { + try { + return owner.getDeclaredConstructor(arguments); + } catch (final NoSuchMethodException e) { + return owner.getConstructor(arguments); + } + } else { + try { + return owner.getDeclaredMethod(method.getMethodName(), arguments); + } catch (final NoSuchMethodException e) { + return owner.getMethod(method.getMethodName(), arguments); + } + } + } catch (final ResolutionException e) { + throw e; + } catch (final Throwable e) { + throw new ResolutionException(new Failure(e, ErrorCode.UNRESOLVED_METHOD, method)); } - } - } catch (final ResolutionException e) { - throw e; - } catch (final Throwable e) { - throw new ResolutionException(new Failure(e, ErrorCode.UNRESOLVED_METHOD, method)); } - } - @Override - public TypeSolver getParent() { - return null; - } + @Override + public TypeSolver getParent() { + return null; + } - @Override - public void setParent(final TypeSolver parent) {} + @Override + public void setParent(final TypeSolver parent) {} - @Override - public SymbolReference tryToSolveType(final String name) { - try { - // inner classes use a dollar instead of a dot in the class name - final StringBuilder builder = new StringBuilder(name.length()); - final String[] parts = name.split("\\."); - for (int i = 0; i < parts.length; i++) { - final String part = parts[i]; - if (i > 0) { - final String prev = parts[i - 1]; - final boolean inner = - Character.isUpperCase(prev.charAt(0)) && Character.isUpperCase(part.charAt(0)); - builder.append(inner ? '$' : '.'); + @Override + public SymbolReference tryToSolveType(final String name) { + try { + // inner classes use a dollar instead of a dot in the class name + final StringBuilder builder = new StringBuilder(name.length()); + final String[] parts = name.split("\\."); + for (int i = 0; i < parts.length; i++) { + final String part = parts[i]; + if (i > 0) { + final String prev = parts[i - 1]; + final boolean inner = + Character.isUpperCase(prev.charAt(0)) && Character.isUpperCase(part.charAt(0)); + builder.append(inner ? '$' : '.'); + } + builder.append(part); + } + final Type type = classNameToType(builder.toString()); + final Class clazz = resolveType(type); + return SymbolReference.solved(ReflectionFactory.typeDeclarationFor(clazz, getRoot())); + } catch (final Throwable e) { + return SymbolReference.unsolved(); } - builder.append(part); - } - final Type type = classNameToType(builder.toString()); - final Class clazz = resolveType(type); - return SymbolReference.solved(ReflectionFactory.typeDeclarationFor(clazz, getRoot())); - } catch (final Throwable e) { - return SymbolReference.unsolved(); } - } - @Override - public SymbolReference tryToSolveTypeInModule( - String qualifiedModuleName, String simpleTypeName) { - return tryToSolveType(simpleTypeName); - } + @Override + public SymbolReference tryToSolveTypeInModule( + String qualifiedModuleName, String simpleTypeName) { + return tryToSolveType(simpleTypeName); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/ext/IastExtension.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/ext/IastExtension.java index 9373073291c..070ae52ca46 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/ext/IastExtension.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/impl/ext/IastExtension.java @@ -61,433 +61,410 @@ @SuppressWarnings("OptionalGetWithoutIsPresent") public class IastExtension implements Extension { - private static final String IAST_CALL_SITES_CLASS = "IastCallSites"; - static final String IAST_CALL_SITES_FQCN = "datadog.trace.api.iast." + IAST_CALL_SITES_CLASS; - private static final String HAS_TELEMETRY_INTERFACE = IAST_CALL_SITES_CLASS + ".HasTelemetry"; - private static final String IAST_METRIC_COLLECTOR_CLASS = "IastMetricCollector"; - private static final String IAST_METRIC_COLLECTOR_FQCN = - "datadog.trace.api.iast.telemetry." + IAST_METRIC_COLLECTOR_CLASS; - - private static final String IAST_METRIC_COLLECTOR_INTERNAL_NAME = - IAST_METRIC_COLLECTOR_FQCN.replaceAll("\\.", "/"); - - private static final String VERBOSITY_CLASS = "Verbosity"; - private static final String VERBOSITY_FQCN = - "datadog.trace.api.iast.telemetry." + VERBOSITY_CLASS; - - private static final String IAST_METRIC_CLASS = "IastMetric"; - private static final String IAST_METRIC_FQCN = - "datadog.trace.api.iast.telemetry." + IAST_METRIC_CLASS; - - private static final String IAST_METRIC_INTERNAL_NAME = IAST_METRIC_FQCN.replaceAll("\\.", "/"); - - @Override - public boolean appliesTo(@Nonnull final CallSiteSpecification spec) { - for (Type spi : spec.getSpi()) { - if (IAST_CALL_SITES_FQCN.equals(spi.getClassName())) { - return true; - } + private static final String IAST_CALL_SITES_CLASS = "IastCallSites"; + static final String IAST_CALL_SITES_FQCN = "datadog.trace.api.iast." + IAST_CALL_SITES_CLASS; + private static final String HAS_TELEMETRY_INTERFACE = IAST_CALL_SITES_CLASS + ".HasTelemetry"; + private static final String IAST_METRIC_COLLECTOR_CLASS = "IastMetricCollector"; + private static final String IAST_METRIC_COLLECTOR_FQCN = + "datadog.trace.api.iast.telemetry." + IAST_METRIC_COLLECTOR_CLASS; + + private static final String IAST_METRIC_COLLECTOR_INTERNAL_NAME = IAST_METRIC_COLLECTOR_FQCN.replaceAll("\\.", "/"); + + private static final String VERBOSITY_CLASS = "Verbosity"; + private static final String VERBOSITY_FQCN = "datadog.trace.api.iast.telemetry." + VERBOSITY_CLASS; + + private static final String IAST_METRIC_CLASS = "IastMetric"; + private static final String IAST_METRIC_FQCN = "datadog.trace.api.iast.telemetry." + IAST_METRIC_CLASS; + + private static final String IAST_METRIC_INTERNAL_NAME = IAST_METRIC_FQCN.replaceAll("\\.", "/"); + + @Override + public boolean appliesTo(@Nonnull final CallSiteSpecification spec) { + for (Type spi : spec.getSpi()) { + if (IAST_CALL_SITES_FQCN.equals(spi.getClassName())) { + return true; + } + } + return false; } - return false; - } - - @Override - public void apply( - @Nonnull final Configuration configuration, @Nonnull final CallSiteResult result) - throws Exception { - addTelemetry(configuration, result); - } - - private void addTelemetry(final Configuration configuration, final CallSiteResult result) - throws Exception { - final TypeResolver resolver = getTypeResolver(configuration); - - // class with the addAdvices methods - final CompilationUnit providerJavaFile = parseJavaFile(resolver, result.getFile()); - final ClassOrInterfaceDeclaration provider = getPrimaryType(providerJavaFile); - if (implementsInterface(provider, HAS_TELEMETRY_INTERFACE)) { - // already processed - return; + + @Override + public void apply(@Nonnull final Configuration configuration, @Nonnull final CallSiteResult result) + throws Exception { + addTelemetry(configuration, result); } - // find all the advices in the provider - final Map advices = findAdvices(result, provider); - - // parse current call site class and fetch all metadata regarding telemetry - final CompilationUnit originalCallSiteFile = - findOriginalCallSite(configuration, resolver, result); - final ClassOrInterfaceDeclaration originalCallSite = getPrimaryType(originalCallSiteFile); - final AdviceMetadata globalMetadata = AdviceMetadata.findAdviceMetadata(originalCallSite); - - // add telemetry to each of the advices - boolean hasTelemetry = false; - for (final MethodDeclaration callSiteMethod : originalCallSite.getMethods()) { - if (isCallSite(callSiteMethod)) { - final AdviceMetadata methodMetadata = AdviceMetadata.findAdviceMetadata(callSiteMethod); - final AdviceMetadata metaData = methodMetadata != null ? methodMetadata : globalMetadata; - // if the call site class or the method has been annotated then apply the extension - if (metaData != null) { - final List adviceLambdas = filterAdviceLambdas(advices, callSiteMethod); - for (final LambdaExpr advice : adviceLambdas) { - addTelemetryToAdvice(resolver, advice, metaData); - hasTelemetry = true; - } + private void addTelemetry(final Configuration configuration, final CallSiteResult result) throws Exception { + final TypeResolver resolver = getTypeResolver(configuration); + + // class with the addAdvices methods + final CompilationUnit providerJavaFile = parseJavaFile(resolver, result.getFile()); + final ClassOrInterfaceDeclaration provider = getPrimaryType(providerJavaFile); + if (implementsInterface(provider, HAS_TELEMETRY_INTERFACE)) { + // already processed + return; } - } - } - if (hasTelemetry) { - // add telemetry support to the provider class - addTelemetryInterface(providerJavaFile); + // find all the advices in the provider + final Map advices = findAdvices(result, provider); + + // parse current call site class and fetch all metadata regarding telemetry + final CompilationUnit originalCallSiteFile = findOriginalCallSite(configuration, resolver, result); + final ClassOrInterfaceDeclaration originalCallSite = getPrimaryType(originalCallSiteFile); + final AdviceMetadata globalMetadata = AdviceMetadata.findAdviceMetadata(originalCallSite); + + // add telemetry to each of the advices + boolean hasTelemetry = false; + for (final MethodDeclaration callSiteMethod : originalCallSite.getMethods()) { + if (isCallSite(callSiteMethod)) { + final AdviceMetadata methodMetadata = AdviceMetadata.findAdviceMetadata(callSiteMethod); + final AdviceMetadata metaData = methodMetadata != null ? methodMetadata : globalMetadata; + // if the call site class or the method has been annotated then apply the extension + if (metaData != null) { + final List adviceLambdas = filterAdviceLambdas(advices, callSiteMethod); + for (final LambdaExpr advice : adviceLambdas) { + addTelemetryToAdvice(resolver, advice, metaData); + hasTelemetry = true; + } + } + } + } + + if (hasTelemetry) { + // add telemetry support to the provider class + addTelemetryInterface(providerJavaFile); + + // save the result + providerJavaFile.getStorage().get().save(); + } + } - // save the result - providerJavaFile.getStorage().get().save(); + private void addTelemetryInterface(final CompilationUnit javaClass) { + javaClass.addImport(IAST_METRIC_COLLECTOR_FQCN); + javaClass.addImport(IAST_METRIC_FQCN); + javaClass.addImport(VERBOSITY_FQCN); + final ClassOrInterfaceDeclaration mainType = getPrimaryType(javaClass); + mainType.addImplementedType(HAS_TELEMETRY_INTERFACE); + final FieldDeclaration verbosityField = + mainType.addField(VERBOSITY_CLASS, "verbosity", Modifier.Keyword.PRIVATE); + verbosityField + .getVariable(0) + .setInitializer(new FieldAccessExpr() + .setScope(new NameExpr(VERBOSITY_CLASS)) + .setName("OFF")); + final MethodDeclaration enableTelemetry = mainType.addMethod("setVerbosity", Modifier.Keyword.PUBLIC) + .addParameter(VERBOSITY_CLASS, "verbosity") + .addAnnotation(Override.class); + final BlockStmt enableTelemetryBody = new BlockStmt(); + enableTelemetryBody.addStatement( + new AssignExpr().setTarget(accessLocalField("verbosity")).setValue(new NameExpr("verbosity"))); + enableTelemetry.setBody(enableTelemetryBody); } - } - - private void addTelemetryInterface(final CompilationUnit javaClass) { - javaClass.addImport(IAST_METRIC_COLLECTOR_FQCN); - javaClass.addImport(IAST_METRIC_FQCN); - javaClass.addImport(VERBOSITY_FQCN); - final ClassOrInterfaceDeclaration mainType = getPrimaryType(javaClass); - mainType.addImplementedType(HAS_TELEMETRY_INTERFACE); - final FieldDeclaration verbosityField = - mainType.addField(VERBOSITY_CLASS, "verbosity", Modifier.Keyword.PRIVATE); - verbosityField - .getVariable(0) - .setInitializer( - new FieldAccessExpr().setScope(new NameExpr(VERBOSITY_CLASS)).setName("OFF")); - final MethodDeclaration enableTelemetry = - mainType - .addMethod("setVerbosity", Modifier.Keyword.PUBLIC) - .addParameter(VERBOSITY_CLASS, "verbosity") - .addAnnotation(Override.class); - final BlockStmt enableTelemetryBody = new BlockStmt(); - enableTelemetryBody.addStatement( - new AssignExpr() - .setTarget(accessLocalField("verbosity")) - .setValue(new NameExpr("verbosity"))); - enableTelemetry.setBody(enableTelemetryBody); - } - - private CompilationUnit findOriginalCallSite( - final Configuration configuration, final TypeResolver resolver, final CallSiteResult result) - throws FileNotFoundException { - final String originalClass = result.getSpecification().getClazz().getClassName(); - final String separator = File.separator.equals("\\") ? "\\\\" : File.separator; - final String javaFile = originalClass.replaceAll("\\.", separator); - return parseSourceFile(configuration, resolver, javaFile); - } - - private void addTelemetryToAdvice( - final TypeResolver resolver, final LambdaExpr adviceLambda, final AdviceMetadata metaData) { - final BlockStmt lambdaBody = adviceLambda.getBody().asBlockStmt(); - final String metric = getMetricName(metaData); - final Byte tagValue = getMetricTagValue(resolver, metaData); - final String instrumentedMetric = "INSTRUMENTED_" + metric; - final IfStmt instrumentedStatement = - new IfStmt() - .setCondition(isEnabledCondition(instrumentedMetric)) - .setThenStmt( - new BlockStmt() - .addStatement(addTelemetryCollectorMethod(instrumentedMetric, tagValue))); - lambdaBody.addStatement(0, instrumentedStatement); - final String executedMetric = "EXECUTED_" + metric; - final IfStmt executedStatement = - new IfStmt() - .setCondition(isEnabledCondition(executedMetric)) - .setThenStmt(addTelemetryCollectorByteCode(executedMetric, tagValue)); - lambdaBody.addStatement(1, executedStatement); - } - - private static Expression isEnabledCondition(final String metric) { - return new MethodCallExpr() - .setScope(new FieldAccessExpr().setScope(new NameExpr(IAST_METRIC_CLASS)).setName(metric)) - .setName("isEnabled") - .addArgument(accessLocalField("verbosity")); - } - - private static MethodCallExpr addTelemetryCollectorMethod( - final String metric, final Byte tagValue) { - final MethodCallExpr method = - new MethodCallExpr() - .setScope(new NameExpr(IAST_METRIC_COLLECTOR_CLASS)) - .setName("add") - .addArgument( - new FieldAccessExpr().setScope(new NameExpr(IAST_METRIC_CLASS)).setName(metric)); - if (tagValue != null) { - method.addArgument( - new CastExpr() - .setExpression(new IntegerLiteralExpr(Byte.toString(tagValue))) - .setType(byte.class)); + + private CompilationUnit findOriginalCallSite( + final Configuration configuration, final TypeResolver resolver, final CallSiteResult result) + throws FileNotFoundException { + final String originalClass = result.getSpecification().getClazz().getClassName(); + final String separator = File.separator.equals("\\") ? "\\\\" : File.separator; + final String javaFile = originalClass.replaceAll("\\.", separator); + return parseSourceFile(configuration, resolver, javaFile); } - method.addArgument(intLiteral(1)); - return method; - } - - private static BlockStmt addTelemetryCollectorByteCode(final String metric, final Byte tagValue) { - final BlockStmt stmt = new BlockStmt(); - // this code generates the java source code needed to provide the bytecode for the statement - // IastTelemetryCollector.add(${metric}, 1); or IastTelemetryCollector.add(${metric}, ${tag}, - // 1); - stmt.addStatement( - new MethodCallExpr() - .setScope(new NameExpr("handler")) - .setName("field") - .addArgument( - new FieldAccessExpr().setScope(new NameExpr(OPCODES_FQDN)).setName("GETSTATIC")) - .addArgument(new StringLiteralExpr(IAST_METRIC_INTERNAL_NAME)) - .addArgument(new StringLiteralExpr(metric)) - .addArgument(new StringLiteralExpr("L" + IAST_METRIC_INTERNAL_NAME + ";"))); - if (tagValue != null) { - stmt.addStatement(pushByteExpression(tagValue)); + + private void addTelemetryToAdvice( + final TypeResolver resolver, final LambdaExpr adviceLambda, final AdviceMetadata metaData) { + final BlockStmt lambdaBody = adviceLambda.getBody().asBlockStmt(); + final String metric = getMetricName(metaData); + final Byte tagValue = getMetricTagValue(resolver, metaData); + final String instrumentedMetric = "INSTRUMENTED_" + metric; + final IfStmt instrumentedStatement = new IfStmt() + .setCondition(isEnabledCondition(instrumentedMetric)) + .setThenStmt(new BlockStmt().addStatement(addTelemetryCollectorMethod(instrumentedMetric, tagValue))); + lambdaBody.addStatement(0, instrumentedStatement); + final String executedMetric = "EXECUTED_" + metric; + final IfStmt executedStatement = new IfStmt() + .setCondition(isEnabledCondition(executedMetric)) + .setThenStmt(addTelemetryCollectorByteCode(executedMetric, tagValue)); + lambdaBody.addStatement(1, executedStatement); } - stmt.addStatement( - new MethodCallExpr() - .setScope(new NameExpr("handler")) - .setName("instruction") - .addArgument( - new FieldAccessExpr().setScope(new NameExpr(OPCODES_FQDN)).setName("ICONST_1"))); - final String descriptor = - tagValue != null - ? "(L" + IAST_METRIC_INTERNAL_NAME + ";BI)V" - : "(L" + IAST_METRIC_INTERNAL_NAME + ";I)V"; - stmt.addStatement( - new MethodCallExpr() - .setScope(new NameExpr("handler")) - .setName("method") - .addArgument( - new FieldAccessExpr().setScope(new NameExpr(OPCODES_FQDN)).setName("INVOKESTATIC")) - .addArgument(new StringLiteralExpr(IAST_METRIC_COLLECTOR_INTERNAL_NAME)) - .addArgument(new StringLiteralExpr("add")) - .addArgument(new StringLiteralExpr(descriptor)) - .addArgument(new BooleanLiteralExpr(false))); - return stmt; - } - - private static String getMetricName(final AdviceMetadata metaData) { - final AnnotationExpr kind = metaData.getKind(); - return kind.getName().getId().toUpperCase(); - } - - private static Byte getMetricTagValue( - final TypeResolver resolver, final AdviceMetadata metadata) { - if (metadata.getTag() == null) { - return null; + + private static Expression isEnabledCondition(final String metric) { + return new MethodCallExpr() + .setScope(new FieldAccessExpr() + .setScope(new NameExpr(IAST_METRIC_CLASS)) + .setName(metric)) + .setName("isEnabled") + .addArgument(accessLocalField("verbosity")); } - final Expression tag = metadata.getTag(); - if (tag.isIntegerLiteralExpr()) { - return tag.asIntegerLiteralExpr().asNumber().byteValue(); - } else { - return getFieldValue(resolver, tag.asFieldAccessExpr()); + + private static MethodCallExpr addTelemetryCollectorMethod(final String metric, final Byte tagValue) { + final MethodCallExpr method = new MethodCallExpr() + .setScope(new NameExpr(IAST_METRIC_COLLECTOR_CLASS)) + .setName("add") + .addArgument(new FieldAccessExpr() + .setScope(new NameExpr(IAST_METRIC_CLASS)) + .setName(metric)); + if (tagValue != null) { + method.addArgument(new CastExpr() + .setExpression(new IntegerLiteralExpr(Byte.toString(tagValue))) + .setType(byte.class)); + } + method.addArgument(intLiteral(1)); + return method; } - } - - private static Byte getFieldValue(final TypeResolver resolver, final FieldAccessExpr tag) { - final FieldAccessExpr fieldAccessExpr = tag.asFieldAccessExpr(); - final ResolvedFieldDeclaration value = fieldAccessExpr.resolve().asField(); - try { - final Field field = getField(value); - field.setAccessible(true); - return (Byte) field.get(field.getDeclaringClass()); - } catch (Exception e) { - throw new RuntimeException(e); + + private static BlockStmt addTelemetryCollectorByteCode(final String metric, final Byte tagValue) { + final BlockStmt stmt = new BlockStmt(); + // this code generates the java source code needed to provide the bytecode for the statement + // IastTelemetryCollector.add(${metric}, 1); or IastTelemetryCollector.add(${metric}, ${tag}, + // 1); + stmt.addStatement(new MethodCallExpr() + .setScope(new NameExpr("handler")) + .setName("field") + .addArgument(new FieldAccessExpr() + .setScope(new NameExpr(OPCODES_FQDN)) + .setName("GETSTATIC")) + .addArgument(new StringLiteralExpr(IAST_METRIC_INTERNAL_NAME)) + .addArgument(new StringLiteralExpr(metric)) + .addArgument(new StringLiteralExpr("L" + IAST_METRIC_INTERNAL_NAME + ";"))); + if (tagValue != null) { + stmt.addStatement(pushByteExpression(tagValue)); + } + stmt.addStatement(new MethodCallExpr() + .setScope(new NameExpr("handler")) + .setName("instruction") + .addArgument(new FieldAccessExpr() + .setScope(new NameExpr(OPCODES_FQDN)) + .setName("ICONST_1"))); + final String descriptor = tagValue != null + ? "(L" + IAST_METRIC_INTERNAL_NAME + ";BI)V" + : "(L" + IAST_METRIC_INTERNAL_NAME + ";I)V"; + stmt.addStatement(new MethodCallExpr() + .setScope(new NameExpr("handler")) + .setName("method") + .addArgument(new FieldAccessExpr() + .setScope(new NameExpr(OPCODES_FQDN)) + .setName("INVOKESTATIC")) + .addArgument(new StringLiteralExpr(IAST_METRIC_COLLECTOR_INTERNAL_NAME)) + .addArgument(new StringLiteralExpr("add")) + .addArgument(new StringLiteralExpr(descriptor)) + .addArgument(new BooleanLiteralExpr(false))); + return stmt; } - } - - private static Field getField(final ResolvedFieldDeclaration resolved) { - try { - final Field field = resolved.getClass().getDeclaredField("field"); - field.setAccessible(true); - return (Field) field.get(resolved); - } catch (Exception e) { - throw new RuntimeException(e); + + private static String getMetricName(final AdviceMetadata metaData) { + final AnnotationExpr kind = metaData.getKind(); + return kind.getName().getId().toUpperCase(); } - } - - /** Find all advice lambdas in the generated call site provider */ - private static Map findAdvices( - final CallSiteResult result, final ClassOrInterfaceDeclaration callSiteProvider) { - final MethodDeclaration acceptMethod = - callSiteProvider.getMethodsBySignature("accept", "Container").get(0); - final BlockStmt body = acceptMethod.getBody().get().asBlockStmt(); - final List addAdviceMethods = - body.getStatements().stream() - .filter(IastExtension::isAddAdviceMethodCall) - .map(it -> it.asExpressionStmt().getExpression().asMethodCallExpr()) - .collect(Collectors.toList()); - return result.getSpecification().getAdvices().stream() - .collect( - Collectors.toMap( - Function.identity(), spec -> findAdviceLambda(spec, addAdviceMethods))); - } - - /** - * Return only the lambdas that match the specified call site method by looking into the - * signatures - */ - private static List filterAdviceLambdas( - final Map advices, final MethodDeclaration callSiteMethod) { - - final List result = new ArrayList<>(); - for (final Map.Entry entry : advices.entrySet()) { - final AdviceSpecification spec = entry.getKey(); - final MethodType methodType = spec.getAdvice(); - if (!methodType.getMethodName().equals(callSiteMethod.getNameAsString())) { - continue; - } - // java parser has issues with inner classes and descriptors, remove the dollar to do the - // matching) - final String descriptor = methodType.getMethodType().getDescriptor().replaceAll("\\$", "/"); - if (!descriptor.equals(callSiteMethod.toDescriptor())) { - continue; - } - result.add(entry.getValue()); + + private static Byte getMetricTagValue(final TypeResolver resolver, final AdviceMetadata metadata) { + if (metadata.getTag() == null) { + return null; + } + final Expression tag = metadata.getTag(); + if (tag.isIntegerLiteralExpr()) { + return tag.asIntegerLiteralExpr().asNumber().byteValue(); + } else { + return getFieldValue(resolver, tag.asFieldAccessExpr()); + } } - return result; - } - private static boolean isAddAdviceMethodCall(final Statement statement) { - if (!statement.isExpressionStmt()) { - return false; + private static Byte getFieldValue(final TypeResolver resolver, final FieldAccessExpr tag) { + final FieldAccessExpr fieldAccessExpr = tag.asFieldAccessExpr(); + final ResolvedFieldDeclaration value = fieldAccessExpr.resolve().asField(); + try { + final Field field = getField(value); + field.setAccessible(true); + return (Byte) field.get(field.getDeclaringClass()); + } catch (Exception e) { + throw new RuntimeException(e); + } } - final Expression expression = statement.asExpressionStmt().getExpression(); - if (!expression.isMethodCallExpr()) { - return false; + + private static Field getField(final ResolvedFieldDeclaration resolved) { + try { + final Field field = resolved.getClass().getDeclaredField("field"); + field.setAccessible(true); + return (Field) field.get(resolved); + } catch (Exception e) { + throw new RuntimeException(e); + } } - final MethodCallExpr methodCall = expression.asMethodCallExpr(); - if (!methodCall.getScope().get().toString().equals("container")) { - return false; + + /** Find all advice lambdas in the generated call site provider */ + private static Map findAdvices( + final CallSiteResult result, final ClassOrInterfaceDeclaration callSiteProvider) { + final MethodDeclaration acceptMethod = + callSiteProvider.getMethodsBySignature("accept", "Container").get(0); + final BlockStmt body = acceptMethod.getBody().get().asBlockStmt(); + final List addAdviceMethods = body.getStatements().stream() + .filter(IastExtension::isAddAdviceMethodCall) + .map(it -> it.asExpressionStmt().getExpression().asMethodCallExpr()) + .collect(Collectors.toList()); + return result.getSpecification().getAdvices().stream() + .collect(Collectors.toMap(Function.identity(), spec -> findAdviceLambda(spec, addAdviceMethods))); } - return methodCall.getNameAsString().equals("addAdvice"); - } - - private static LambdaExpr findAdviceLambda( - final AdviceSpecification spec, final List addAdvices) { - final MethodType pointcut = spec.getPointcut(); - for (final MethodCallExpr add : addAdvices) { - final NodeList arguments = add.getArguments(); - final String owner = arguments.get(1).asStringLiteralExpr().asString(); - if (!owner.equals(pointcut.getOwner().getInternalName())) { - continue; - } - final String method = arguments.get(2).asStringLiteralExpr().asString(); - if (!method.equals(pointcut.getMethodName())) { - continue; - } - final String description = arguments.get(3).asStringLiteralExpr().asString(); - if (!description.equals(pointcut.getMethodType().getDescriptor())) { - continue; - } - return arguments.get(4).asLambdaExpr(); + + /** + * Return only the lambdas that match the specified call site method by looking into the + * signatures + */ + private static List filterAdviceLambdas( + final Map advices, final MethodDeclaration callSiteMethod) { + + final List result = new ArrayList<>(); + for (final Map.Entry entry : advices.entrySet()) { + final AdviceSpecification spec = entry.getKey(); + final MethodType methodType = spec.getAdvice(); + if (!methodType.getMethodName().equals(callSiteMethod.getNameAsString())) { + continue; + } + // java parser has issues with inner classes and descriptors, remove the dollar to do the + // matching) + final String descriptor = methodType.getMethodType().getDescriptor().replaceAll("\\$", "/"); + if (!descriptor.equals(callSiteMethod.toDescriptor())) { + continue; + } + result.add(entry.getValue()); + } + return result; } - throw new IllegalArgumentException("Cannot find lambda expression for pointcut " + pointcut); - } - - private static boolean isCallSite(final MethodDeclaration method) { - return Stream.of("Before", "Around", "After") - .map(method::getAnnotationByName) - .anyMatch(Optional::isPresent); - } - - private static TypeResolver getTypeResolver(final Configuration configuration) { - final URL[] urls = - configuration.getClassPath().stream().map(CallSiteUtils::toURL).toArray(URL[]::new); - final ClassLoader loader = new URLClassLoader(urls); - return typeResolver(loader, Thread.currentThread().getContextClassLoader()); - } - - private static CompilationUnit parseSourceFile( - final Configuration configuration, final TypeResolver resolver, final String file) - throws FileNotFoundException { - final Path callSiteSource = configuration.getSrcFolder().resolve(file + ".java"); - if (!Files.exists(callSiteSource)) { - throw new RuntimeException("Error finding original call site at " + callSiteSource); + + private static boolean isAddAdviceMethodCall(final Statement statement) { + if (!statement.isExpressionStmt()) { + return false; + } + final Expression expression = statement.asExpressionStmt().getExpression(); + if (!expression.isMethodCallExpr()) { + return false; + } + final MethodCallExpr methodCall = expression.asMethodCallExpr(); + if (!methodCall.getScope().get().toString().equals("container")) { + return false; + } + return methodCall.getNameAsString().equals("addAdvice"); } - return parseJavaFile(resolver, callSiteSource.toFile()); - } - - private static CompilationUnit parseJavaFile(final TypeResolver resolver, final File file) - throws FileNotFoundException { - final JavaSymbolSolver solver = new JavaSymbolSolver(resolver); - final JavaParser parser = new JavaParser(new ParserConfiguration().setSymbolResolver(solver)); - return parser.parse(file).getResult().get(); - } - - private static Expression getAnnotationExpression(final AnnotationExpr expr) { - if (expr.isMarkerAnnotationExpr()) { - return null; - } else if (expr.isSingleMemberAnnotationExpr()) { - return expr.asSingleMemberAnnotationExpr().getMemberValue(); - } else { - final List pairs = expr.asNormalAnnotationExpr().getPairs(); - return pairs.stream() - .filter(it -> it.getName().toString().equals("value")) - .map(MemberValuePair::getValue) - .findFirst() - .orElse(null); + + private static LambdaExpr findAdviceLambda(final AdviceSpecification spec, final List addAdvices) { + final MethodType pointcut = spec.getPointcut(); + for (final MethodCallExpr add : addAdvices) { + final NodeList arguments = add.getArguments(); + final String owner = arguments.get(1).asStringLiteralExpr().asString(); + if (!owner.equals(pointcut.getOwner().getInternalName())) { + continue; + } + final String method = arguments.get(2).asStringLiteralExpr().asString(); + if (!method.equals(pointcut.getMethodName())) { + continue; + } + final String description = arguments.get(3).asStringLiteralExpr().asString(); + if (!description.equals(pointcut.getMethodType().getDescriptor())) { + continue; + } + return arguments.get(4).asLambdaExpr(); + } + throw new IllegalArgumentException("Cannot find lambda expression for pointcut " + pointcut); } - } - - public static Expression pushByteExpression(final byte value) { - final FieldAccessExpr opCodes = new FieldAccessExpr().setScope(new NameExpr(OPCODES_FQDN)); - final MethodCallExpr result = - new MethodCallExpr().setScope(new NameExpr("handler")).setName("instruction"); - switch (value) { - case -1: - result.addArgument(opCodes.setName("ICONST_M1")); - break; - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - result.addArgument(opCodes.setName("ICONST_" + value)); - break; - default: - result - .addArgument(opCodes.setName("BIPUSH")) - .addArgument(new IntegerLiteralExpr(Integer.toString(value))); - break; + + private static boolean isCallSite(final MethodDeclaration method) { + return Stream.of("Before", "Around", "After") + .map(method::getAnnotationByName) + .anyMatch(Optional::isPresent); } - return result; - } - private static class AdviceMetadata { - private final AnnotationExpr kind; - private final Expression tag; + private static TypeResolver getTypeResolver(final Configuration configuration) { + final URL[] urls = + configuration.getClassPath().stream().map(CallSiteUtils::toURL).toArray(URL[]::new); + final ClassLoader loader = new URLClassLoader(urls); + return typeResolver(loader, Thread.currentThread().getContextClassLoader()); + } - private AdviceMetadata(final AnnotationExpr kind, final Expression tag) { - this.kind = kind; - this.tag = tag; + private static CompilationUnit parseSourceFile( + final Configuration configuration, final TypeResolver resolver, final String file) + throws FileNotFoundException { + final Path callSiteSource = configuration.getSrcFolder().resolve(file + ".java"); + if (!Files.exists(callSiteSource)) { + throw new RuntimeException("Error finding original call site at " + callSiteSource); + } + return parseJavaFile(resolver, callSiteSource.toFile()); } - private static AdviceMetadata findAdviceMetadata(final BodyDeclaration target) { - return target.getAnnotations().stream() - .filter(AdviceMetadata::isAdviceAnnotation) - .map( - annotation -> { - final Expression tag = getAnnotationExpression(annotation); - return new AdviceMetadata(annotation, tag); - }) - .findFirst() - .orElse(null); + private static CompilationUnit parseJavaFile(final TypeResolver resolver, final File file) + throws FileNotFoundException { + final JavaSymbolSolver solver = new JavaSymbolSolver(resolver); + final JavaParser parser = new JavaParser(new ParserConfiguration().setSymbolResolver(solver)); + return parser.parse(file).getResult().get(); } - private static boolean isAdviceAnnotation(final AnnotationExpr expr) { - final String identifier = expr.getName().getIdentifier(); - return identifier.equals("Source") - || identifier.equals("Propagation") - || identifier.equals("Sink"); + private static Expression getAnnotationExpression(final AnnotationExpr expr) { + if (expr.isMarkerAnnotationExpr()) { + return null; + } else if (expr.isSingleMemberAnnotationExpr()) { + return expr.asSingleMemberAnnotationExpr().getMemberValue(); + } else { + final List pairs = expr.asNormalAnnotationExpr().getPairs(); + return pairs.stream() + .filter(it -> it.getName().toString().equals("value")) + .map(MemberValuePair::getValue) + .findFirst() + .orElse(null); + } } - public AnnotationExpr getKind() { - return kind; + public static Expression pushByteExpression(final byte value) { + final FieldAccessExpr opCodes = new FieldAccessExpr().setScope(new NameExpr(OPCODES_FQDN)); + final MethodCallExpr result = + new MethodCallExpr().setScope(new NameExpr("handler")).setName("instruction"); + switch (value) { + case -1: + result.addArgument(opCodes.setName("ICONST_M1")); + break; + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + result.addArgument(opCodes.setName("ICONST_" + value)); + break; + default: + result.addArgument(opCodes.setName("BIPUSH")) + .addArgument(new IntegerLiteralExpr(Integer.toString(value))); + break; + } + return result; } - public Expression getTag() { - return tag; + private static class AdviceMetadata { + private final AnnotationExpr kind; + private final Expression tag; + + private AdviceMetadata(final AnnotationExpr kind, final Expression tag) { + this.kind = kind; + this.tag = tag; + } + + private static AdviceMetadata findAdviceMetadata(final BodyDeclaration target) { + return target.getAnnotations().stream() + .filter(AdviceMetadata::isAdviceAnnotation) + .map(annotation -> { + final Expression tag = getAnnotationExpression(annotation); + return new AdviceMetadata(annotation, tag); + }) + .findFirst() + .orElse(null); + } + + private static boolean isAdviceAnnotation(final AnnotationExpr expr) { + final String identifier = expr.getName().getIdentifier(); + return identifier.equals("Source") || identifier.equals("Propagation") || identifier.equals("Sink"); + } + + public AnnotationExpr getKind() { + return kind; + } + + public Expression getTag() { + return tag; + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java index 8613af8958d..22b8e4ab20c 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteConstants.java @@ -3,53 +3,51 @@ import org.objectweb.asm.Opcodes; public abstract class CallSiteConstants { - private CallSiteConstants() {} + private CallSiteConstants() {} - public static final String CALL_SITE_PACKAGE = "datadog.trace.agent.tooling.csi"; - public static final String CALL_SITE_ANNOTATION = CALL_SITE_PACKAGE + ".CallSite"; - public static final String BEFORE_ANNOTATION = CALL_SITE_ANNOTATION + "$Before"; - public static final String BEFORE_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$BeforeArray"; - public static final String AROUND_ANNOTATION = CALL_SITE_ANNOTATION + "$Around"; - public static final String AROUND_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$AroundArray"; - public static final String AFTER_ANNOTATION = CALL_SITE_ANNOTATION + "$After"; - public static final String AFTER_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$AfterArray"; - public static final String THIS_ANNOTATION = CALL_SITE_ANNOTATION + "$This"; - public static final String ALL_ARGS_ANNOTATION = CALL_SITE_ANNOTATION + "$AllArguments"; - public static final String INVOKE_DYNAMIC_CONSTANTS_ANNOTATION = - CALL_SITE_ANNOTATION + "$InvokeDynamicConstants"; + public static final String CALL_SITE_PACKAGE = "datadog.trace.agent.tooling.csi"; + public static final String CALL_SITE_ANNOTATION = CALL_SITE_PACKAGE + ".CallSite"; + public static final String BEFORE_ANNOTATION = CALL_SITE_ANNOTATION + "$Before"; + public static final String BEFORE_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$BeforeArray"; + public static final String AROUND_ANNOTATION = CALL_SITE_ANNOTATION + "$Around"; + public static final String AROUND_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$AroundArray"; + public static final String AFTER_ANNOTATION = CALL_SITE_ANNOTATION + "$After"; + public static final String AFTER_ARRAY_ANNOTATION = CALL_SITE_ANNOTATION + "$AfterArray"; + public static final String THIS_ANNOTATION = CALL_SITE_ANNOTATION + "$This"; + public static final String ALL_ARGS_ANNOTATION = CALL_SITE_ANNOTATION + "$AllArguments"; + public static final String INVOKE_DYNAMIC_CONSTANTS_ANNOTATION = CALL_SITE_ANNOTATION + "$InvokeDynamicConstants"; - public static final String ARGUMENT_ANNOTATION = CALL_SITE_ANNOTATION + "$Argument"; - public static final String RETURN_ANNOTATION = CALL_SITE_ANNOTATION + "$Return"; - public static final String CALL_SITE_ADVICE_CLASS = "CallSiteAdvice"; + public static final String ARGUMENT_ANNOTATION = CALL_SITE_ANNOTATION + "$Argument"; + public static final String RETURN_ANNOTATION = CALL_SITE_ANNOTATION + "$Return"; + public static final String CALL_SITE_ADVICE_CLASS = "CallSiteAdvice"; - public static final String CALL_SITE_ADVICE_FQCN = - CALL_SITE_PACKAGE + "." + CALL_SITE_ADVICE_CLASS; + public static final String CALL_SITE_ADVICE_FQCN = CALL_SITE_PACKAGE + "." + CALL_SITE_ADVICE_CLASS; - public static final String CALL_SITES_CLASS = "CallSites"; - public static final String CALL_SITES_FQCN = CALL_SITE_PACKAGE + "." + CALL_SITES_CLASS; + public static final String CALL_SITES_CLASS = "CallSites"; + public static final String CALL_SITES_FQCN = CALL_SITE_PACKAGE + "." + CALL_SITES_CLASS; - public static final String HAS_ENABLED_PROPERTY_CLASS = CALL_SITES_CLASS + ".HasEnabledProperty"; + public static final String HAS_ENABLED_PROPERTY_CLASS = CALL_SITES_CLASS + ".HasEnabledProperty"; - public static final String ADVICE_TYPE_CLASS = "AdviceType"; + public static final String ADVICE_TYPE_CLASS = "AdviceType"; - public static final String STACK_DUP_MODE_CLASS = "StackDupMode"; + public static final String STACK_DUP_MODE_CLASS = "StackDupMode"; - public static final String METHOD_HANDLER_CLASS = "MethodHandler"; + public static final String METHOD_HANDLER_CLASS = "MethodHandler"; - public static final String CONSTRUCTOR_METHOD = ""; + public static final String CONSTRUCTOR_METHOD = ""; - /** - * {@link datadog.trace.plugin.csi.ValidationContext} property name for the {@link - * datadog.trace.plugin.csi.TypeResolver} - */ - public static final String TYPE_RESOLVER = "typeResolver"; + /** + * {@link datadog.trace.plugin.csi.ValidationContext} property name for the {@link + * datadog.trace.plugin.csi.TypeResolver} + */ + public static final String TYPE_RESOLVER = "typeResolver"; - /** ASM version to use in all CSI related tasks */ - public static final int ASM_API_VERSION = Opcodes.ASM7; + /** ASM version to use in all CSI related tasks */ + public static final int ASM_API_VERSION = Opcodes.ASM7; - public static final String AUTO_SERVICE_FQDN = "com.google.auto.service.AutoService"; + public static final String AUTO_SERVICE_FQDN = "com.google.auto.service.AutoService"; - public static final String HANDLE_FQDN = "net.bytebuddy.jar.asm.Handle"; + public static final String HANDLE_FQDN = "net.bytebuddy.jar.asm.Handle"; - public static final String OPCODES_FQDN = "net.bytebuddy.jar.asm.Opcodes"; + public static final String OPCODES_FQDN = "net.bytebuddy.jar.asm.Opcodes"; } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java index 1f9ecdc5aad..a5fcb9515c1 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/CallSiteUtils.java @@ -12,58 +12,59 @@ public abstract class CallSiteUtils { - private CallSiteUtils() {} + private CallSiteUtils() {} - public static Type classNameToType(@Nonnull final String className) { - return Type.getType(classNameToDescriptor(className)); - } - - public static String classNameToDescriptor(@Nonnull final Class clazz) { - return classNameToDescriptor(clazz.getName()); - } + public static Type classNameToType(@Nonnull final String className) { + return Type.getType(classNameToDescriptor(className)); + } - public static String classNameToDescriptor(@Nonnull final String className) { - return "L" + className.replaceAll("\\.", "/") + ";"; - } + public static String classNameToDescriptor(@Nonnull final Class clazz) { + return classNameToDescriptor(clazz.getName()); + } - public static void deleteFile(@Nonnull final File file) { - if (file.exists() && !file.delete()) { - throw new RuntimeException("Cannot delete file: " + file); + public static String classNameToDescriptor(@Nonnull final String className) { + return "L" + className.replaceAll("\\.", "/") + ";"; } - } - public static String repeat(@Nonnull final String value, int count) { - if (count < 0) { - throw new IllegalArgumentException("count is negative: " + count); + public static void deleteFile(@Nonnull final File file) { + if (file.exists() && !file.delete()) { + throw new RuntimeException("Cannot delete file: " + file); + } } - if (count == 1) { - return value; + + public static String repeat(@Nonnull final String value, int count) { + if (count < 0) { + throw new IllegalArgumentException("count is negative: " + count); + } + if (count == 1) { + return value; + } + return IntStream.range(0, count).mapToObj(i -> value).collect(Collectors.joining()); } - return IntStream.range(0, count).mapToObj(i -> value).collect(Collectors.joining()); - } - public static String repeat(final char value, int count) { - return repeat(Character.toString(value), count); - } + public static String repeat(final char value, int count) { + return repeat(Character.toString(value), count); + } - public static URL toURL(final Path path) { - try { - URL url = path.toUri().toURL(); - // URLClassLoader interprets URLs ending with '/' as directories. If the trailing '/' is - // missing, a directory URL is treated as a JAR. If the path does yet exist on disk - // assumes that paths not ending with ".jar" are directories. - boolean shouldAddSlash = - Files.exists(path) ? Files.isDirectory(path) : !path.toString().endsWith(".jar"); + public static URL toURL(final Path path) { + try { + URL url = path.toUri().toURL(); + // URLClassLoader interprets URLs ending with '/' as directories. If the trailing '/' is + // missing, a directory URL is treated as a JAR. If the path does yet exist on disk + // assumes that paths not ending with ".jar" are directories. + boolean shouldAddSlash = Files.exists(path) + ? Files.isDirectory(path) + : !path.toString().endsWith(".jar"); - if (shouldAddSlash) { - String urlString = url.toString(); - if (!urlString.endsWith("/")) { - url = new URL(urlString + "/"); + if (shouldAddSlash) { + String urlString = url.toString(); + if (!urlString.endsWith("/")) { + url = new URL(urlString + "/"); + } + } + return url; + } catch (MalformedURLException e) { + throw new RuntimeException(e); } - } - return url; - } catch (MalformedURLException e) { - throw new RuntimeException(e); } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java index 293125a76ee..33b99158dc8 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/ErrorCode.java @@ -6,422 +6,418 @@ public enum ErrorCode implements Function { - // TypeResolver - - UNRESOLVED_TYPE { - @Override - public String apply(final Object[] objects) { - final Type type = (Type) objects[0]; - return String.format("Cannot resolve type '%s'", type.getClassName()); - } - }, - - UNRESOLVED_METHOD { - @Override - public String apply(final Object[] objects) { - final MethodType method = (MethodType) objects[0]; - return String.format( - "Cannot resolve method '%s' in type '%s' with descriptor '%s'", - method.getMethodName(), method.getOwner(), method.getMethodType().getDescriptor()); - } - }, - - // Specification - - CALL_SITE_SHOULD_HAVE_ADVICE_METHODS { - @Override - public String apply(final Object[] objects) { - return "@CallSite annotated class should contain advice methods (@Before, @Around or @After)"; - } - }, - - CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE { - @Override - public String apply(final Object[] objects) { - final Class spiClass = (Class) objects[0]; - return String.format("@CallSite spi class should be an interface, received '%s'", spiClass); - } - }, - - CALL_SITE_ENABLED_SHOULD_BE_STATIC_AND_PUBLIC { - @Override - public String apply(final Object[] objects) { - final Method method = (Method) objects[0]; - return String.format( - "@CallSite enabled method should be static and public, received '%s'", method); - } - }, - - CALL_SITE_SPI_SHOULD_BE_EMPTY { - @Override - public String apply(final Object[] objects) { - final Class spiClass = (Class) objects[0]; - return String.format( - "@CallSite spi class should not define any methods, spi class '%s'", spiClass); - } - }, - - ADVICE_METHOD_NOT_STATIC_AND_PUBLIC { - @Override - public String apply(final Object[] objects) { - return "Advice method should be static and public"; - } - }, - - ADVICE_METHOD_RETURN_NOT_COMPATIBLE { - @Override - public String apply(final Object[] objects) { - final Type pointcut = (Type) objects[0]; - final Type callSite = (Type) objects[1]; - return String.format( - "Call site return '%s' not compatible with pointcut return '%s'", - callSite.getClassName(), pointcut.getClassName()); - } - }, - - ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE { - @Override - public String apply(final Object[] objects) { - final Type pointcut = (Type) objects[0]; - final Type callSite = (Type) objects[1]; - return String.format( - "Call site @This with type '%s' not compatible with pointcut owner '%s'", - callSite.getClassName(), pointcut.getClassName()); - } - }, - - ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE { - @Override - public String apply(final Object[] objects) { - final Type pointcut = (Type) objects[0]; - final Type callSite = (Type) objects[1]; - return String.format( - "Call site @Return with type '%s' not compatible with pointcut owner '%s'", - callSite.getClassName(), pointcut.getClassName()); - } - }, - - ADVICE_METHOD_PARAM_ALL_ARGS_NOT_COMPATIBLE { - @Override - public String apply(final Object[] objects) { - final Type pointcut = (Type) objects[0]; - final Type callSite = (Type) objects[1]; - return String.format( - "Call site @AllArguments with type '%s' not compatible with '%s'", - callSite.getClassName(), pointcut.getClassName()); - } - }, - - ADVICE_METHOD_PARAM_NOT_COMPATIBLE { - @Override - public String apply(final Object[] objects) { - final Type pointcut = (Type) objects[0]; - final Type callSite = (Type) objects[1]; - final int index = (int) objects[2]; - return String.format( - "Call site @Argument with type '%s' not compatible with pointcut parameter type '%s', found at index '%s'", - callSite.getClassName(), pointcut.getClassName(), index); - } - }, - - ADVICE_PARAMETER_NOT_ANNOTATED { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format("Call site parameter not annotated, found at index '%s'", index); - } - }, - - ADVICE_PARAMETER_THIS_DUPLICATED { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @This is duplicated, found at index '%s'", index); - } - }, - - ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @This should be the first, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_THIS_ON_STATIC_METHOD { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter can't be annotated with @This for static pointcut methods, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_THIS_ON_INVOKE_DYNAMIC { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter can't be annotated with @This for invoke dynamic instructions, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_RETURN_DUPLICATED { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @Return duplicated, found at index '%s'", index); - } - }, - - ADVICE_PARAMETER_ALL_ARGS_DUPLICATED { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @AllArguments duplicated, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_DUPLICATED { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @InvokeDynamicConstants duplicated, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NOT_COMPATIBLE { - @Override - public String apply(final Object[] objects) { - final Type pointcut = (Type) objects[0]; - final Type callSite = (Type) objects[1]; - return String.format( - "Call site @InvokeDynamicConstants with type '%s' not compatible with '%s'", - callSite.getClassName(), pointcut.getClassName()); - } - }, - - ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @InvokeDynamicConstants should be the last, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @InvokeDynamicConstants should only be used on @After call sites, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @InvokeDynamicConstants should target only invoke dynamic instructions, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_ALL_ARGS_MIXED { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @AllArguments mixed with @Argument, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @Argument out of bounds for pointcut, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_ON_INVOKE_DYNAMIC { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameters can't be annotated with @Argument for invoke dynamic instructions, found at index '%s'", - index); - } - }, - - ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @Return should be last, found at index '%s'", index); - } - }, - - ADVICE_PARAMETER_RETURN_NON_AFTER_ADVICE { - @Override - public String apply(final Object[] objects) { - final int index = (int) objects[0]; - return String.format( - "Call site parameter annotated with @Return should only be used on @After call sites, found at index '%s'", - index); - } - }, - - ADVICE_POINTCUT_CONSTRUCTOR_NOT_VOID { - @Override - public String apply(final Object[] objects) { - final MethodType pointcut = (MethodType) objects[0]; - return String.format( - "Pointcut return type should be void for constructors, found '%s'", - pointcut.getMethodType().getReturnType().getClassName()); - } - }, - - ADVICE_BEFORE_SHOULD_RETURN_VOID { - @Override - public String apply(final Object[] objects) { - final MethodType advice = (MethodType) objects[0]; - return String.format( - "Before advice should return void, received '%s'", - advice.getMethodType().getReturnType().getClassName()); - } - }, - - ADVICE_BEFORE_SHOULD_NOT_CONTAIN_RETURN { - @Override - public String apply(final Object[] objects) { - return "Before advice should not contain @Return annotated parameters"; - } - }, - - ADVICE_BEFORE_CTOR_SHOULD_NOT_CONTAIN_THIS { - @Override - public String apply(final Object[] objects) { - return "Before advice should not contain @This annotated parameters in constructors"; - } - }, - - ADVICE_AROUND_SHOULD_NOT_RETURN_VOID { - @Override - public String apply(final Object[] objects) { - return "Around advice should not return void"; - } - }, - - ADVICE_AROUND_POINTCUT_CTOR { - @Override - public String apply(final Object[] objects) { - return "Around advice not yet supported for constructors"; - } - }, - - ADVICE_AROUND_SHOULD_NOT_CONTAIN_RETURN { - @Override - public String apply(final Object[] objects) { - return "Around advice should not contain @Return annotated parameters"; - } - }, - - ADVICE_AFTER_SHOULD_NOT_RETURN_VOID { - @Override - public String apply(final Object[] objects) { - return "After advice should not return void"; - } - }, - - ADVICE_AFTER_SHOULD_HAVE_THIS { - @Override - public String apply(final Object[] objects) { - return "After advice first parameter should be annotated with @This or @AllArguments(includeThis = true)"; - } - }, - - ADVICE_AFTER_VOID_METHOD_SHOULD_RETURN_VOID { - @Override - public String apply(final Object[] objects) { - return "After advice for void method should return void"; - } - }, - - ADVICE_AFTER_VOID_METHOD_SHOULD_NOT_HAVE_RETURN { - @Override - public String apply(final Object[] objects) { - return "After advice for void method should not contain @Return annotated parameters"; - } - }, - - ADVICE_AFTER_SHOULD_HAVE_RETURN { - @Override - public String apply(final Object[] objects) { - return "After advice last parameter should be annotated with @Return"; - } - }, - - ADVICE_AFTER_CONSTRUCTOR_ALL_ARGUMENTS { - @Override - public String apply(final Object[] objects) { - return "After advice in constructors should use @AllArguments"; - } - }, - - POINTCUT_SIGNATURE_INVALID { - @Override - public String apply(final Object[] objects) { - final String signature = (String) objects[0]; - final String pattern = (String) objects[1]; - return String.format( - "Pointcut signature '%s' does not match the regular expression '%s'", signature, pattern); - } - }, - - POINTCUT_SIGNATURE_INVALID_TYPE { - @Override - public String apply(final Object[] objects) { - final String signature = (String) objects[0]; - final String type = (String) objects[1]; - return String.format("Pointcut signature '%s' contains in valid type '%s'", signature, type); - } - }, - - // Errors in extensions - - EXTENSION_ERROR { - @Override - public String apply(final Object[] objects) { - final Class extension = (Class) objects[0]; - return String.format("Failed to apply extension %s", extension); - } - }, - - // Others - - UNCAUGHT_ERROR { - @Override - public String apply(final Object[] objects) { - return "Uncaught error"; + // TypeResolver + + UNRESOLVED_TYPE { + @Override + public String apply(final Object[] objects) { + final Type type = (Type) objects[0]; + return String.format("Cannot resolve type '%s'", type.getClassName()); + } + }, + + UNRESOLVED_METHOD { + @Override + public String apply(final Object[] objects) { + final MethodType method = (MethodType) objects[0]; + return String.format( + "Cannot resolve method '%s' in type '%s' with descriptor '%s'", + method.getMethodName(), + method.getOwner(), + method.getMethodType().getDescriptor()); + } + }, + + // Specification + + CALL_SITE_SHOULD_HAVE_ADVICE_METHODS { + @Override + public String apply(final Object[] objects) { + return "@CallSite annotated class should contain advice methods (@Before, @Around or @After)"; + } + }, + + CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE { + @Override + public String apply(final Object[] objects) { + final Class spiClass = (Class) objects[0]; + return String.format("@CallSite spi class should be an interface, received '%s'", spiClass); + } + }, + + CALL_SITE_ENABLED_SHOULD_BE_STATIC_AND_PUBLIC { + @Override + public String apply(final Object[] objects) { + final Method method = (Method) objects[0]; + return String.format("@CallSite enabled method should be static and public, received '%s'", method); + } + }, + + CALL_SITE_SPI_SHOULD_BE_EMPTY { + @Override + public String apply(final Object[] objects) { + final Class spiClass = (Class) objects[0]; + return String.format("@CallSite spi class should not define any methods, spi class '%s'", spiClass); + } + }, + + ADVICE_METHOD_NOT_STATIC_AND_PUBLIC { + @Override + public String apply(final Object[] objects) { + return "Advice method should be static and public"; + } + }, + + ADVICE_METHOD_RETURN_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site return '%s' not compatible with pointcut return '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @This with type '%s' not compatible with pointcut owner '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @Return with type '%s' not compatible with pointcut owner '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_ALL_ARGS_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @AllArguments with type '%s' not compatible with '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_METHOD_PARAM_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + final int index = (int) objects[2]; + return String.format( + "Call site @Argument with type '%s' not compatible with pointcut parameter type '%s', found at index '%s'", + callSite.getClassName(), pointcut.getClassName(), index); + } + }, + + ADVICE_PARAMETER_NOT_ANNOTATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format("Call site parameter not annotated, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_THIS_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format("Call site parameter annotated with @This is duplicated, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @This should be the first, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_THIS_ON_STATIC_METHOD { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter can't be annotated with @This for static pointcut methods, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_THIS_ON_INVOKE_DYNAMIC { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter can't be annotated with @This for invoke dynamic instructions, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_RETURN_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format("Call site parameter annotated with @Return duplicated, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_ALL_ARGS_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @AllArguments duplicated, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_DUPLICATED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants duplicated, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NOT_COMPATIBLE { + @Override + public String apply(final Object[] objects) { + final Type pointcut = (Type) objects[0]; + final Type callSite = (Type) objects[1]; + return String.format( + "Call site @InvokeDynamicConstants with type '%s' not compatible with '%s'", + callSite.getClassName(), pointcut.getClassName()); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants should be the last, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants should only be used on @After call sites, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @InvokeDynamicConstants should target only invoke dynamic instructions, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_ALL_ARGS_MIXED { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @AllArguments mixed with @Argument, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @Argument out of bounds for pointcut, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_ON_INVOKE_DYNAMIC { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameters can't be annotated with @Argument for invoke dynamic instructions, found at index '%s'", + index); + } + }, + + ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @Return should be last, found at index '%s'", index); + } + }, + + ADVICE_PARAMETER_RETURN_NON_AFTER_ADVICE { + @Override + public String apply(final Object[] objects) { + final int index = (int) objects[0]; + return String.format( + "Call site parameter annotated with @Return should only be used on @After call sites, found at index '%s'", + index); + } + }, + + ADVICE_POINTCUT_CONSTRUCTOR_NOT_VOID { + @Override + public String apply(final Object[] objects) { + final MethodType pointcut = (MethodType) objects[0]; + return String.format( + "Pointcut return type should be void for constructors, found '%s'", + pointcut.getMethodType().getReturnType().getClassName()); + } + }, + + ADVICE_BEFORE_SHOULD_RETURN_VOID { + @Override + public String apply(final Object[] objects) { + final MethodType advice = (MethodType) objects[0]; + return String.format( + "Before advice should return void, received '%s'", + advice.getMethodType().getReturnType().getClassName()); + } + }, + + ADVICE_BEFORE_SHOULD_NOT_CONTAIN_RETURN { + @Override + public String apply(final Object[] objects) { + return "Before advice should not contain @Return annotated parameters"; + } + }, + + ADVICE_BEFORE_CTOR_SHOULD_NOT_CONTAIN_THIS { + @Override + public String apply(final Object[] objects) { + return "Before advice should not contain @This annotated parameters in constructors"; + } + }, + + ADVICE_AROUND_SHOULD_NOT_RETURN_VOID { + @Override + public String apply(final Object[] objects) { + return "Around advice should not return void"; + } + }, + + ADVICE_AROUND_POINTCUT_CTOR { + @Override + public String apply(final Object[] objects) { + return "Around advice not yet supported for constructors"; + } + }, + + ADVICE_AROUND_SHOULD_NOT_CONTAIN_RETURN { + @Override + public String apply(final Object[] objects) { + return "Around advice should not contain @Return annotated parameters"; + } + }, + + ADVICE_AFTER_SHOULD_NOT_RETURN_VOID { + @Override + public String apply(final Object[] objects) { + return "After advice should not return void"; + } + }, + + ADVICE_AFTER_SHOULD_HAVE_THIS { + @Override + public String apply(final Object[] objects) { + return "After advice first parameter should be annotated with @This or @AllArguments(includeThis = true)"; + } + }, + + ADVICE_AFTER_VOID_METHOD_SHOULD_RETURN_VOID { + @Override + public String apply(final Object[] objects) { + return "After advice for void method should return void"; + } + }, + + ADVICE_AFTER_VOID_METHOD_SHOULD_NOT_HAVE_RETURN { + @Override + public String apply(final Object[] objects) { + return "After advice for void method should not contain @Return annotated parameters"; + } + }, + + ADVICE_AFTER_SHOULD_HAVE_RETURN { + @Override + public String apply(final Object[] objects) { + return "After advice last parameter should be annotated with @Return"; + } + }, + + ADVICE_AFTER_CONSTRUCTOR_ALL_ARGUMENTS { + @Override + public String apply(final Object[] objects) { + return "After advice in constructors should use @AllArguments"; + } + }, + + POINTCUT_SIGNATURE_INVALID { + @Override + public String apply(final Object[] objects) { + final String signature = (String) objects[0]; + final String pattern = (String) objects[1]; + return String.format( + "Pointcut signature '%s' does not match the regular expression '%s'", signature, pattern); + } + }, + + POINTCUT_SIGNATURE_INVALID_TYPE { + @Override + public String apply(final Object[] objects) { + final String signature = (String) objects[0]; + final String type = (String) objects[1]; + return String.format("Pointcut signature '%s' contains in valid type '%s'", signature, type); + } + }, + + // Errors in extensions + + EXTENSION_ERROR { + @Override + public String apply(final Object[] objects) { + final Class extension = (Class) objects[0]; + return String.format("Failed to apply extension %s", extension); + } + }, + + // Others + + UNCAUGHT_ERROR { + @Override + public String apply(final Object[] objects) { + return "Uncaught error"; + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/JavaParserUtils.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/JavaParserUtils.java index 5d45b5d09f8..3658884ef27 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/JavaParserUtils.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/JavaParserUtils.java @@ -9,26 +9,25 @@ public abstract class JavaParserUtils { - private JavaParserUtils() {} + private JavaParserUtils() {} - public static Expression intLiteral(final int value) { - return new IntegerLiteralExpr().setValue(Integer.toString(value)); - } + public static Expression intLiteral(final int value) { + return new IntegerLiteralExpr().setValue(Integer.toString(value)); + } - public static FieldAccessExpr accessLocalField(final String name) { - return new FieldAccessExpr().setScope(new ThisExpr()).setName(name); - } + public static FieldAccessExpr accessLocalField(final String name) { + return new FieldAccessExpr().setScope(new ThisExpr()).setName(name); + } - public static ClassOrInterfaceDeclaration getPrimaryType(final CompilationUnit javaClass) { - return javaClass - .getPrimaryType() - .orElseGet(() -> javaClass.getTypes().get(0)) - .asClassOrInterfaceDeclaration(); - } + public static ClassOrInterfaceDeclaration getPrimaryType(final CompilationUnit javaClass) { + return javaClass + .getPrimaryType() + .orElseGet(() -> javaClass.getTypes().get(0)) + .asClassOrInterfaceDeclaration(); + } - public static boolean implementsInterface( - final ClassOrInterfaceDeclaration type, final String interfaceName) { - return type.getImplementedTypes().stream() - .anyMatch(it -> it.getNameAsString().equals(interfaceName)); - } + public static boolean implementsInterface(final ClassOrInterfaceDeclaration type, final String interfaceName) { + return type.getImplementedTypes().stream() + .anyMatch(it -> it.getNameAsString().equals(interfaceName)); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java index ea1dddf5eb8..a0344146033 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/MethodType.java @@ -11,73 +11,72 @@ /** Description of a method including its declaring type, name and descriptor. */ public class MethodType { - private final Type owner; - private final String methodName; - private final Type methodType; - private final boolean constructor; + private final Type owner; + private final String methodName; + private final Type methodType; + private final boolean constructor; - public MethodType( - @Nonnull final Type owner, @Nonnull final String methodName, @Nonnull final Type methodType) { - if (owner.getSort() == Type.METHOD) { - throw new IllegalArgumentException("Owner should not be a method " + owner); + public MethodType(@Nonnull final Type owner, @Nonnull final String methodName, @Nonnull final Type methodType) { + if (owner.getSort() == Type.METHOD) { + throw new IllegalArgumentException("Owner should not be a method " + owner); + } + if (methodType.getSort() != Type.METHOD) { + throw new IllegalArgumentException("Type should be a method " + methodType); + } + this.owner = owner; + this.methodName = methodName; + this.methodType = methodType; + constructor = CONSTRUCTOR_METHOD.equals(methodName); } - if (methodType.getSort() != Type.METHOD) { - throw new IllegalArgumentException("Type should be a method " + methodType); - } - this.owner = owner; - this.methodName = methodName; - this.methodType = methodType; - constructor = CONSTRUCTOR_METHOD.equals(methodName); - } - - public Type getOwner() { - return owner; - } - public String getMethodName() { - return methodName; - } + public Type getOwner() { + return owner; + } - public Type getMethodType() { - return methodType; - } + public String getMethodName() { + return methodName; + } - public boolean isConstructor() { - return constructor; - } + public Type getMethodType() { + return methodType; + } - public boolean isVoidReturn() { - return Type.VOID_TYPE.equals(methodType.getReturnType()); - } + public boolean isConstructor() { + return constructor; + } - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; + public boolean isVoidReturn() { + return Type.VOID_TYPE.equals(methodType.getReturnType()); } - if (o == null || getClass() != o.getClass()) { - return false; + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MethodType that = (MethodType) o; + return Objects.equals(owner, that.owner) + && Objects.equals(methodName, that.methodName) + && Objects.equals(methodType, that.methodType); } - MethodType that = (MethodType) o; - return Objects.equals(owner, that.owner) - && Objects.equals(methodName, that.methodName) - && Objects.equals(methodType, that.methodType); - } - @Override - public int hashCode() { - return Objects.hash(owner, methodName, methodType); - } + @Override + public int hashCode() { + return Objects.hash(owner, methodName, methodType); + } - @Override - public String toString() { - return String.format( - "%s %s.%s(%s)", - methodType.getReturnType().getClassName(), - owner.getClassName(), - methodName, - Arrays.stream(methodType.getArgumentTypes()) - .map(Type::getClassName) - .collect(Collectors.joining(", "))); - } + @Override + public String toString() { + return String.format( + "%s %s.%s(%s)", + methodType.getReturnType().getClassName(), + owner.getClassName(), + methodName, + Arrays.stream(methodType.getArgumentTypes()) + .map(Type::getClassName) + .collect(Collectors.joining(", "))); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/Types.java b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/Types.java index d4ff86e1399..a2684a0d92a 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/Types.java +++ b/buildSrc/call-site-instrumentation-plugin/src/main/java/datadog/trace/plugin/csi/util/Types.java @@ -3,9 +3,9 @@ import org.objectweb.asm.Type; public abstract class Types { - private Types() {} + private Types() {} - public static final Type BOOLEAN = Type.BOOLEAN_TYPE; - public static final Type STRING = Type.getType(String.class); - public static final Type OBJECT_ARRAY = Type.getType(Object[].class); + public static final Type BOOLEAN = Type.BOOLEAN_TYPE; + public static final Type STRING = Type.getType(String.class); + public static final Type OBJECT_ARRAY = Type.getType(Object[].class); } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceGeneratorTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceGeneratorTest.java index 3206b3c9b31..04c31ac2dab 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceGeneratorTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceGeneratorTest.java @@ -23,556 +23,496 @@ class AdviceGeneratorTest extends BaseCsiPluginTest { - @TempDir private File buildDir; - - @CallSite(spi = CallSites.class) - public static class BeforeAdvice { - @CallSite.Before( - "java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)") - public static void before(@CallSite.Argument String algorithm) {} - } - - @Test - void testBeforeAdvice() { - CallSiteSpecification spec = buildClassSpecification(BeforeAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class); - asserter.helpers(BeforeAdvice.class); - asserter.advices( - 0, - advice -> { - advice.type("BEFORE"); - advice.pointcut( - "java/security/MessageDigest", - "getInstance", - "(Ljava/lang/String;)Ljava/security/MessageDigest;"); - advice.statements( - "handler.dupParameters(descriptor, StackDupMode.COPY);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$BeforeAdvice\", \"before\", \"(Ljava/lang/String;)V\");", - "handler.method(opcode, owner, name, descriptor, isInterface);"); + @TempDir + private File buildDir; + + @CallSite(spi = CallSites.class) + public static class BeforeAdvice { + @CallSite.Before("java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)") + public static void before(@CallSite.Argument String algorithm) {} + } + + @Test + void testBeforeAdvice() { + CallSiteSpecification spec = buildClassSpecification(BeforeAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class); + asserter.helpers(BeforeAdvice.class); + asserter.advices(0, advice -> { + advice.type("BEFORE"); + advice.pointcut( + "java/security/MessageDigest", "getInstance", "(Ljava/lang/String;)Ljava/security/MessageDigest;"); + advice.statements( + "handler.dupParameters(descriptor, StackDupMode.COPY);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$BeforeAdvice\", \"before\", \"(Ljava/lang/String;)V\");", + "handler.method(opcode, owner, name, descriptor, isInterface);"); }); - } - - @CallSite(spi = CallSites.class) - public static class AroundAdvice { - @CallSite.Around( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") - public static String around( - @CallSite.This String self, - @CallSite.Argument String regexp, - @CallSite.Argument String replacement) { - return self.replaceAll(regexp, replacement); - } - } - - @Test - void testAroundAdvice() { - CallSiteSpecification spec = buildClassSpecification(AroundAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class); - asserter.helpers(AroundAdvice.class); - asserter.advices( - 0, - advice -> { - advice.type("AROUND"); - advice.pointcut( - "java/lang/String", - "replaceAll", - "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); - advice.statements( - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AroundAdvice\", \"around\", \"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;\");"); + } + + @CallSite(spi = CallSites.class) + public static class AroundAdvice { + @CallSite.Around("java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") + public static String around( + @CallSite.This String self, @CallSite.Argument String regexp, @CallSite.Argument String replacement) { + return self.replaceAll(regexp, replacement); + } + } + + @Test + void testAroundAdvice() { + CallSiteSpecification spec = buildClassSpecification(AroundAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class); + asserter.helpers(AroundAdvice.class); + asserter.advices(0, advice -> { + advice.type("AROUND"); + advice.pointcut( + "java/lang/String", "replaceAll", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); + advice.statements( + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AroundAdvice\", \"around\", \"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;\");"); }); - } - - @CallSite(spi = CallSites.class) - public static class AfterAdvice { - @CallSite.After("java.lang.String java.lang.String.concat(java.lang.String)") - public static String after( - @CallSite.This String self, - @CallSite.Argument String param, - @CallSite.Return String result) { - return result; - } - } - - @Test - void testAfterAdvice() { - CallSiteSpecification spec = buildClassSpecification(AfterAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class); - asserter.helpers(AfterAdvice.class); - asserter.advices( - 0, - advice -> { - advice.type("AFTER"); - advice.pointcut("java/lang/String", "concat", "(Ljava/lang/String;)Ljava/lang/String;"); - advice.statements( - "handler.dupInvoke(owner, descriptor, StackDupMode.COPY);", - "handler.method(opcode, owner, name, descriptor, isInterface);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AfterAdvice\", \"after\", \"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;\");"); + } + + @CallSite(spi = CallSites.class) + public static class AfterAdvice { + @CallSite.After("java.lang.String java.lang.String.concat(java.lang.String)") + public static String after( + @CallSite.This String self, @CallSite.Argument String param, @CallSite.Return String result) { + return result; + } + } + + @Test + void testAfterAdvice() { + CallSiteSpecification spec = buildClassSpecification(AfterAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class); + asserter.helpers(AfterAdvice.class); + asserter.advices(0, advice -> { + advice.type("AFTER"); + advice.pointcut("java/lang/String", "concat", "(Ljava/lang/String;)Ljava/lang/String;"); + advice.statements( + "handler.dupInvoke(owner, descriptor, StackDupMode.COPY);", + "handler.method(opcode, owner, name, descriptor, isInterface);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AfterAdvice\", \"after\", \"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;\");"); }); - } - - @CallSite(spi = CallSites.class) - public static class AfterAdviceCtor { - @CallSite.After("void java.net.URL.(java.lang.String)") - public static URL after(@CallSite.AllArguments Object[] args, @CallSite.Return URL url) { - return url; - } - } - - @Test - void testAfterAdviceCtor() { - CallSiteSpecification spec = buildClassSpecification(AfterAdviceCtor.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class); - asserter.helpers(AfterAdviceCtor.class); - asserter.advices( - 0, - advice -> { - advice.pointcut("java/net/URL", "", "(Ljava/lang/String;)V"); - advice.statements( - "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY_CTOR);", - "handler.method(opcode, owner, name, descriptor, isInterface);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AfterAdviceCtor\", \"after\", \"([Ljava/lang/Object;Ljava/net/URL;)Ljava/net/URL;\");"); + } + + @CallSite(spi = CallSites.class) + public static class AfterAdviceCtor { + @CallSite.After("void java.net.URL.(java.lang.String)") + public static URL after(@CallSite.AllArguments Object[] args, @CallSite.Return URL url) { + return url; + } + } + + @Test + void testAfterAdviceCtor() { + CallSiteSpecification spec = buildClassSpecification(AfterAdviceCtor.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class); + asserter.helpers(AfterAdviceCtor.class); + asserter.advices(0, advice -> { + advice.pointcut("java/net/URL", "", "(Ljava/lang/String;)V"); + advice.statements( + "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY_CTOR);", + "handler.method(opcode, owner, name, descriptor, isInterface);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AfterAdviceCtor\", \"after\", \"([Ljava/lang/Object;Ljava/net/URL;)Ljava/net/URL;\");"); }); - } - - @CallSite(spi = SpiAdvice.SampleSpi.class) - public static class SpiAdvice { - @CallSite.Before( - "java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)") - public static void before(@CallSite.Argument String algorithm) {} - - interface SampleSpi {} - } - - @Test - void testGeneratorWithSpi() { - CallSiteSpecification spec = buildClassSpecification(SpiAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class, SpiAdvice.SampleSpi.class); - } - - @CallSite(spi = CallSites.class) - public static class InvokeDynamicAfterAdvice { - @CallSite.After( - value = - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamic = true) - public static String after( - @CallSite.AllArguments Object[] arguments, @CallSite.Return String result) { - return result; - } - } - - @Test - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicAfterAdvice() { - CallSiteSpecification spec = buildClassSpecification(InvokeDynamicAfterAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class); - asserter.helpers(InvokeDynamicAfterAdvice.class); - asserter.advices( - 0, - advice -> { - advice.pointcut( - "java/lang/invoke/StringConcatFactory", - "makeConcatWithConstants", - "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;"); - advice.statements( - "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY);", - "handler.invokeDynamic(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$InvokeDynamicAfterAdvice\", \"after\", \"([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;\");"); + } + + @CallSite(spi = SpiAdvice.SampleSpi.class) + public static class SpiAdvice { + @CallSite.Before("java.security.MessageDigest java.security.MessageDigest.getInstance(java.lang.String)") + public static void before(@CallSite.Argument String algorithm) {} + + interface SampleSpi {} + } + + @Test + void testGeneratorWithSpi() { + CallSiteSpecification spec = buildClassSpecification(SpiAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class, SpiAdvice.SampleSpi.class); + } + + @CallSite(spi = CallSites.class) + public static class InvokeDynamicAfterAdvice { + @CallSite.After( + value = + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamic = true) + public static String after(@CallSite.AllArguments Object[] arguments, @CallSite.Return String result) { + return result; + } + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicAfterAdvice() { + CallSiteSpecification spec = buildClassSpecification(InvokeDynamicAfterAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class); + asserter.helpers(InvokeDynamicAfterAdvice.class); + asserter.advices(0, advice -> { + advice.pointcut( + "java/lang/invoke/StringConcatFactory", + "makeConcatWithConstants", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;"); + advice.statements( + "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY);", + "handler.invokeDynamic(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$InvokeDynamicAfterAdvice\", \"after\", \"([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;\");"); }); - } - - @CallSite(spi = CallSites.class) - public static class InvokeDynamicAroundAdvice { - @CallSite.Around( - value = - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamic = true) - public static java.lang.invoke.CallSite around( - @CallSite.Argument MethodHandles.Lookup lookup, - @CallSite.Argument String name, - @CallSite.Argument MethodType concatType, - @CallSite.Argument String recipe, - @CallSite.Argument Object... constants) { - return null; - } - } - - @Test - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicAroundAdvice() { - CallSiteSpecification spec = buildClassSpecification(InvokeDynamicAroundAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class); - asserter.helpers(InvokeDynamicAroundAdvice.class); - asserter.advices( - 0, - advice -> { - advice.pointcut( - "java/lang/invoke/StringConcatFactory", - "makeConcatWithConstants", - "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;"); - advice.statements( - "handler.invokeDynamic(name, descriptor, new Handle(Opcodes.H_INVOKESTATIC, \"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$InvokeDynamicAroundAdvice\", \"around\", \"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;\", false), bootstrapMethodArguments);"); + } + + @CallSite(spi = CallSites.class) + public static class InvokeDynamicAroundAdvice { + @CallSite.Around( + value = + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamic = true) + public static java.lang.invoke.CallSite around( + @CallSite.Argument MethodHandles.Lookup lookup, + @CallSite.Argument String name, + @CallSite.Argument MethodType concatType, + @CallSite.Argument String recipe, + @CallSite.Argument Object... constants) { + return null; + } + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicAroundAdvice() { + CallSiteSpecification spec = buildClassSpecification(InvokeDynamicAroundAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class); + asserter.helpers(InvokeDynamicAroundAdvice.class); + asserter.advices(0, advice -> { + advice.pointcut( + "java/lang/invoke/StringConcatFactory", + "makeConcatWithConstants", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;"); + advice.statements( + "handler.invokeDynamic(name, descriptor, new Handle(Opcodes.H_INVOKESTATIC, \"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$InvokeDynamicAroundAdvice\", \"around\", \"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;\", false), bootstrapMethodArguments);"); }); - } - - @CallSite(spi = CallSites.class) - public static class InvokeDynamicWithConstantsAdvice { - @CallSite.After( - value = - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamic = true) - public static String after( - @CallSite.AllArguments Object[] arguments, - @CallSite.Return String result, - @CallSite.InvokeDynamicConstants Object[] constants) { - return result; - } - } - - @Test - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicWithConstantsAdvice() { - CallSiteSpecification spec = buildClassSpecification(InvokeDynamicWithConstantsAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class); - asserter.helpers(InvokeDynamicWithConstantsAdvice.class); - asserter.advices( - 0, - advice -> { - advice.pointcut( - "java/lang/invoke/StringConcatFactory", - "makeConcatWithConstants", - "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;"); - advice.statements( - "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY);", - "handler.invokeDynamic(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);", - "handler.loadConstantArray(bootstrapMethodArguments);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$InvokeDynamicWithConstantsAdvice\", \"after\", \"([Ljava/lang/Object;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;\");"); + } + + @CallSite(spi = CallSites.class) + public static class InvokeDynamicWithConstantsAdvice { + @CallSite.After( + value = + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamic = true) + public static String after( + @CallSite.AllArguments Object[] arguments, + @CallSite.Return String result, + @CallSite.InvokeDynamicConstants Object[] constants) { + return result; + } + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicWithConstantsAdvice() { + CallSiteSpecification spec = buildClassSpecification(InvokeDynamicWithConstantsAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class); + asserter.helpers(InvokeDynamicWithConstantsAdvice.class); + asserter.advices(0, advice -> { + advice.pointcut( + "java/lang/invoke/StringConcatFactory", + "makeConcatWithConstants", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;"); + advice.statements( + "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY);", + "handler.invokeDynamic(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);", + "handler.loadConstantArray(bootstrapMethodArguments);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$InvokeDynamicWithConstantsAdvice\", \"after\", \"([Ljava/lang/Object;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;\");"); }); - } - - @CallSite(spi = CallSites.class) - public static class ArrayAdvice { - @CallSite.AfterArray({ - @CallSite.After("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), - @CallSite.After("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") - }) - public static Map after( - @CallSite.This ServletRequest request, @CallSite.Return Map parameters) { - return parameters; - } - } - - @Test - void testArrayAdvice() { - CallSiteSpecification spec = buildClassSpecification(ArrayAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.advices( - 0, - advice -> { - advice.pointcut("javax/servlet/ServletRequest", "getParameterMap", "()Ljava/util/Map;"); + } + + @CallSite(spi = CallSites.class) + public static class ArrayAdvice { + @CallSite.AfterArray({ + @CallSite.After("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), + @CallSite.After("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") + }) + public static Map after(@CallSite.This ServletRequest request, @CallSite.Return Map parameters) { + return parameters; + } + } + + @Test + void testArrayAdvice() { + CallSiteSpecification spec = buildClassSpecification(ArrayAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.advices(0, advice -> { + advice.pointcut("javax/servlet/ServletRequest", "getParameterMap", "()Ljava/util/Map;"); }); - asserter.advices( - 1, - advice -> { - advice.pointcut( - "javax/servlet/ServletRequestWrapper", "getParameterMap", "()Ljava/util/Map;"); + asserter.advices(1, advice -> { + advice.pointcut("javax/servlet/ServletRequestWrapper", "getParameterMap", "()Ljava/util/Map;"); }); - } - - public static class MinJavaVersionCheck { - public static boolean isAtLeast(String version) { - return Integer.parseInt(version) >= 9; - } - } - - @CallSite( - spi = CallSites.class, - enabled = { - "datadog.trace.plugin.csi.impl.AdviceGeneratorTest$MinJavaVersionCheck", - "isAtLeast", - "18" - }) - public static class MinJavaVersionAdvice { - @CallSite.After("java.lang.String java.lang.String.concat(java.lang.String)") - public static String after( - @CallSite.This String self, - @CallSite.Argument String param, - @CallSite.Return String result) { - return result; - } - } - - @Test - void testCustomEnabledProperty() throws Exception { - CallSiteSpecification spec = buildClassSpecification(MinJavaVersionAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.interfaces(CallSites.class, CallSites.HasEnabledProperty.class); - asserter.enabled(MinJavaVersionCheck.class.getDeclaredMethod("isAtLeast", String.class), "18"); - } - - @CallSite(spi = CallSites.class) - public static class PartialArgumentsBeforeAdvice { - @CallSite.Before("int java.sql.Statement.executeUpdate(java.lang.String, java.lang.String[])") - public static void before(@CallSite.Argument(0) String arg1) {} - - @CallSite.Before( - "java.lang.String java.lang.String.format(java.lang.String, java.lang.Object[])") - public static void before(@CallSite.Argument(1) Object[] arg) {} - - @CallSite.Before("java.lang.CharSequence java.lang.String.subSequence(int, int)") - public static void before(@CallSite.This String thiz, @CallSite.Argument(0) int arg) {} - } - - @Test - void partialArgumentsWithBeforeAdvice() { - CallSiteSpecification spec = buildClassSpecification(PartialArgumentsBeforeAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.advices( - 0, - advice -> { - advice.pointcut( - "java/sql/Statement", "executeUpdate", "(Ljava/lang/String;[Ljava/lang/String;)I"); - advice.statements( - "int[] parameterIndices = new int[] { 0 };", - "handler.dupParameters(descriptor, parameterIndices, owner);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$PartialArgumentsBeforeAdvice\", \"before\", \"(Ljava/lang/String;)V\");", - "handler.method(opcode, owner, name, descriptor, isInterface);"); + } + + public static class MinJavaVersionCheck { + public static boolean isAtLeast(String version) { + return Integer.parseInt(version) >= 9; + } + } + + @CallSite( + spi = CallSites.class, + enabled = {"datadog.trace.plugin.csi.impl.AdviceGeneratorTest$MinJavaVersionCheck", "isAtLeast", "18"}) + public static class MinJavaVersionAdvice { + @CallSite.After("java.lang.String java.lang.String.concat(java.lang.String)") + public static String after( + @CallSite.This String self, @CallSite.Argument String param, @CallSite.Return String result) { + return result; + } + } + + @Test + void testCustomEnabledProperty() throws Exception { + CallSiteSpecification spec = buildClassSpecification(MinJavaVersionAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.interfaces(CallSites.class, CallSites.HasEnabledProperty.class); + asserter.enabled(MinJavaVersionCheck.class.getDeclaredMethod("isAtLeast", String.class), "18"); + } + + @CallSite(spi = CallSites.class) + public static class PartialArgumentsBeforeAdvice { + @CallSite.Before("int java.sql.Statement.executeUpdate(java.lang.String, java.lang.String[])") + public static void before(@CallSite.Argument(0) String arg1) {} + + @CallSite.Before("java.lang.String java.lang.String.format(java.lang.String, java.lang.Object[])") + public static void before(@CallSite.Argument(1) Object[] arg) {} + + @CallSite.Before("java.lang.CharSequence java.lang.String.subSequence(int, int)") + public static void before(@CallSite.This String thiz, @CallSite.Argument(0) int arg) {} + } + + @Test + void partialArgumentsWithBeforeAdvice() { + CallSiteSpecification spec = buildClassSpecification(PartialArgumentsBeforeAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.advices(0, advice -> { + advice.pointcut("java/sql/Statement", "executeUpdate", "(Ljava/lang/String;[Ljava/lang/String;)I"); + advice.statements( + "int[] parameterIndices = new int[] { 0 };", + "handler.dupParameters(descriptor, parameterIndices, owner);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$PartialArgumentsBeforeAdvice\", \"before\", \"(Ljava/lang/String;)V\");", + "handler.method(opcode, owner, name, descriptor, isInterface);"); }); - asserter.advices( - 1, - advice -> { - advice.pointcut( - "java/lang/String", - "format", - "(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;"); - advice.statements( - "int[] parameterIndices = new int[] { 1 };", - "handler.dupParameters(descriptor, parameterIndices, null);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$PartialArgumentsBeforeAdvice\", \"before\", \"([Ljava/lang/Object;)V\");", - "handler.method(opcode, owner, name, descriptor, isInterface);"); + asserter.advices(1, advice -> { + advice.pointcut("java/lang/String", "format", "(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;"); + advice.statements( + "int[] parameterIndices = new int[] { 1 };", + "handler.dupParameters(descriptor, parameterIndices, null);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$PartialArgumentsBeforeAdvice\", \"before\", \"([Ljava/lang/Object;)V\");", + "handler.method(opcode, owner, name, descriptor, isInterface);"); }); - asserter.advices( - 2, - advice -> { - advice.pointcut("java/lang/String", "subSequence", "(II)Ljava/lang/CharSequence;"); - advice.statements( - "int[] parameterIndices = new int[] { 0 };", - "handler.dupInvoke(owner, descriptor, parameterIndices);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$PartialArgumentsBeforeAdvice\", \"before\", \"(Ljava/lang/String;I)V\");", - "handler.method(opcode, owner, name, descriptor, isInterface);"); + asserter.advices(2, advice -> { + advice.pointcut("java/lang/String", "subSequence", "(II)Ljava/lang/CharSequence;"); + advice.statements( + "int[] parameterIndices = new int[] { 0 };", + "handler.dupInvoke(owner, descriptor, parameterIndices);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$PartialArgumentsBeforeAdvice\", \"before\", \"(Ljava/lang/String;I)V\");", + "handler.method(opcode, owner, name, descriptor, isInterface);"); }); - } - - /** - * Captures two of three arguments positionally. Before the fix, Stream.sorted() without a - * comparator threw ClassCastException (ArgumentSpecification is not Comparable). The parameters - * TreeMap is keyed by advice-parameter index, so the stream is already in the correct order — - * sorted() must not be called. The reversed case verifies that parameterIndices follows the - * advice signature order, not the pointcut index order. - */ - @CallSite(spi = CallSites.class) - public static class MultiplePartialArgumentsBeforeAdvice { - /** Captures args 0 and 1 in the same order as the pointcut. */ - @CallSite.Before( - "java.lang.String java.lang.String.format(java.util.Locale, java.lang.String, java.lang.Object[])") - public static void before( - @CallSite.Argument(0) java.util.Locale locale, @CallSite.Argument(1) String format) {} + } /** - * Captures the same two args but with their advice positions reversed. parameterIndices must be - * {1, 0} (advice order), not {0, 1} (pointcut index order). + * Captures two of three arguments positionally. Before the fix, Stream.sorted() without a + * comparator threw ClassCastException (ArgumentSpecification is not Comparable). The parameters + * TreeMap is keyed by advice-parameter index, so the stream is already in the correct order — + * sorted() must not be called. The reversed case verifies that parameterIndices follows the + * advice signature order, not the pointcut index order. */ - @CallSite.Before( - "java.lang.String java.lang.String.format(java.util.Locale, java.lang.String, java.lang.Object[])") - public static void beforeReversed( - @CallSite.Argument(1) String format, @CallSite.Argument(0) java.util.Locale locale) {} - } - - @Test - void multiplePartialArgumentsWithBeforeAdvice() { - CallSiteSpecification spec = - buildClassSpecification(MultiplePartialArgumentsBeforeAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - // In-order capture: {0, 1} matches both advice and pointcut order - asserter.advices( - 0, - advice -> { - advice.pointcut( - "java/lang/String", - "format", - "(Ljava/util/Locale;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;"); - advice.statements( - "int[] parameterIndices = new int[] { 0, 1 };", - "handler.dupParameters(descriptor, parameterIndices, null);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$MultiplePartialArgumentsBeforeAdvice\", \"before\", \"(Ljava/util/Locale;Ljava/lang/String;)V\");", - "handler.method(opcode, owner, name, descriptor, isInterface);"); + @CallSite(spi = CallSites.class) + public static class MultiplePartialArgumentsBeforeAdvice { + /** Captures args 0 and 1 in the same order as the pointcut. */ + @CallSite.Before( + "java.lang.String java.lang.String.format(java.util.Locale, java.lang.String, java.lang.Object[])") + public static void before(@CallSite.Argument(0) java.util.Locale locale, @CallSite.Argument(1) String format) {} + + /** + * Captures the same two args but with their advice positions reversed. parameterIndices must be + * {1, 0} (advice order), not {0, 1} (pointcut index order). + */ + @CallSite.Before( + "java.lang.String java.lang.String.format(java.util.Locale, java.lang.String, java.lang.Object[])") + public static void beforeReversed( + @CallSite.Argument(1) String format, @CallSite.Argument(0) java.util.Locale locale) {} + } + + @Test + void multiplePartialArgumentsWithBeforeAdvice() { + CallSiteSpecification spec = buildClassSpecification(MultiplePartialArgumentsBeforeAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + // In-order capture: {0, 1} matches both advice and pointcut order + asserter.advices(0, advice -> { + advice.pointcut( + "java/lang/String", + "format", + "(Ljava/util/Locale;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;"); + advice.statements( + "int[] parameterIndices = new int[] { 0, 1 };", + "handler.dupParameters(descriptor, parameterIndices, null);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$MultiplePartialArgumentsBeforeAdvice\", \"before\", \"(Ljava/util/Locale;Ljava/lang/String;)V\");", + "handler.method(opcode, owner, name, descriptor, isInterface);"); }); - // Reversed capture: {1, 0} follows the advice signature, not the pointcut index order {0, 1} - asserter.advices( - 1, - advice -> { - advice.pointcut( - "java/lang/String", - "format", - "(Ljava/util/Locale;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;"); - advice.statements( - "int[] parameterIndices = new int[] { 1, 0 };", - "handler.dupParameters(descriptor, parameterIndices, null);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$MultiplePartialArgumentsBeforeAdvice\", \"beforeReversed\", \"(Ljava/lang/String;Ljava/util/Locale;)V\");", - "handler.method(opcode, owner, name, descriptor, isInterface);"); + // Reversed capture: {1, 0} follows the advice signature, not the pointcut index order {0, 1} + asserter.advices(1, advice -> { + advice.pointcut( + "java/lang/String", + "format", + "(Ljava/util/Locale;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;"); + advice.statements( + "int[] parameterIndices = new int[] { 1, 0 };", + "handler.dupParameters(descriptor, parameterIndices, null);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$MultiplePartialArgumentsBeforeAdvice\", \"beforeReversed\", \"(Ljava/lang/String;Ljava/util/Locale;)V\");", + "handler.method(opcode, owner, name, descriptor, isInterface);"); }); - } - - @CallSite(spi = CallSites.class) - public static class SuperTypeReturnAdvice { - @CallSite.After("void java.lang.StringBuilder.(java.lang.String)") - public static Object after( - @CallSite.AllArguments Object[] args, @CallSite.Return Object result) { - return result; - } - } - - @Test - void testReturningSuperType() { - CallSiteSpecification spec = buildClassSpecification(SuperTypeReturnAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.advices( - 0, - advice -> { - advice.pointcut("java/lang/StringBuilder", "", "(Ljava/lang/String;)V"); - advice.statements( - "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY_CTOR);", - "handler.method(opcode, owner, name, descriptor, isInterface);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$SuperTypeReturnAdvice\", \"after\", \"([Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;\");", - "handler.instruction(Opcodes.CHECKCAST, \"java/lang/StringBuilder\");"); + } + + @CallSite(spi = CallSites.class) + public static class SuperTypeReturnAdvice { + @CallSite.After("void java.lang.StringBuilder.(java.lang.String)") + public static Object after(@CallSite.AllArguments Object[] args, @CallSite.Return Object result) { + return result; + } + } + + @Test + void testReturningSuperType() { + CallSiteSpecification spec = buildClassSpecification(SuperTypeReturnAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.advices(0, advice -> { + advice.pointcut("java/lang/StringBuilder", "", "(Ljava/lang/String;)V"); + advice.statements( + "handler.dupParameters(descriptor, StackDupMode.PREPEND_ARRAY_CTOR);", + "handler.method(opcode, owner, name, descriptor, isInterface);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$SuperTypeReturnAdvice\", \"after\", \"([Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;\");", + "handler.instruction(Opcodes.CHECKCAST, \"java/lang/StringBuilder\");"); }); - } - - @CallSite(spi = {IastCallSites.class, RaspCallSites.class}) - public static class MultipleSpiClassesAdvice { - @CallSite.After("void java.lang.StringBuilder.(java.lang.String)") - public static Object after( - @CallSite.AllArguments Object[] args, @CallSite.Return Object result) { - return result; - } - } - - @Test - void testMultipleSpiClasses() { - CallSiteSpecification spec = buildClassSpecification(MultipleSpiClassesAdvice.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.spi(IastCallSites.class, RaspCallSites.class); - } - - @CallSite(spi = CallSites.class) - public static class AfterAdviceWithVoidReturn { - @CallSite.After("void java.lang.StringBuilder.setLength(int)") - public static void after(@CallSite.This StringBuilder self, @CallSite.Argument(0) int length) {} - } - - @Test - void testAfterAdviceWithVoidReturn() { - CallSiteSpecification spec = buildClassSpecification(AfterAdviceWithVoidReturn.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - - CallSiteResult result = generator.generate(spec); - - assertNoErrors(result); - CallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.advices( - 0, - advice -> { - advice.pointcut("java/lang/StringBuilder", "setLength", "(I)V"); - advice.statements( - "handler.dupInvoke(owner, descriptor, StackDupMode.COPY);", - "handler.method(opcode, owner, name, descriptor, isInterface);", - "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AfterAdviceWithVoidReturn\", \"after\", \"(Ljava/lang/StringBuilder;I)V\");"); + } + + @CallSite(spi = {IastCallSites.class, RaspCallSites.class}) + public static class MultipleSpiClassesAdvice { + @CallSite.After("void java.lang.StringBuilder.(java.lang.String)") + public static Object after(@CallSite.AllArguments Object[] args, @CallSite.Return Object result) { + return result; + } + } + + @Test + void testMultipleSpiClasses() { + CallSiteSpecification spec = buildClassSpecification(MultipleSpiClassesAdvice.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.spi(IastCallSites.class, RaspCallSites.class); + } + + @CallSite(spi = CallSites.class) + public static class AfterAdviceWithVoidReturn { + @CallSite.After("void java.lang.StringBuilder.setLength(int)") + public static void after(@CallSite.This StringBuilder self, @CallSite.Argument(0) int length) {} + } + + @Test + void testAfterAdviceWithVoidReturn() { + CallSiteSpecification spec = buildClassSpecification(AfterAdviceWithVoidReturn.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + + CallSiteResult result = generator.generate(spec); + + assertNoErrors(result); + CallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.advices(0, advice -> { + advice.pointcut("java/lang/StringBuilder", "setLength", "(I)V"); + advice.statements( + "handler.dupInvoke(owner, descriptor, StackDupMode.COPY);", + "handler.method(opcode, owner, name, descriptor, isInterface);", + "handler.advice(\"datadog/trace/plugin/csi/impl/AdviceGeneratorTest$AfterAdviceWithVoidReturn\", \"after\", \"(Ljava/lang/StringBuilder;I)V\");"); }); - } + } - private static AdviceGenerator buildAdviceGenerator(File targetFolder) { - return new AdviceGeneratorImpl(targetFolder, pointcutParser()); - } + private static AdviceGenerator buildAdviceGenerator(File targetFolder) { + return new AdviceGeneratorImpl(targetFolder, pointcutParser()); + } - private static CallSiteAssert assertCallSites(File generated) { - return new AssertBuilder(generated).build(); - } + private static CallSiteAssert assertCallSites(File generated) { + return new AssertBuilder(generated).build(); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.java index 3c9fe3cacb5..30f3fbd0dba 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AdviceSpecificationTest.java @@ -40,684 +40,619 @@ class AdviceSpecificationTest extends BaseCsiPluginTest { - @CallSite(spi = CallSites.class) - static class EmptyAdvice {} - - @Test - void testClassGeneratorErrorCallSiteWithoutAdvices() { - ValidationContext context = mockValidationContext(); - CallSiteSpecification spec = buildClassSpecification(EmptyAdvice.class); - - spec.validate(context); - verify(context).addError(eq(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS), any()); - } - - @CallSite(spi = CallSites.class) - static class NonPublicStaticMethodAdvice { - @CallSite.Before("void java.lang.Runnable.run()") - private void advice(@CallSite.This Runnable run) {} - } - - @Test - void testClassGeneratorErrorNonPublicStaticMethod() { - ValidationContext context = mockValidationContext(); - CallSiteSpecification spec = buildClassSpecification(NonPublicStaticMethodAdvice.class); - - spec.getAdvices().forEach(it -> it.validate(context)); - - verify(context).addError(eq(ErrorCode.ADVICE_METHOD_NOT_STATIC_AND_PUBLIC), any()); - } - - static class BeforeStringConcat { - static void concat(String self, String value) {} - } - - static Stream adviceClassShouldBeOnClasspathProvider() { - return Stream.of( - Arguments.of(Type.getType("Lfoo/bar/FooBar;"), 1), - Arguments.of(Type.getType(BeforeStringConcat.class), 0)); - } - - @ParameterizedTest - @MethodSource("adviceClassShouldBeOnClasspathProvider") - void testAdviceClassShouldBeOnTheClasspath(Type type, int errors) throws Exception { - ValidationContext context = mockValidationContext(); - BeforeSpecification spec = - createBeforeSpec( - BeforeStringConcat.class.getDeclaredMethod("concat", String.class, String.class), - type, - Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError( - argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_TYPE)); - } - - static Stream beforeAdviceShouldReturnVoidProvider() { - return Stream.of(Arguments.of(String.class, 1), Arguments.of(void.class, 0)); - } - - @ParameterizedTest - @MethodSource("beforeAdviceShouldReturnVoidProvider") - void testBeforeAdviceShouldReturnVoid(Class returnType, int errors) { - ValidationContext context = mockValidationContext(); - BeforeSpecification spec = - createBeforeSpec( - BeforeStringConcat.class, - "concat", - returnType, - new Class[] {String.class, String.class}, - Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_BEFORE_SHOULD_RETURN_VOID), any()); - } - - static class AroundStringConcat { - static String concat(String self, String value) { - return self.concat(value); - } - } - - static Stream aroundAdviceReturnTypeProvider() { - return Stream.of( - Arguments.of(MessageDigest.class, 1), - Arguments.of(Object.class, 0), - Arguments.of(String.class, 0)); - } - - @ParameterizedTest - @MethodSource("aroundAdviceReturnTypeProvider") - void testAroundAdviceShouldReturnTypeCompatibleWithPointcut(Class returnType, int errors) { - ValidationContext context = mockValidationContext(); - AroundSpecification spec = - createAroundSpec( - AroundStringConcat.class, - "concat", - returnType, - new Class[] {String.class, String.class}, - Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError(eq(ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE), any()); - } - - static class AfterStringConcat { - static String concat(String self, String value, String result) { - return result; - } - } - - static Stream afterAdviceReturnTypeProvider() { - return Stream.of( - Arguments.of(MessageDigest.class, 1), - Arguments.of(Object.class, 0), - Arguments.of(String.class, 0)); - } - - @ParameterizedTest - @MethodSource("afterAdviceReturnTypeProvider") - void testAfterAdviceShouldReturnTypeCompatibleWithPointcut(Class returnType, int errors) { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - AfterStringConcat.class, - "concat", - returnType, - new Class[] {String.class, String.class, String.class}, - Arrays.asList( - new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError(eq(ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE), any()); - } - - static Stream thisParameterShouldBeFirstProvider() { - return Stream.of( - Arguments.of(Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), 0), - Arguments.of(Arrays.asList(new ArgumentSpecification(), new ThisSpecification()), 1)); - } - - @ParameterizedTest - @MethodSource("thisParameterShouldBeFirstProvider") - void testThisParameterShouldAlwaysBeTheFirst(List params, int errors) - throws Exception { - ValidationContext context = mockValidationContext(); - AroundSpecification spec = - createAroundSpec( - AroundStringConcat.class.getDeclaredMethod("concat", String.class, String.class), - params, - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError(eq(ErrorCode.ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST), any()); - } - - static Stream thisParameterCompatibilityProvider() { - return Stream.of( - Arguments.of(MessageDigest.class, 1), - Arguments.of(Object.class, 0), - Arguments.of(String.class, 0)); - } - - @ParameterizedTest - @MethodSource("thisParameterCompatibilityProvider") - void testThisParameterShouldBeCompatibleWithPointcut(Class type, int errors) { - ValidationContext context = mockValidationContext(); - AroundSpecification spec = - createAroundSpec( - AroundStringConcat.class, - "concat", - String.class, - new Class[] {type, String.class}, - Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError(eq(ErrorCode.ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE), any()); - if (type != String.class) { - verify(context) - .addError( - argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_METHOD)); - } - } - - static Stream returnParameterShouldBeLastProvider() { - return Stream.of( - Arguments.of( - Arrays.asList( - new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), - 0), - Arguments.of( - Arrays.asList( - new ThisSpecification(), new ReturnSpecification(), new ArgumentSpecification()), - 1)); - } - - @ParameterizedTest - @MethodSource("returnParameterShouldBeLastProvider") - void testReturnParameterShouldAlwaysBeTheLast(List params, int errors) - throws Exception { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - AfterStringConcat.class.getDeclaredMethod( - "concat", String.class, String.class, String.class), - params, - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError(eq(ErrorCode.ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST), any()); - } - - static Stream returnParameterCompatibilityProvider() { - return Stream.of( - Arguments.of(MessageDigest.class, 1), - Arguments.of(String.class, 0), - Arguments.of(Object.class, 0)); - } - - @ParameterizedTest - @MethodSource("returnParameterCompatibilityProvider") - void testReturnParameterShouldBeCompatibleWithPointcut(Class returnType, int errors) { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - AfterStringConcat.class, - "concat", - String.class, - new Class[] {String.class, String.class, returnType}, - Arrays.asList( - new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError(eq(ErrorCode.ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE), any()); - if (returnType != String.class) { - verify(context) - .addError( - argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_METHOD)); - } - } - - static Stream argumentParameterCompatibilityProvider() { - return Stream.of( - Arguments.of(MessageDigest.class, 1), - Arguments.of(String.class, 0), - Arguments.of(Object.class, 0)); - } - - @ParameterizedTest - @MethodSource("argumentParameterCompatibilityProvider") - void testArgumentParameterShouldBeCompatibleWithPointcut(Class parameterType, int errors) { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - AfterStringConcat.class, - "concat", - String.class, - new Class[] {String.class, parameterType, String.class}, - Arrays.asList( - new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context, times(errors)) - .addError(eq(ErrorCode.ADVICE_METHOD_PARAM_NOT_COMPATIBLE), any()); - if (parameterType != String.class) { - verify(context) - .addError( - argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_METHOD)); - } - } - - static class BadAfterStringConcat { - static String concat(String param1, String param2) { - return param2; - } - } - - static Stream afterAdviceRequiresThisAndReturnProvider() { - return Stream.of( - Arguments.of( - Arrays.asList(new ArgumentSpecification(), new ReturnSpecification()), - ErrorCode.ADVICE_AFTER_SHOULD_HAVE_THIS), - Arguments.of( - Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), - ErrorCode.ADVICE_AFTER_SHOULD_HAVE_RETURN)); - } - - @ParameterizedTest - @MethodSource("afterAdviceRequiresThisAndReturnProvider") - void testAfterAdviceRequiresThisAndReturnParameters( - List params, ErrorCode error) throws Exception { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - BadAfterStringConcat.class.getDeclaredMethod("concat", String.class, String.class), - params, - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context).addError(eq(error), any()); - } - - static class BadAllArgsAfterStringConcat { - static String concat(Object[] param1, String param2, String param3) { - return param3; - } - } - - @Test - void shouldNotMixAllArgumentsAndArgument() throws Exception { - ValidationContext context = mockValidationContext(); - AllArgsSpecification allArgs = new AllArgsSpecification(); - allArgs.setIncludeThis(true); - AfterSpecification spec = - createAfterSpec( - BadAllArgsAfterStringConcat.class.getDeclaredMethod( - "concat", Object[].class, String.class, String.class), - Arrays.asList(allArgs, new ArgumentSpecification(), new ReturnSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context).addError(eq(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED), any()); - verify(context).addError(eq(ErrorCode.ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS), any()); - } - - static class TestInheritedMethod { - static String after(ServletRequest request, String parameter, String value) { - return value; - } - } - - @Test - void testInheritedMethods() throws Exception { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - TestInheritedMethod.class.getDeclaredMethod( - "after", ServletRequest.class, String.class, String.class), - Arrays.asList( - new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), - "java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)"); - - spec.validate(context); - } - - static class TestInvokeDynamicConstants { - static Object after(Object[] parameter, Object result, Object[] constants) { - return result; - } - } - - @Test - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicConstants() throws Exception { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - TestInvokeDynamicConstants.class.getDeclaredMethod( - "after", Object[].class, Object.class, Object[].class), - Arrays.asList( - new AllArgsSpecification(), - new ReturnSpecification(), - new InvokeDynamicConstantsSpecification()), - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - true); - - spec.validate(context); - } - - static Stream invokeDynamicConstantsShouldBeLastProvider() { - return Stream.of( - Arguments.of( - Arrays.asList( - new AllArgsSpecification(), - new ReturnSpecification(), - new InvokeDynamicConstantsSpecification()), - null), - Arguments.of( - Arrays.asList( - new AllArgsSpecification(), - new InvokeDynamicConstantsSpecification(), - new ReturnSpecification()), - ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST)); - } - - @ParameterizedTest - @MethodSource("invokeDynamicConstantsShouldBeLastProvider") - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicConstantsShouldBeLast(List params, ErrorCode error) - throws Exception { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - TestInvokeDynamicConstants.class.getDeclaredMethod( - "after", Object[].class, Object.class, Object[].class), - params, - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - true); - - spec.validate(context); - if (error != null) { - verify(context).addError(eq(error), any()); - } - } - - static class TestInvokeDynamicConstantsNonInvokeDynamic { - static Object after(Object self, Object[] parameter, Object value, Object[] constants) { - return value; - } - } - - @Test - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicConstantsOnNonInvokeDynamicPointcut() throws Exception { - ValidationContext context = mockValidationContext(); - AfterSpecification spec = - createAfterSpec( - TestInvokeDynamicConstantsNonInvokeDynamic.class.getDeclaredMethod( - "after", Object.class, Object[].class, Object.class, Object[].class), - Arrays.asList( - new ThisSpecification(), - new AllArgsSpecification(), - new InvokeDynamicConstantsSpecification(), - new ReturnSpecification()), - "java.lang.String java.lang.String.concat(java.lang.String)"); - - spec.validate(context); - verify(context) - .addError( - eq(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC), any()); - } - - static class TestInvokeDynamicConstantsBefore { - static void before(Object[] parameter, Object[] constants) {} - } - - @Test - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicConstantsOnNonAfterAdvice() throws Exception { - ValidationContext context = mockValidationContext(); - BeforeSpecification spec = - createBeforeSpec( - TestInvokeDynamicConstantsBefore.class.getDeclaredMethod( - "before", Object[].class, Object[].class), - Arrays.asList(new AllArgsSpecification(), new InvokeDynamicConstantsSpecification()), - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - true); - - spec.validate(context); - verify(context) - .addError(eq(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE), any()); - } - - static class TestInvokeDynamicConstantsAround { - static java.lang.invoke.CallSite around( - MethodHandles.Lookup lookup, - String name, - java.lang.invoke.MethodType concatType, - String recipe, - Object... constants) { - return null; - } - } - - @Test - @EnabledForJreRange(min = JRE.JAVA_9) - void testInvokeDynamicOnAroundAdvice() throws Exception { - ValidationContext context = mockValidationContext(); - AroundSpecification spec = - createAroundSpec( - TestInvokeDynamicConstantsAround.class.getDeclaredMethod( - "around", - MethodHandles.Lookup.class, + @CallSite(spi = CallSites.class) + static class EmptyAdvice {} + + @Test + void testClassGeneratorErrorCallSiteWithoutAdvices() { + ValidationContext context = mockValidationContext(); + CallSiteSpecification spec = buildClassSpecification(EmptyAdvice.class); + + spec.validate(context); + verify(context).addError(eq(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS), any()); + } + + @CallSite(spi = CallSites.class) + static class NonPublicStaticMethodAdvice { + @CallSite.Before("void java.lang.Runnable.run()") + private void advice(@CallSite.This Runnable run) {} + } + + @Test + void testClassGeneratorErrorNonPublicStaticMethod() { + ValidationContext context = mockValidationContext(); + CallSiteSpecification spec = buildClassSpecification(NonPublicStaticMethodAdvice.class); + + spec.getAdvices().forEach(it -> it.validate(context)); + + verify(context).addError(eq(ErrorCode.ADVICE_METHOD_NOT_STATIC_AND_PUBLIC), any()); + } + + static class BeforeStringConcat { + static void concat(String self, String value) {} + } + + static Stream adviceClassShouldBeOnClasspathProvider() { + return Stream.of( + Arguments.of(Type.getType("Lfoo/bar/FooBar;"), 1), + Arguments.of(Type.getType(BeforeStringConcat.class), 0)); + } + + @ParameterizedTest + @MethodSource("adviceClassShouldBeOnClasspathProvider") + void testAdviceClassShouldBeOnTheClasspath(Type type, int errors) throws Exception { + ValidationContext context = mockValidationContext(); + BeforeSpecification spec = createBeforeSpec( + BeforeStringConcat.class.getDeclaredMethod("concat", String.class, String.class), + type, + Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)) + .addError(argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_TYPE)); + } + + static Stream beforeAdviceShouldReturnVoidProvider() { + return Stream.of(Arguments.of(String.class, 1), Arguments.of(void.class, 0)); + } + + @ParameterizedTest + @MethodSource("beforeAdviceShouldReturnVoidProvider") + void testBeforeAdviceShouldReturnVoid(Class returnType, int errors) { + ValidationContext context = mockValidationContext(); + BeforeSpecification spec = createBeforeSpec( + BeforeStringConcat.class, + "concat", + returnType, + new Class[] {String.class, String.class}, + Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_BEFORE_SHOULD_RETURN_VOID), any()); + } + + static class AroundStringConcat { + static String concat(String self, String value) { + return self.concat(value); + } + } + + static Stream aroundAdviceReturnTypeProvider() { + return Stream.of( + Arguments.of(MessageDigest.class, 1), Arguments.of(Object.class, 0), Arguments.of(String.class, 0)); + } + + @ParameterizedTest + @MethodSource("aroundAdviceReturnTypeProvider") + void testAroundAdviceShouldReturnTypeCompatibleWithPointcut(Class returnType, int errors) { + ValidationContext context = mockValidationContext(); + AroundSpecification spec = createAroundSpec( + AroundStringConcat.class, + "concat", + returnType, + new Class[] {String.class, String.class}, + Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE), any()); + } + + static class AfterStringConcat { + static String concat(String self, String value, String result) { + return result; + } + } + + static Stream afterAdviceReturnTypeProvider() { + return Stream.of( + Arguments.of(MessageDigest.class, 1), Arguments.of(Object.class, 0), Arguments.of(String.class, 0)); + } + + @ParameterizedTest + @MethodSource("afterAdviceReturnTypeProvider") + void testAfterAdviceShouldReturnTypeCompatibleWithPointcut(Class returnType, int errors) { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + AfterStringConcat.class, + "concat", + returnType, + new Class[] {String.class, String.class, String.class}, + Arrays.asList(new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_METHOD_RETURN_NOT_COMPATIBLE), any()); + } + + static Stream thisParameterShouldBeFirstProvider() { + return Stream.of( + Arguments.of(Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), 0), + Arguments.of(Arrays.asList(new ArgumentSpecification(), new ThisSpecification()), 1)); + } + + @ParameterizedTest + @MethodSource("thisParameterShouldBeFirstProvider") + void testThisParameterShouldAlwaysBeTheFirst(List params, int errors) throws Exception { + ValidationContext context = mockValidationContext(); + AroundSpecification spec = createAroundSpec( + AroundStringConcat.class.getDeclaredMethod("concat", String.class, String.class), + params, + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_PARAMETER_THIS_SHOULD_BE_FIRST), any()); + } + + static Stream thisParameterCompatibilityProvider() { + return Stream.of( + Arguments.of(MessageDigest.class, 1), Arguments.of(Object.class, 0), Arguments.of(String.class, 0)); + } + + @ParameterizedTest + @MethodSource("thisParameterCompatibilityProvider") + void testThisParameterShouldBeCompatibleWithPointcut(Class type, int errors) { + ValidationContext context = mockValidationContext(); + AroundSpecification spec = createAroundSpec( + AroundStringConcat.class, + "concat", String.class, - java.lang.invoke.MethodType.class, + new Class[] {type, String.class}, + Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_METHOD_PARAM_THIS_NOT_COMPATIBLE), any()); + if (type != String.class) { + verify(context) + .addError(argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_METHOD)); + } + } + + static Stream returnParameterShouldBeLastProvider() { + return Stream.of( + Arguments.of( + Arrays.asList(new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), + 0), + Arguments.of( + Arrays.asList(new ThisSpecification(), new ReturnSpecification(), new ArgumentSpecification()), + 1)); + } + + @ParameterizedTest + @MethodSource("returnParameterShouldBeLastProvider") + void testReturnParameterShouldAlwaysBeTheLast(List params, int errors) throws Exception { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + AfterStringConcat.class.getDeclaredMethod("concat", String.class, String.class, String.class), + params, + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_PARAMETER_RETURN_SHOULD_BE_LAST), any()); + } + + static Stream returnParameterCompatibilityProvider() { + return Stream.of( + Arguments.of(MessageDigest.class, 1), Arguments.of(String.class, 0), Arguments.of(Object.class, 0)); + } + + @ParameterizedTest + @MethodSource("returnParameterCompatibilityProvider") + void testReturnParameterShouldBeCompatibleWithPointcut(Class returnType, int errors) { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + AfterStringConcat.class, + "concat", String.class, - Object[].class), - Arrays.asList( - new ArgumentSpecification(), - new ArgumentSpecification(), - new ArgumentSpecification(), - new ArgumentSpecification(), - new ArgumentSpecification()), - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - true); - - spec.validate(context); - } - - @CallSite(spi = CallSites.class) - static class AfterWithVoidWrongAdvice { - @CallSite.After("void java.lang.String.getChars(int, int, char[], int)") - static String after(@CallSite.AllArguments Object[] args, @CallSite.Return String result) { - return result; - } - } - - @Test - void testAfterAdviceWithVoidShouldNotUseReturn() { - ValidationContext context = mockValidationContext(); - CallSiteSpecification spec = buildClassSpecification(AfterWithVoidWrongAdvice.class); - - spec.getAdvices().forEach(it -> it.validate(context)); - - verify(context).addError(eq(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_RETURN_VOID), any()); - verify(context).addError(eq(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_NOT_HAVE_RETURN), any()); - } - - // Helper methods to create specifications - private BeforeSpecification createBeforeSpec( - Method method, List params, String signature) { - return createBeforeSpec(method, null, params, signature, false); - } - - private BeforeSpecification createBeforeSpec( - Method method, List params, String signature, boolean invokeDynamic) { - return createBeforeSpec(method, null, params, signature, invokeDynamic); - } - - private BeforeSpecification createBeforeSpec( - Method method, Type ownerOverride, List params, String signature) { - return createBeforeSpec(method, ownerOverride, params, signature, false); - } - - private BeforeSpecification createBeforeSpec( - Method method, - Type ownerOverride, - List params, - String signature, - boolean invokeDynamic) { - Type owner = ownerOverride != null ? ownerOverride : Type.getType(method.getDeclaringClass()); - Type[] argTypes = - Arrays.stream(method.getParameterTypes()).map(Type::getType).toArray(Type[]::new); - Type returnType = Type.getType(method.getReturnType()); - MethodType methodType = - new MethodType(owner, method.getName(), Type.getMethodType(returnType, argTypes)); - Map paramMap = new HashMap<>(); - for (int i = 0; i < params.size(); i++) { - paramMap.put(i, params.get(i)); - } - updateArgumentIndices(paramMap); - BeforeSpecification spec = - new BeforeSpecification(methodType, paramMap, signature, invokeDynamic); - spec.parseSignature(CallSiteFactory.pointcutParser()); - return spec; - } - - private BeforeSpecification createBeforeSpec( - Class clazz, - String methodName, - Class returnType, - Class[] argTypes, - List params, - String signature) { - Type owner = Type.getType(clazz); - Type[] argTypesAsm = Arrays.stream(argTypes).map(Type::getType).toArray(Type[]::new); - Type returnTypeAsm = Type.getType(returnType); - MethodType methodType = - new MethodType(owner, methodName, Type.getMethodType(returnTypeAsm, argTypesAsm)); - Map paramMap = new HashMap<>(); - for (int i = 0; i < params.size(); i++) { - paramMap.put(i, params.get(i)); - } - updateArgumentIndices(paramMap); - BeforeSpecification spec = new BeforeSpecification(methodType, paramMap, signature, false); - spec.parseSignature(CallSiteFactory.pointcutParser()); - return spec; - } - - private AroundSpecification createAroundSpec( - Method method, List params, String signature) { - return createAroundSpec(method, params, signature, false); - } - - private AroundSpecification createAroundSpec( - Method method, List params, String signature, boolean invokeDynamic) { - Type owner = Type.getType(method.getDeclaringClass()); - Type[] argTypes = - Arrays.stream(method.getParameterTypes()).map(Type::getType).toArray(Type[]::new); - Type returnType = Type.getType(method.getReturnType()); - MethodType methodType = - new MethodType(owner, method.getName(), Type.getMethodType(returnType, argTypes)); - Map paramMap = new HashMap<>(); - for (int i = 0; i < params.size(); i++) { - paramMap.put(i, params.get(i)); - } - updateArgumentIndices(paramMap); - AroundSpecification spec = - new AroundSpecification(methodType, paramMap, signature, invokeDynamic); - spec.parseSignature(CallSiteFactory.pointcutParser()); - return spec; - } - - private AroundSpecification createAroundSpec( - Class clazz, - String methodName, - Class returnType, - Class[] argTypes, - List params, - String signature) { - Type owner = Type.getType(clazz); - Type[] argTypesAsm = Arrays.stream(argTypes).map(Type::getType).toArray(Type[]::new); - Type returnTypeAsm = Type.getType(returnType); - MethodType methodType = - new MethodType(owner, methodName, Type.getMethodType(returnTypeAsm, argTypesAsm)); - Map paramMap = new HashMap<>(); - for (int i = 0; i < params.size(); i++) { - paramMap.put(i, params.get(i)); - } - updateArgumentIndices(paramMap); - AroundSpecification spec = new AroundSpecification(methodType, paramMap, signature, false); - spec.parseSignature(CallSiteFactory.pointcutParser()); - return spec; - } - - private AfterSpecification createAfterSpec( - Method method, List params, String signature) { - return createAfterSpec(method, params, signature, false); - } - - private AfterSpecification createAfterSpec( - Method method, List params, String signature, boolean invokeDynamic) { - Type owner = Type.getType(method.getDeclaringClass()); - Type[] argTypes = - Arrays.stream(method.getParameterTypes()).map(Type::getType).toArray(Type[]::new); - Type returnType = Type.getType(method.getReturnType()); - MethodType methodType = - new MethodType(owner, method.getName(), Type.getMethodType(returnType, argTypes)); - Map paramMap = new HashMap<>(); - for (int i = 0; i < params.size(); i++) { - paramMap.put(i, params.get(i)); - } - updateArgumentIndices(paramMap); - AfterSpecification spec = - new AfterSpecification(methodType, paramMap, signature, invokeDynamic); - spec.parseSignature(CallSiteFactory.pointcutParser()); - return spec; - } - - private AfterSpecification createAfterSpec( - Class clazz, - String methodName, - Class returnType, - Class[] argTypes, - List params, - String signature) { - Type owner = Type.getType(clazz); - Type[] argTypesAsm = Arrays.stream(argTypes).map(Type::getType).toArray(Type[]::new); - Type returnTypeAsm = Type.getType(returnType); - MethodType methodType = - new MethodType(owner, methodName, Type.getMethodType(returnTypeAsm, argTypesAsm)); - Map paramMap = new HashMap<>(); - for (int i = 0; i < params.size(); i++) { - paramMap.put(i, params.get(i)); - } - updateArgumentIndices(paramMap); - AfterSpecification spec = new AfterSpecification(methodType, paramMap, signature, false); - spec.parseSignature(CallSiteFactory.pointcutParser()); - return spec; - } - - private void updateArgumentIndices(Map paramMap) { - int index = 0; - for (ParameterSpecification param : paramMap.values()) { - if (param instanceof ArgumentSpecification) { - ((ArgumentSpecification) param).setIndex(index++); - } - } - } + new Class[] {String.class, String.class, returnType}, + Arrays.asList(new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_METHOD_PARAM_RETURN_NOT_COMPATIBLE), any()); + if (returnType != String.class) { + verify(context) + .addError(argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_METHOD)); + } + } + + static Stream argumentParameterCompatibilityProvider() { + return Stream.of( + Arguments.of(MessageDigest.class, 1), Arguments.of(String.class, 0), Arguments.of(Object.class, 0)); + } + + @ParameterizedTest + @MethodSource("argumentParameterCompatibilityProvider") + void testArgumentParameterShouldBeCompatibleWithPointcut(Class parameterType, int errors) { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + AfterStringConcat.class, + "concat", + String.class, + new Class[] {String.class, parameterType, String.class}, + Arrays.asList(new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context, times(errors)).addError(eq(ErrorCode.ADVICE_METHOD_PARAM_NOT_COMPATIBLE), any()); + if (parameterType != String.class) { + verify(context) + .addError(argThat((Failure failure) -> failure.getErrorCode() == ErrorCode.UNRESOLVED_METHOD)); + } + } + + static class BadAfterStringConcat { + static String concat(String param1, String param2) { + return param2; + } + } + + static Stream afterAdviceRequiresThisAndReturnProvider() { + return Stream.of( + Arguments.of( + Arrays.asList(new ArgumentSpecification(), new ReturnSpecification()), + ErrorCode.ADVICE_AFTER_SHOULD_HAVE_THIS), + Arguments.of( + Arrays.asList(new ThisSpecification(), new ArgumentSpecification()), + ErrorCode.ADVICE_AFTER_SHOULD_HAVE_RETURN)); + } + + @ParameterizedTest + @MethodSource("afterAdviceRequiresThisAndReturnProvider") + void testAfterAdviceRequiresThisAndReturnParameters(List params, ErrorCode error) + throws Exception { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + BadAfterStringConcat.class.getDeclaredMethod("concat", String.class, String.class), + params, + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context).addError(eq(error), any()); + } + + static class BadAllArgsAfterStringConcat { + static String concat(Object[] param1, String param2, String param3) { + return param3; + } + } + + @Test + void shouldNotMixAllArgumentsAndArgument() throws Exception { + ValidationContext context = mockValidationContext(); + AllArgsSpecification allArgs = new AllArgsSpecification(); + allArgs.setIncludeThis(true); + AfterSpecification spec = createAfterSpec( + BadAllArgsAfterStringConcat.class.getDeclaredMethod( + "concat", Object[].class, String.class, String.class), + Arrays.asList(allArgs, new ArgumentSpecification(), new ReturnSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context).addError(eq(ErrorCode.ADVICE_PARAMETER_ALL_ARGS_MIXED), any()); + verify(context).addError(eq(ErrorCode.ADVICE_PARAMETER_ARGUMENT_OUT_OF_BOUNDS), any()); + } + + static class TestInheritedMethod { + static String after(ServletRequest request, String parameter, String value) { + return value; + } + } + + @Test + void testInheritedMethods() throws Exception { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + TestInheritedMethod.class.getDeclaredMethod("after", ServletRequest.class, String.class, String.class), + Arrays.asList(new ThisSpecification(), new ArgumentSpecification(), new ReturnSpecification()), + "java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)"); + + spec.validate(context); + } + + static class TestInvokeDynamicConstants { + static Object after(Object[] parameter, Object result, Object[] constants) { + return result; + } + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicConstants() throws Exception { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + TestInvokeDynamicConstants.class.getDeclaredMethod( + "after", Object[].class, Object.class, Object[].class), + Arrays.asList( + new AllArgsSpecification(), + new ReturnSpecification(), + new InvokeDynamicConstantsSpecification()), + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + true); + + spec.validate(context); + } + + static Stream invokeDynamicConstantsShouldBeLastProvider() { + return Stream.of( + Arguments.of( + Arrays.asList( + new AllArgsSpecification(), + new ReturnSpecification(), + new InvokeDynamicConstantsSpecification()), + null), + Arguments.of( + Arrays.asList( + new AllArgsSpecification(), + new InvokeDynamicConstantsSpecification(), + new ReturnSpecification()), + ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_SHOULD_BE_LAST)); + } + + @ParameterizedTest + @MethodSource("invokeDynamicConstantsShouldBeLastProvider") + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicConstantsShouldBeLast(List params, ErrorCode error) throws Exception { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + TestInvokeDynamicConstants.class.getDeclaredMethod( + "after", Object[].class, Object.class, Object[].class), + params, + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + true); + + spec.validate(context); + if (error != null) { + verify(context).addError(eq(error), any()); + } + } + + static class TestInvokeDynamicConstantsNonInvokeDynamic { + static Object after(Object self, Object[] parameter, Object value, Object[] constants) { + return value; + } + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicConstantsOnNonInvokeDynamicPointcut() throws Exception { + ValidationContext context = mockValidationContext(); + AfterSpecification spec = createAfterSpec( + TestInvokeDynamicConstantsNonInvokeDynamic.class.getDeclaredMethod( + "after", Object.class, Object[].class, Object.class, Object[].class), + Arrays.asList( + new ThisSpecification(), + new AllArgsSpecification(), + new InvokeDynamicConstantsSpecification(), + new ReturnSpecification()), + "java.lang.String java.lang.String.concat(java.lang.String)"); + + spec.validate(context); + verify(context).addError(eq(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_ON_NON_INVOKE_DYNAMIC), any()); + } + + static class TestInvokeDynamicConstantsBefore { + static void before(Object[] parameter, Object[] constants) {} + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicConstantsOnNonAfterAdvice() throws Exception { + ValidationContext context = mockValidationContext(); + BeforeSpecification spec = createBeforeSpec( + TestInvokeDynamicConstantsBefore.class.getDeclaredMethod("before", Object[].class, Object[].class), + Arrays.asList(new AllArgsSpecification(), new InvokeDynamicConstantsSpecification()), + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + true); + + spec.validate(context); + verify(context).addError(eq(ErrorCode.ADVICE_PARAMETER_INVOKE_DYNAMIC_CONSTANTS_NON_AFTER_ADVICE), any()); + } + + static class TestInvokeDynamicConstantsAround { + static java.lang.invoke.CallSite around( + MethodHandles.Lookup lookup, + String name, + java.lang.invoke.MethodType concatType, + String recipe, + Object... constants) { + return null; + } + } + + @Test + @EnabledForJreRange(min = JRE.JAVA_9) + void testInvokeDynamicOnAroundAdvice() throws Exception { + ValidationContext context = mockValidationContext(); + AroundSpecification spec = createAroundSpec( + TestInvokeDynamicConstantsAround.class.getDeclaredMethod( + "around", + MethodHandles.Lookup.class, + String.class, + java.lang.invoke.MethodType.class, + String.class, + Object[].class), + Arrays.asList( + new ArgumentSpecification(), + new ArgumentSpecification(), + new ArgumentSpecification(), + new ArgumentSpecification(), + new ArgumentSpecification()), + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + true); + + spec.validate(context); + } + + @CallSite(spi = CallSites.class) + static class AfterWithVoidWrongAdvice { + @CallSite.After("void java.lang.String.getChars(int, int, char[], int)") + static String after(@CallSite.AllArguments Object[] args, @CallSite.Return String result) { + return result; + } + } + + @Test + void testAfterAdviceWithVoidShouldNotUseReturn() { + ValidationContext context = mockValidationContext(); + CallSiteSpecification spec = buildClassSpecification(AfterWithVoidWrongAdvice.class); + + spec.getAdvices().forEach(it -> it.validate(context)); + + verify(context).addError(eq(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_RETURN_VOID), any()); + verify(context).addError(eq(ErrorCode.ADVICE_AFTER_VOID_METHOD_SHOULD_NOT_HAVE_RETURN), any()); + } + + // Helper methods to create specifications + private BeforeSpecification createBeforeSpec(Method method, List params, String signature) { + return createBeforeSpec(method, null, params, signature, false); + } + + private BeforeSpecification createBeforeSpec( + Method method, List params, String signature, boolean invokeDynamic) { + return createBeforeSpec(method, null, params, signature, invokeDynamic); + } + + private BeforeSpecification createBeforeSpec( + Method method, Type ownerOverride, List params, String signature) { + return createBeforeSpec(method, ownerOverride, params, signature, false); + } + + private BeforeSpecification createBeforeSpec( + Method method, + Type ownerOverride, + List params, + String signature, + boolean invokeDynamic) { + Type owner = ownerOverride != null ? ownerOverride : Type.getType(method.getDeclaringClass()); + Type[] argTypes = + Arrays.stream(method.getParameterTypes()).map(Type::getType).toArray(Type[]::new); + Type returnType = Type.getType(method.getReturnType()); + MethodType methodType = new MethodType(owner, method.getName(), Type.getMethodType(returnType, argTypes)); + Map paramMap = new HashMap<>(); + for (int i = 0; i < params.size(); i++) { + paramMap.put(i, params.get(i)); + } + updateArgumentIndices(paramMap); + BeforeSpecification spec = new BeforeSpecification(methodType, paramMap, signature, invokeDynamic); + spec.parseSignature(CallSiteFactory.pointcutParser()); + return spec; + } + + private BeforeSpecification createBeforeSpec( + Class clazz, + String methodName, + Class returnType, + Class[] argTypes, + List params, + String signature) { + Type owner = Type.getType(clazz); + Type[] argTypesAsm = Arrays.stream(argTypes).map(Type::getType).toArray(Type[]::new); + Type returnTypeAsm = Type.getType(returnType); + MethodType methodType = new MethodType(owner, methodName, Type.getMethodType(returnTypeAsm, argTypesAsm)); + Map paramMap = new HashMap<>(); + for (int i = 0; i < params.size(); i++) { + paramMap.put(i, params.get(i)); + } + updateArgumentIndices(paramMap); + BeforeSpecification spec = new BeforeSpecification(methodType, paramMap, signature, false); + spec.parseSignature(CallSiteFactory.pointcutParser()); + return spec; + } + + private AroundSpecification createAroundSpec(Method method, List params, String signature) { + return createAroundSpec(method, params, signature, false); + } + + private AroundSpecification createAroundSpec( + Method method, List params, String signature, boolean invokeDynamic) { + Type owner = Type.getType(method.getDeclaringClass()); + Type[] argTypes = + Arrays.stream(method.getParameterTypes()).map(Type::getType).toArray(Type[]::new); + Type returnType = Type.getType(method.getReturnType()); + MethodType methodType = new MethodType(owner, method.getName(), Type.getMethodType(returnType, argTypes)); + Map paramMap = new HashMap<>(); + for (int i = 0; i < params.size(); i++) { + paramMap.put(i, params.get(i)); + } + updateArgumentIndices(paramMap); + AroundSpecification spec = new AroundSpecification(methodType, paramMap, signature, invokeDynamic); + spec.parseSignature(CallSiteFactory.pointcutParser()); + return spec; + } + + private AroundSpecification createAroundSpec( + Class clazz, + String methodName, + Class returnType, + Class[] argTypes, + List params, + String signature) { + Type owner = Type.getType(clazz); + Type[] argTypesAsm = Arrays.stream(argTypes).map(Type::getType).toArray(Type[]::new); + Type returnTypeAsm = Type.getType(returnType); + MethodType methodType = new MethodType(owner, methodName, Type.getMethodType(returnTypeAsm, argTypesAsm)); + Map paramMap = new HashMap<>(); + for (int i = 0; i < params.size(); i++) { + paramMap.put(i, params.get(i)); + } + updateArgumentIndices(paramMap); + AroundSpecification spec = new AroundSpecification(methodType, paramMap, signature, false); + spec.parseSignature(CallSiteFactory.pointcutParser()); + return spec; + } + + private AfterSpecification createAfterSpec(Method method, List params, String signature) { + return createAfterSpec(method, params, signature, false); + } + + private AfterSpecification createAfterSpec( + Method method, List params, String signature, boolean invokeDynamic) { + Type owner = Type.getType(method.getDeclaringClass()); + Type[] argTypes = + Arrays.stream(method.getParameterTypes()).map(Type::getType).toArray(Type[]::new); + Type returnType = Type.getType(method.getReturnType()); + MethodType methodType = new MethodType(owner, method.getName(), Type.getMethodType(returnType, argTypes)); + Map paramMap = new HashMap<>(); + for (int i = 0; i < params.size(); i++) { + paramMap.put(i, params.get(i)); + } + updateArgumentIndices(paramMap); + AfterSpecification spec = new AfterSpecification(methodType, paramMap, signature, invokeDynamic); + spec.parseSignature(CallSiteFactory.pointcutParser()); + return spec; + } + + private AfterSpecification createAfterSpec( + Class clazz, + String methodName, + Class returnType, + Class[] argTypes, + List params, + String signature) { + Type owner = Type.getType(clazz); + Type[] argTypesAsm = Arrays.stream(argTypes).map(Type::getType).toArray(Type[]::new); + Type returnTypeAsm = Type.getType(returnType); + MethodType methodType = new MethodType(owner, methodName, Type.getMethodType(returnTypeAsm, argTypesAsm)); + Map paramMap = new HashMap<>(); + for (int i = 0; i < params.size(); i++) { + paramMap.put(i, params.get(i)); + } + updateArgumentIndices(paramMap); + AfterSpecification spec = new AfterSpecification(methodType, paramMap, signature, false); + spec.parseSignature(CallSiteFactory.pointcutParser()); + return spec; + } + + private void updateArgumentIndices(Map paramMap) { + int index = 0; + for (ParameterSpecification param : paramMap.values()) { + if (param instanceof ArgumentSpecification) { + ((ArgumentSpecification) param).setIndex(index++); + } + } + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.java index 14ad65bfb0b..a7d4da7581a 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/AsmSpecificationBuilderTest.java @@ -31,559 +31,526 @@ class AsmSpecificationBuilderTest extends BaseCsiPluginTest { - static class NonCallSite {} + static class NonCallSite {} - @Test - void testSpecificationBuilderForNonCallSite() { - File advice = fetchClass(NonCallSite.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + @Test + void testSpecificationBuilderForNonCallSite() { + File advice = fetchClass(NonCallSite.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - Optional result = specificationBuilder.build(advice); + Optional result = specificationBuilder.build(advice); - assertFalse(result.isPresent()); - } + assertFalse(result.isPresent()); + } - @CallSite(spi = WithSpiClass.Spi.class) - static class WithSpiClass { - interface Spi {} - } + @CallSite(spi = WithSpiClass.Spi.class) + static class WithSpiClass { + interface Spi {} + } - @Test - void testSpecificationBuilderWithCustomSpiClass() { - File advice = fetchClass(WithSpiClass.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + @Test + void testSpecificationBuilderWithCustomSpiClass() { + File advice = fetchClass(WithSpiClass.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - assertEquals( - Arrays.asList(Type.getType(WithSpiClass.Spi.class)), Arrays.asList(result.getSpi())); - } + assertEquals(Arrays.asList(Type.getType(WithSpiClass.Spi.class)), Arrays.asList(result.getSpi())); + } - @CallSite( - spi = CallSites.class, - helpers = {HelpersAdvice.SampleHelper1.class, HelpersAdvice.SampleHelper2.class}) - static class HelpersAdvice { - static class SampleHelper1 {} + @CallSite( + spi = CallSites.class, + helpers = {HelpersAdvice.SampleHelper1.class, HelpersAdvice.SampleHelper2.class}) + static class HelpersAdvice { + static class SampleHelper1 {} - static class SampleHelper2 {} - } + static class SampleHelper2 {} + } - @Test - void testSpecificationBuilderWithCustomHelperClasses() { - File advice = fetchClass(HelpersAdvice.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + @Test + void testSpecificationBuilderWithCustomHelperClasses() { + File advice = fetchClass(HelpersAdvice.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - List helpers = Arrays.asList(result.getHelpers()); - assertTrue( - helpers.containsAll( - Arrays.asList( + List helpers = Arrays.asList(result.getHelpers()); + assertTrue(helpers.containsAll(Arrays.asList( Type.getType(HelpersAdvice.class), Type.getType(HelpersAdvice.SampleHelper1.class), Type.getType(HelpersAdvice.SampleHelper2.class)))); - } - - @CallSite(spi = CallSites.class) - static class BeforeAdvice { - @CallSite.Before( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") - static void before( - @CallSite.This String self, - @CallSite.Argument String regexp, - @CallSite.Argument String replacement) {} - } - - @Test - void testSpecificationBuilderForBeforeAdvice() { - File advice = fetchClass(BeforeAdvice.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(BeforeAdvice.class.getName(), result.getClazz().getClassName()); - BeforeSpecification beforeSpec = (BeforeSpecification) findAdvice(result, "before"); - assertNotNull(beforeSpec); - assertEquals( - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", - beforeSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", - beforeSpec.getSignature()); - assertNotNull(beforeSpec.findThis()); - assertNull(beforeSpec.findReturn()); - assertNull(beforeSpec.findAllArguments()); - assertNull(beforeSpec.findInvokeDynamicConstants()); - List arguments = getArguments(beforeSpec); - assertEquals(Arrays.asList(0, 1), arguments); - } - - @CallSite(spi = CallSites.class) - static class AroundAdvice { - @CallSite.Around( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") - static String around( - @CallSite.This String self, - @CallSite.Argument String regexp, - @CallSite.Argument String replacement) { - return self.replaceAll(regexp, replacement); - } - } - - @Test - void testSpecificationBuilderForAroundAdvice() { - File advice = fetchClass(AroundAdvice.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(AroundAdvice.class.getName(), result.getClazz().getClassName()); - AroundSpecification aroundSpec = (AroundSpecification) findAdvice(result, "around"); - assertNotNull(aroundSpec); - assertEquals( - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", - aroundSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", - aroundSpec.getSignature()); - assertNotNull(aroundSpec.findThis()); - assertNull(aroundSpec.findReturn()); - assertNull(aroundSpec.findAllArguments()); - assertNull(aroundSpec.findInvokeDynamicConstants()); - List arguments = getArguments(aroundSpec); - assertEquals(Arrays.asList(0, 1), arguments); - } - - @CallSite(spi = CallSites.class) - static class AfterAdvice { - @CallSite.After( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") - static String after( - @CallSite.This String self, - @CallSite.Argument String regexp, - @CallSite.Argument String replacement, - @CallSite.Return String result) { - return result; - } - } - - @Test - void testSpecificationBuilderForAfterAdvice() { - File advice = fetchClass(AfterAdvice.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(AfterAdvice.class.getName(), result.getClazz().getClassName()); - AfterSpecification afterSpec = (AfterSpecification) findAdvice(result, "after"); - assertNotNull(afterSpec); - assertEquals( - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", - afterSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", - afterSpec.getSignature()); - assertNotNull(afterSpec.findThis()); - assertNotNull(afterSpec.findReturn()); - assertNull(afterSpec.findAllArguments()); - assertNull(afterSpec.findInvokeDynamicConstants()); - List arguments = getArguments(afterSpec); - assertEquals(Arrays.asList(0, 1), arguments); - } - - @CallSite(spi = CallSites.class) - static class AllArgsAdvice { - @CallSite.Around( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") - static String allArgs( - @CallSite.AllArguments(includeThis = true) Object[] arguments, - @CallSite.Return String result) { - return result; - } - } - - @Test - void testSpecificationBuilderForAdviceWithAllArguments() { - File advice = fetchClass(AllArgsAdvice.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(AllArgsAdvice.class.getName(), result.getClazz().getClassName()); - AroundSpecification allArgsSpec = (AroundSpecification) findAdvice(result, "allArgs"); - assertNotNull(allArgsSpec); - assertEquals( - "([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;", - allArgsSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", - allArgsSpec.getSignature()); - assertNull(allArgsSpec.findThis()); - assertNotNull(allArgsSpec.findReturn()); - AllArgsSpecification allArguments = allArgsSpec.findAllArguments(); - assertNotNull(allArguments); - assertTrue(allArguments.isIncludeThis()); - assertNull(allArgsSpec.findInvokeDynamicConstants()); - List arguments = getArguments(allArgsSpec); - assertEquals(Arrays.asList(), arguments); - } - - @CallSite(spi = CallSites.class) - static class InvokeDynamicBeforeAdvice { - @CallSite.After( - value = - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamic = true) - static String invokeDynamic( - @CallSite.AllArguments Object[] arguments, @CallSite.Return String result) { - return result; - } - } - - @Test - void testSpecificationBuilderForBeforeInvokeDynamic() { - File advice = fetchClass(InvokeDynamicBeforeAdvice.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(InvokeDynamicBeforeAdvice.class.getName(), result.getClazz().getClassName()); - AfterSpecification invokeDynamicSpec = (AfterSpecification) findAdvice(result, "invokeDynamic"); - assertNotNull(invokeDynamicSpec); - assertEquals( - "([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;", - invokeDynamicSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamicSpec.getSignature()); - assertNull(invokeDynamicSpec.findThis()); - assertNotNull(invokeDynamicSpec.findReturn()); - AllArgsSpecification allArguments = invokeDynamicSpec.findAllArguments(); - assertNotNull(allArguments); - assertFalse(allArguments.isIncludeThis()); - assertNull(invokeDynamicSpec.findInvokeDynamicConstants()); - List arguments = getArguments(invokeDynamicSpec); - assertEquals(Arrays.asList(), arguments); - } - - @CallSite(spi = CallSites.class) - static class InvokeDynamicAroundAdvice { - @CallSite.Around( - value = - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamic = true) - static java.lang.invoke.CallSite invokeDynamic( - @CallSite.Argument MethodHandles.Lookup lookup, - @CallSite.Argument String name, - @CallSite.Argument MethodType concatType, - @CallSite.Argument String recipe, - @CallSite.Argument Object... constants) { - return null; - } - } - - @Test - void testSpecificationBuilderForAroundInvokeDynamic() { - File advice = fetchClass(InvokeDynamicAroundAdvice.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(InvokeDynamicAroundAdvice.class.getName(), result.getClazz().getClassName()); - AroundSpecification invokeDynamicSpec = - (AroundSpecification) findAdvice(result, "invokeDynamic"); - assertNotNull(invokeDynamicSpec); - assertEquals( - "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;", - invokeDynamicSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamicSpec.getSignature()); - assertNull(invokeDynamicSpec.findThis()); - assertNull(invokeDynamicSpec.findReturn()); - assertNull(invokeDynamicSpec.findAllArguments()); - assertNull(invokeDynamicSpec.findInvokeDynamicConstants()); - List arguments = getArguments(invokeDynamicSpec); - assertEquals(Arrays.asList(0, 1, 2, 3, 4), arguments); - } - - @CallSite(spi = CallSites.class) - static class TestInvokeDynamicConstants { - @CallSite.After( - value = - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - invokeDynamic = true) - static String after( - @CallSite.AllArguments Object[] parameter, - @CallSite.InvokeDynamicConstants Object[] constants, - @CallSite.Return String value) { - return value; - } - } - - @Test - void testInvokeDynamicConstants() { - File advice = fetchClass(TestInvokeDynamicConstants.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(TestInvokeDynamicConstants.class.getName(), result.getClazz().getClassName()); - AfterSpecification inheritedSpec = (AfterSpecification) findAdvice(result, "after"); - assertNotNull(inheritedSpec); - assertEquals( - "([Ljava/lang/Object;[Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;", - inheritedSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", - inheritedSpec.getSignature()); - assertNull(inheritedSpec.findThis()); - assertNotNull(inheritedSpec.findReturn()); - assertNotNull(inheritedSpec.findInvokeDynamicConstants()); - List arguments = getArguments(inheritedSpec); - assertEquals(Arrays.asList(), arguments); - } - - @CallSite(spi = CallSites.class) - static class TestBeforeArray { - - @CallSite.BeforeArray({ - @CallSite.Before("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), - @CallSite.Before("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") - }) - static void before(@CallSite.This ServletRequest request) {} - } - - @Test - void testSpecificationBuilderForBeforeAdviceArray() { - File advice = fetchClass(TestBeforeArray.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(TestBeforeArray.class.getName(), result.getClazz().getClassName()); - List list = result.getAdvices(); - assertEquals(2, list.size()); - for (AdviceSpecification spec : list) { - assertInstanceOf(BeforeSpecification.class, spec); - assertEquals( - "(Ljavax/servlet/ServletRequest;)V", spec.getAdvice().getMethodType().getDescriptor()); - assertTrue( - spec.getSignature().equals("java.util.Map javax.servlet.ServletRequest.getParameterMap()") - || spec.getSignature() - .equals("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()")); - assertNotNull(spec.findThis()); - assertNull(spec.findReturn()); - assertNull(spec.findAllArguments()); - assertNull(spec.findInvokeDynamicConstants()); - List arguments = getArguments(spec); - assertEquals(Arrays.asList(), arguments); - } - } - - @CallSite(spi = CallSites.class) - static class TestAroundArray { - - @CallSite.AroundArray({ - @CallSite.Around("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), - @CallSite.Around("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") - }) - static Map around(@CallSite.This ServletRequest request) { - return request.getParameterMap(); - } - } - - @Test - void testSpecificationBuilderForAroundAdviceArray() { - File advice = fetchClass(TestAroundArray.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(TestAroundArray.class.getName(), result.getClazz().getClassName()); - List list = result.getAdvices(); - assertEquals(2, list.size()); - for (AdviceSpecification spec : list) { - assertInstanceOf(AroundSpecification.class, spec); - assertEquals( - "(Ljavax/servlet/ServletRequest;)Ljava/util/Map;", - spec.getAdvice().getMethodType().getDescriptor()); - assertTrue( - spec.getSignature().equals("java.util.Map javax.servlet.ServletRequest.getParameterMap()") - || spec.getSignature() - .equals("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()")); - assertNotNull(spec.findThis()); - assertNull(spec.findReturn()); - assertNull(spec.findAllArguments()); - assertNull(spec.findInvokeDynamicConstants()); - List arguments = getArguments(spec); - assertEquals(Arrays.asList(), arguments); - } - } - - @CallSite(spi = CallSites.class) - static class TestAfterArray { - - @CallSite.AfterArray({ - @CallSite.After("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), - @CallSite.After("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") - }) - static Map after(@CallSite.This ServletRequest request, @CallSite.Return Map parameters) { - return parameters; - } - } - - @Test - void testSpecificationBuilderForAfterAdviceArray() { - File advice = fetchClass(TestAfterArray.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(TestAfterArray.class.getName(), result.getClazz().getClassName()); - List list = result.getAdvices(); - assertEquals(2, list.size()); - for (AdviceSpecification spec : list) { - assertInstanceOf(AfterSpecification.class, spec); - assertEquals( - "(Ljavax/servlet/ServletRequest;Ljava/util/Map;)Ljava/util/Map;", - spec.getAdvice().getMethodType().getDescriptor()); - assertTrue( - spec.getSignature().equals("java.util.Map javax.servlet.ServletRequest.getParameterMap()") - || spec.getSignature() - .equals("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()")); - assertNotNull(spec.findThis()); - assertNotNull(spec.findReturn()); - assertNull(spec.findAllArguments()); - assertNull(spec.findInvokeDynamicConstants()); - List arguments = getArguments(spec); - assertEquals(Arrays.asList(), arguments); - } - } - - @CallSite(spi = CallSites.class) - static class TestInheritedMethod { - @CallSite.After( - "java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)") - static String after( - @CallSite.This ServletRequest request, - @CallSite.Argument String parameter, - @CallSite.Return String value) { - return value; - } - } - - @Test - void testSpecificationBuilderForInheritedMethods() { - File advice = fetchClass(TestInheritedMethod.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(TestInheritedMethod.class.getName(), result.getClazz().getClassName()); - AfterSpecification inheritedSpec = (AfterSpecification) findAdvice(result, "after"); - assertNotNull(inheritedSpec); - assertEquals( - "(Ljavax/servlet/ServletRequest;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", - inheritedSpec.getAdvice().getMethodType().getDescriptor()); - assertEquals( - "java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)", - inheritedSpec.getSignature()); - assertNotNull(inheritedSpec.findThis()); - assertNotNull(inheritedSpec.findReturn()); - assertNull(inheritedSpec.findAllArguments()); - assertNull(inheritedSpec.findInvokeDynamicConstants()); - List arguments = getArguments(inheritedSpec); - assertEquals(Arrays.asList(0), arguments); - } - - static class IsEnabled { - static boolean isEnabled(String defaultValue) { - return true; - } - } - - @CallSite( - spi = CallSites.class, - enabled = { - "datadog.trace.plugin.csi.impl.AsmSpecificationBuilderTest$IsEnabled", - "isEnabled", - "true" - }) - static class TestEnablement { - @CallSite.After( - "java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)") - static String after( - @CallSite.This ServletRequest request, - @CallSite.Argument String parameter, - @CallSite.Return String value) { - return value; - } - } - - @Test - void testSpecificationBuilderWithEnabledProperty() { - File advice = fetchClass(TestEnablement.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(TestEnablement.class.getName(), result.getClazz().getClassName()); - assertNotNull(result.getEnabled()); - assertEquals(Type.getType(IsEnabled.class), result.getEnabled().getMethod().getOwner()); - assertEquals("isEnabled", result.getEnabled().getMethod().getMethodName()); - assertEquals( - Type.getMethodType(Types.BOOLEAN, Types.STRING), - result.getEnabled().getMethod().getMethodType()); - assertEquals(Arrays.asList("true"), result.getEnabled().getArguments()); - } - - @CallSite(spi = CallSites.class) - static class TestWithOtherAnnotations { - @CallSite.Around("java.lang.StringBuilder java.lang.StringBuilder.append(java.lang.Object)") - @CallSite.Around("java.lang.StringBuffer java.lang.StringBuffer.append(java.lang.Object)") - @Nonnull - static Appendable aroundAppend( - @CallSite.This @Nullable Appendable self, @CallSite.Argument(0) @Nullable Object param) - throws Throwable { - return self.append(param.toString()); - } - } - - @Test - void testSpecificationBuilderWithMultipleMethodAnnotations() { - File advice = fetchClass(TestWithOtherAnnotations.class); - AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); - - CallSiteSpecification result = - specificationBuilder.build(advice).orElseThrow(RuntimeException::new); - - assertEquals(TestWithOtherAnnotations.class.getName(), result.getClazz().getClassName()); - assertEquals(2, result.getAdvices().size()); - } - - private static List getArguments(AdviceSpecification advice) { - return advice.getArguments().map(arg -> arg.getIndex()).collect(Collectors.toList()); - } - - private static AdviceSpecification findAdvice(CallSiteSpecification result, String name) { - return result.getAdvices().stream() - .filter(it -> it.getAdvice().getMethodName().equals(name)) - .findFirst() - .orElse(null); - } + } + + @CallSite(spi = CallSites.class) + static class BeforeAdvice { + @CallSite.Before("java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") + static void before( + @CallSite.This String self, @CallSite.Argument String regexp, @CallSite.Argument String replacement) {} + } + + @Test + void testSpecificationBuilderForBeforeAdvice() { + File advice = fetchClass(BeforeAdvice.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(BeforeAdvice.class.getName(), result.getClazz().getClassName()); + BeforeSpecification beforeSpec = (BeforeSpecification) findAdvice(result, "before"); + assertNotNull(beforeSpec); + assertEquals( + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + beforeSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", + beforeSpec.getSignature()); + assertNotNull(beforeSpec.findThis()); + assertNull(beforeSpec.findReturn()); + assertNull(beforeSpec.findAllArguments()); + assertNull(beforeSpec.findInvokeDynamicConstants()); + List arguments = getArguments(beforeSpec); + assertEquals(Arrays.asList(0, 1), arguments); + } + + @CallSite(spi = CallSites.class) + static class AroundAdvice { + @CallSite.Around("java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") + static String around( + @CallSite.This String self, @CallSite.Argument String regexp, @CallSite.Argument String replacement) { + return self.replaceAll(regexp, replacement); + } + } + + @Test + void testSpecificationBuilderForAroundAdvice() { + File advice = fetchClass(AroundAdvice.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(AroundAdvice.class.getName(), result.getClazz().getClassName()); + AroundSpecification aroundSpec = (AroundSpecification) findAdvice(result, "around"); + assertNotNull(aroundSpec); + assertEquals( + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + aroundSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", + aroundSpec.getSignature()); + assertNotNull(aroundSpec.findThis()); + assertNull(aroundSpec.findReturn()); + assertNull(aroundSpec.findAllArguments()); + assertNull(aroundSpec.findInvokeDynamicConstants()); + List arguments = getArguments(aroundSpec); + assertEquals(Arrays.asList(0, 1), arguments); + } + + @CallSite(spi = CallSites.class) + static class AfterAdvice { + @CallSite.After("java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") + static String after( + @CallSite.This String self, + @CallSite.Argument String regexp, + @CallSite.Argument String replacement, + @CallSite.Return String result) { + return result; + } + } + + @Test + void testSpecificationBuilderForAfterAdvice() { + File advice = fetchClass(AfterAdvice.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(AfterAdvice.class.getName(), result.getClazz().getClassName()); + AfterSpecification afterSpec = (AfterSpecification) findAdvice(result, "after"); + assertNotNull(afterSpec); + assertEquals( + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + afterSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", + afterSpec.getSignature()); + assertNotNull(afterSpec.findThis()); + assertNotNull(afterSpec.findReturn()); + assertNull(afterSpec.findAllArguments()); + assertNull(afterSpec.findInvokeDynamicConstants()); + List arguments = getArguments(afterSpec); + assertEquals(Arrays.asList(0, 1), arguments); + } + + @CallSite(spi = CallSites.class) + static class AllArgsAdvice { + @CallSite.Around("java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)") + static String allArgs( + @CallSite.AllArguments(includeThis = true) Object[] arguments, @CallSite.Return String result) { + return result; + } + } + + @Test + void testSpecificationBuilderForAdviceWithAllArguments() { + File advice = fetchClass(AllArgsAdvice.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(AllArgsAdvice.class.getName(), result.getClazz().getClassName()); + AroundSpecification allArgsSpec = (AroundSpecification) findAdvice(result, "allArgs"); + assertNotNull(allArgsSpec); + assertEquals( + "([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;", + allArgsSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.String java.lang.String.replaceAll(java.lang.String, java.lang.String)", + allArgsSpec.getSignature()); + assertNull(allArgsSpec.findThis()); + assertNotNull(allArgsSpec.findReturn()); + AllArgsSpecification allArguments = allArgsSpec.findAllArguments(); + assertNotNull(allArguments); + assertTrue(allArguments.isIncludeThis()); + assertNull(allArgsSpec.findInvokeDynamicConstants()); + List arguments = getArguments(allArgsSpec); + assertEquals(Arrays.asList(), arguments); + } + + @CallSite(spi = CallSites.class) + static class InvokeDynamicBeforeAdvice { + @CallSite.After( + value = + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamic = true) + static String invokeDynamic(@CallSite.AllArguments Object[] arguments, @CallSite.Return String result) { + return result; + } + } + + @Test + void testSpecificationBuilderForBeforeInvokeDynamic() { + File advice = fetchClass(InvokeDynamicBeforeAdvice.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals( + InvokeDynamicBeforeAdvice.class.getName(), result.getClazz().getClassName()); + AfterSpecification invokeDynamicSpec = (AfterSpecification) findAdvice(result, "invokeDynamic"); + assertNotNull(invokeDynamicSpec); + assertEquals( + "([Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;", + invokeDynamicSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamicSpec.getSignature()); + assertNull(invokeDynamicSpec.findThis()); + assertNotNull(invokeDynamicSpec.findReturn()); + AllArgsSpecification allArguments = invokeDynamicSpec.findAllArguments(); + assertNotNull(allArguments); + assertFalse(allArguments.isIncludeThis()); + assertNull(invokeDynamicSpec.findInvokeDynamicConstants()); + List arguments = getArguments(invokeDynamicSpec); + assertEquals(Arrays.asList(), arguments); + } + + @CallSite(spi = CallSites.class) + static class InvokeDynamicAroundAdvice { + @CallSite.Around( + value = + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamic = true) + static java.lang.invoke.CallSite invokeDynamic( + @CallSite.Argument MethodHandles.Lookup lookup, + @CallSite.Argument String name, + @CallSite.Argument MethodType concatType, + @CallSite.Argument String recipe, + @CallSite.Argument Object... constants) { + return null; + } + } + + @Test + void testSpecificationBuilderForAroundInvokeDynamic() { + File advice = fetchClass(InvokeDynamicAroundAdvice.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals( + InvokeDynamicAroundAdvice.class.getName(), result.getClazz().getClassName()); + AroundSpecification invokeDynamicSpec = (AroundSpecification) findAdvice(result, "invokeDynamic"); + assertNotNull(invokeDynamicSpec); + assertEquals( + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;", + invokeDynamicSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamicSpec.getSignature()); + assertNull(invokeDynamicSpec.findThis()); + assertNull(invokeDynamicSpec.findReturn()); + assertNull(invokeDynamicSpec.findAllArguments()); + assertNull(invokeDynamicSpec.findInvokeDynamicConstants()); + List arguments = getArguments(invokeDynamicSpec); + assertEquals(Arrays.asList(0, 1, 2, 3, 4), arguments); + } + + @CallSite(spi = CallSites.class) + static class TestInvokeDynamicConstants { + @CallSite.After( + value = + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + invokeDynamic = true) + static String after( + @CallSite.AllArguments Object[] parameter, + @CallSite.InvokeDynamicConstants Object[] constants, + @CallSite.Return String value) { + return value; + } + } + + @Test + void testInvokeDynamicConstants() { + File advice = fetchClass(TestInvokeDynamicConstants.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals( + TestInvokeDynamicConstants.class.getName(), result.getClazz().getClassName()); + AfterSpecification inheritedSpec = (AfterSpecification) findAdvice(result, "after"); + assertNotNull(inheritedSpec); + assertEquals( + "([Ljava/lang/Object;[Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;", + inheritedSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.invoke.CallSite java.lang.invoke.StringConcatFactory.makeConcatWithConstants(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType, java.lang.String, java.lang.Object[])", + inheritedSpec.getSignature()); + assertNull(inheritedSpec.findThis()); + assertNotNull(inheritedSpec.findReturn()); + assertNotNull(inheritedSpec.findInvokeDynamicConstants()); + List arguments = getArguments(inheritedSpec); + assertEquals(Arrays.asList(), arguments); + } + + @CallSite(spi = CallSites.class) + static class TestBeforeArray { + + @CallSite.BeforeArray({ + @CallSite.Before("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), + @CallSite.Before("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") + }) + static void before(@CallSite.This ServletRequest request) {} + } + + @Test + void testSpecificationBuilderForBeforeAdviceArray() { + File advice = fetchClass(TestBeforeArray.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(TestBeforeArray.class.getName(), result.getClazz().getClassName()); + List list = result.getAdvices(); + assertEquals(2, list.size()); + for (AdviceSpecification spec : list) { + assertInstanceOf(BeforeSpecification.class, spec); + assertEquals( + "(Ljavax/servlet/ServletRequest;)V", + spec.getAdvice().getMethodType().getDescriptor()); + assertTrue(spec.getSignature().equals("java.util.Map javax.servlet.ServletRequest.getParameterMap()") + || spec.getSignature() + .equals("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()")); + assertNotNull(spec.findThis()); + assertNull(spec.findReturn()); + assertNull(spec.findAllArguments()); + assertNull(spec.findInvokeDynamicConstants()); + List arguments = getArguments(spec); + assertEquals(Arrays.asList(), arguments); + } + } + + @CallSite(spi = CallSites.class) + static class TestAroundArray { + + @CallSite.AroundArray({ + @CallSite.Around("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), + @CallSite.Around("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") + }) + static Map around(@CallSite.This ServletRequest request) { + return request.getParameterMap(); + } + } + + @Test + void testSpecificationBuilderForAroundAdviceArray() { + File advice = fetchClass(TestAroundArray.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(TestAroundArray.class.getName(), result.getClazz().getClassName()); + List list = result.getAdvices(); + assertEquals(2, list.size()); + for (AdviceSpecification spec : list) { + assertInstanceOf(AroundSpecification.class, spec); + assertEquals( + "(Ljavax/servlet/ServletRequest;)Ljava/util/Map;", + spec.getAdvice().getMethodType().getDescriptor()); + assertTrue(spec.getSignature().equals("java.util.Map javax.servlet.ServletRequest.getParameterMap()") + || spec.getSignature() + .equals("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()")); + assertNotNull(spec.findThis()); + assertNull(spec.findReturn()); + assertNull(spec.findAllArguments()); + assertNull(spec.findInvokeDynamicConstants()); + List arguments = getArguments(spec); + assertEquals(Arrays.asList(), arguments); + } + } + + @CallSite(spi = CallSites.class) + static class TestAfterArray { + + @CallSite.AfterArray({ + @CallSite.After("java.util.Map javax.servlet.ServletRequest.getParameterMap()"), + @CallSite.After("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()") + }) + static Map after(@CallSite.This ServletRequest request, @CallSite.Return Map parameters) { + return parameters; + } + } + + @Test + void testSpecificationBuilderForAfterAdviceArray() { + File advice = fetchClass(TestAfterArray.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(TestAfterArray.class.getName(), result.getClazz().getClassName()); + List list = result.getAdvices(); + assertEquals(2, list.size()); + for (AdviceSpecification spec : list) { + assertInstanceOf(AfterSpecification.class, spec); + assertEquals( + "(Ljavax/servlet/ServletRequest;Ljava/util/Map;)Ljava/util/Map;", + spec.getAdvice().getMethodType().getDescriptor()); + assertTrue(spec.getSignature().equals("java.util.Map javax.servlet.ServletRequest.getParameterMap()") + || spec.getSignature() + .equals("java.util.Map javax.servlet.ServletRequestWrapper.getParameterMap()")); + assertNotNull(spec.findThis()); + assertNotNull(spec.findReturn()); + assertNull(spec.findAllArguments()); + assertNull(spec.findInvokeDynamicConstants()); + List arguments = getArguments(spec); + assertEquals(Arrays.asList(), arguments); + } + } + + @CallSite(spi = CallSites.class) + static class TestInheritedMethod { + @CallSite.After("java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)") + static String after( + @CallSite.This ServletRequest request, + @CallSite.Argument String parameter, + @CallSite.Return String value) { + return value; + } + } + + @Test + void testSpecificationBuilderForInheritedMethods() { + File advice = fetchClass(TestInheritedMethod.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(TestInheritedMethod.class.getName(), result.getClazz().getClassName()); + AfterSpecification inheritedSpec = (AfterSpecification) findAdvice(result, "after"); + assertNotNull(inheritedSpec); + assertEquals( + "(Ljavax/servlet/ServletRequest;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + inheritedSpec.getAdvice().getMethodType().getDescriptor()); + assertEquals( + "java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)", + inheritedSpec.getSignature()); + assertNotNull(inheritedSpec.findThis()); + assertNotNull(inheritedSpec.findReturn()); + assertNull(inheritedSpec.findAllArguments()); + assertNull(inheritedSpec.findInvokeDynamicConstants()); + List arguments = getArguments(inheritedSpec); + assertEquals(Arrays.asList(0), arguments); + } + + static class IsEnabled { + static boolean isEnabled(String defaultValue) { + return true; + } + } + + @CallSite( + spi = CallSites.class, + enabled = {"datadog.trace.plugin.csi.impl.AsmSpecificationBuilderTest$IsEnabled", "isEnabled", "true"}) + static class TestEnablement { + @CallSite.After("java.lang.String javax.servlet.http.HttpServletRequest.getParameter(java.lang.String)") + static String after( + @CallSite.This ServletRequest request, + @CallSite.Argument String parameter, + @CallSite.Return String value) { + return value; + } + } + + @Test + void testSpecificationBuilderWithEnabledProperty() { + File advice = fetchClass(TestEnablement.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(TestEnablement.class.getName(), result.getClazz().getClassName()); + assertNotNull(result.getEnabled()); + assertEquals( + Type.getType(IsEnabled.class), result.getEnabled().getMethod().getOwner()); + assertEquals("isEnabled", result.getEnabled().getMethod().getMethodName()); + assertEquals( + Type.getMethodType(Types.BOOLEAN, Types.STRING), + result.getEnabled().getMethod().getMethodType()); + assertEquals(Arrays.asList("true"), result.getEnabled().getArguments()); + } + + @CallSite(spi = CallSites.class) + static class TestWithOtherAnnotations { + @CallSite.Around("java.lang.StringBuilder java.lang.StringBuilder.append(java.lang.Object)") + @CallSite.Around("java.lang.StringBuffer java.lang.StringBuffer.append(java.lang.Object)") + @Nonnull + static Appendable aroundAppend( + @CallSite.This @Nullable Appendable self, @CallSite.Argument(0) @Nullable Object param) + throws Throwable { + return self.append(param.toString()); + } + } + + @Test + void testSpecificationBuilderWithMultipleMethodAnnotations() { + File advice = fetchClass(TestWithOtherAnnotations.class); + AsmSpecificationBuilder specificationBuilder = new AsmSpecificationBuilder(); + + CallSiteSpecification result = specificationBuilder.build(advice).orElseThrow(RuntimeException::new); + + assertEquals(TestWithOtherAnnotations.class.getName(), result.getClazz().getClassName()); + assertEquals(2, result.getAdvices().size()); + } + + private static List getArguments(AdviceSpecification advice) { + return advice.getArguments().map(arg -> arg.getIndex()).collect(Collectors.toList()); + } + + private static AdviceSpecification findAdvice(CallSiteSpecification result, String name) { + return result.getAdvices().stream() + .filter(it -> it.getAdvice().getMethodName().equals(name)) + .findFirst() + .orElse(null); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.java index a2bd158d20b..98de15153c8 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/BaseCsiPluginTest.java @@ -21,43 +21,41 @@ public abstract class BaseCsiPluginTest { - protected static void assertNoErrors(HasErrors hasErrors) { - List errors = - hasErrors.getErrors().stream() - .map( - error -> { - String causeString = error.getCause() == null ? "-" : error.getCauseString(); - return error.getMessage() + ": " + causeString; + protected static void assertNoErrors(HasErrors hasErrors) { + List errors = hasErrors.getErrors().stream() + .map(error -> { + String causeString = error.getCause() == null ? "-" : error.getCauseString(); + return error.getMessage() + ": " + causeString; }) - .collect(Collectors.toList()); - assertEquals(Collections.emptyList(), errors); - } + .collect(Collectors.toList()); + assertEquals(Collections.emptyList(), errors); + } - protected static File fetchClass(Class clazz) { - try { - Path folder = Paths.get(clazz.getResource("/").toURI()).resolve("../../"); - String fileSeparator = File.separator.equals("\\") ? "\\\\" : File.separator; - String classFile = clazz.getName().replaceAll("\\.", fileSeparator) + ".class"; - Path groovy = folder.resolve("groovy/test").resolve(classFile); - if (Files.exists(groovy)) { - return groovy.toFile(); - } - return folder.resolve("java/test").resolve(classFile).toFile(); - } catch (URISyntaxException e) { - throw new RuntimeException(e); + protected static File fetchClass(Class clazz) { + try { + Path folder = Paths.get(clazz.getResource("/").toURI()).resolve("../../"); + String fileSeparator = File.separator.equals("\\") ? "\\\\" : File.separator; + String classFile = clazz.getName().replaceAll("\\.", fileSeparator) + ".class"; + Path groovy = folder.resolve("groovy/test").resolve(classFile); + if (Files.exists(groovy)) { + return groovy.toFile(); + } + return folder.resolve("java/test").resolve(classFile).toFile(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } } - } - protected static CallSiteSpecification buildClassSpecification(Class clazz) { - File classFile = fetchClass(clazz); - CallSiteSpecification spec = specificationBuilder().build(classFile).get(); - spec.getAdvices().forEach(advice -> advice.parseSignature(pointcutParser())); - return spec; - } + protected static CallSiteSpecification buildClassSpecification(Class clazz) { + File classFile = fetchClass(clazz); + CallSiteSpecification spec = specificationBuilder().build(classFile).get(); + spec.getAdvices().forEach(advice -> advice.parseSignature(pointcutParser())); + return spec; + } - protected ValidationContext mockValidationContext() { - ValidationContext context = mock(ValidationContext.class); - when(context.getContextProperty(TYPE_RESOLVER)).thenReturn(typeResolver()); - return context; - } + protected ValidationContext mockValidationContext() { + ValidationContext context = mock(ValidationContext.class); + when(context.getContextProperty(TYPE_RESOLVER)).thenReturn(typeResolver()); + return context; + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.java index a0d60d379c1..f1175a4d689 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/CallSiteSpecificationTest.java @@ -17,53 +17,50 @@ class CallSiteSpecificationTest extends BaseCsiPluginTest { - @Test - void testCallSiteSpiShouldBeAnInterface() { - ValidationContext context = mockValidationContext(); - AdviceSpecification mockAdvice = mock(AdviceSpecification.class); - List advices = Collections.singletonList(mockAdvice); - Set spiTypes = Collections.singleton(Type.getType(String.class)); - List helperClassNames = Collections.emptyList(); - Set constants = Collections.emptySet(); - CallSiteSpecification spec = - new CallSiteSpecification( - Type.getType(String.class), advices, spiTypes, helperClassNames, constants); + @Test + void testCallSiteSpiShouldBeAnInterface() { + ValidationContext context = mockValidationContext(); + AdviceSpecification mockAdvice = mock(AdviceSpecification.class); + List advices = Collections.singletonList(mockAdvice); + Set spiTypes = Collections.singleton(Type.getType(String.class)); + List helperClassNames = Collections.emptyList(); + Set constants = Collections.emptySet(); + CallSiteSpecification spec = + new CallSiteSpecification(Type.getType(String.class), advices, spiTypes, helperClassNames, constants); - spec.validate(context); + spec.validate(context); - verify(context).addError(eq(ErrorCode.CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE), any()); - } + verify(context).addError(eq(ErrorCode.CALL_SITE_SPI_SHOULD_BE_AN_INTERFACE), any()); + } - @Test - void testCallSiteSpiShouldNotDefineAnyMethods() { - ValidationContext context = mockValidationContext(); - AdviceSpecification mockAdvice = mock(AdviceSpecification.class); - List advices = Collections.singletonList(mockAdvice); - Set spiTypes = Collections.singleton(Type.getType(Comparable.class)); - List helperClassNames = Collections.emptyList(); - Set constants = Collections.emptySet(); - CallSiteSpecification spec = - new CallSiteSpecification( - Type.getType(String.class), advices, spiTypes, helperClassNames, constants); + @Test + void testCallSiteSpiShouldNotDefineAnyMethods() { + ValidationContext context = mockValidationContext(); + AdviceSpecification mockAdvice = mock(AdviceSpecification.class); + List advices = Collections.singletonList(mockAdvice); + Set spiTypes = Collections.singleton(Type.getType(Comparable.class)); + List helperClassNames = Collections.emptyList(); + Set constants = Collections.emptySet(); + CallSiteSpecification spec = + new CallSiteSpecification(Type.getType(String.class), advices, spiTypes, helperClassNames, constants); - spec.validate(context); + spec.validate(context); - verify(context).addError(eq(ErrorCode.CALL_SITE_SPI_SHOULD_BE_EMPTY), any()); - } + verify(context).addError(eq(ErrorCode.CALL_SITE_SPI_SHOULD_BE_EMPTY), any()); + } - @Test - void testCallSiteShouldHaveAdvices() { - ValidationContext context = mockValidationContext(); - List advices = Collections.emptyList(); - Set spiTypes = Collections.singleton(Type.getType(CallSiteAdvice.class)); - List helperClassNames = Collections.emptyList(); - Set constants = Collections.emptySet(); - CallSiteSpecification spec = - new CallSiteSpecification( - Type.getType(String.class), advices, spiTypes, helperClassNames, constants); + @Test + void testCallSiteShouldHaveAdvices() { + ValidationContext context = mockValidationContext(); + List advices = Collections.emptyList(); + Set spiTypes = Collections.singleton(Type.getType(CallSiteAdvice.class)); + List helperClassNames = Collections.emptyList(); + Set constants = Collections.emptySet(); + CallSiteSpecification spec = + new CallSiteSpecification(Type.getType(String.class), advices, spiTypes, helperClassNames, constants); - spec.validate(context); + spec.validate(context); - verify(context).addError(eq(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS), any()); - } + verify(context).addError(eq(ErrorCode.CALL_SITE_SHOULD_HAVE_ADVICE_METHODS), any()); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.java index 550082ccb7d..72ee4390d2c 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/RegexpAdvicePointcutParserTest.java @@ -7,156 +7,148 @@ class RegexpAdvicePointcutParserTest { - @Test - void resolveConstructor() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "void datadog.trace.plugin.csi.samples.SignatureParserExample.()"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("", signature.getMethodName()); - assertEquals("()V", signature.getMethodType().getDescriptor()); - } - - @Test - void resolveConstructorWithArgs() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "void datadog.trace.plugin.csi.samples.SignatureParserExample.(java.lang.String)"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("", signature.getMethodName()); - assertEquals("(Ljava/lang/String;)V", signature.getMethodType().getDescriptor()); - } - - @Test - void resolveWithoutArgs() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.noParams()"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("noParams", signature.getMethodName()); - assertEquals("()Ljava/lang/String;", signature.getMethodType().getDescriptor()); - } - - @Test - void resolveOneParam() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.oneParam(java.util.Map)"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("oneParam", signature.getMethodName()); - assertEquals("(Ljava/util/Map;)Ljava/lang/String;", signature.getMethodType().getDescriptor()); - } - - @Test - void resolveMultipleParams() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.multipleParams(java.lang.String, int, java.util.List)"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("multipleParams", signature.getMethodName()); - assertEquals( - "(Ljava/lang/String;ILjava/util/List;)Ljava/lang/String;", - signature.getMethodType().getDescriptor()); - } - - @Test - void resolveVarargs() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.varargs(java.lang.String[])"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("varargs", signature.getMethodName()); - assertEquals( - "([Ljava/lang/String;)Ljava/lang/String;", signature.getMethodType().getDescriptor()); - } - - @Test - void resolvePrimitive() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "int datadog.trace.plugin.csi.samples.SignatureParserExample.primitive()"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("primitive", signature.getMethodName()); - assertEquals("()I", signature.getMethodType().getDescriptor()); - } - - @Test - void resolvePrimitiveArrayType() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "byte[] datadog.trace.plugin.csi.samples.SignatureParserExample.primitiveArray()"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("primitiveArray", signature.getMethodName()); - assertEquals("()[B", signature.getMethodType().getDescriptor()); - } - - @Test - void resolveObjectArrayType() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "java.lang.Object[] datadog.trace.plugin.csi.samples.SignatureParserExample.objectArray()"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("objectArray", signature.getMethodName()); - assertEquals("()[Ljava/lang/Object;", signature.getMethodType().getDescriptor()); - } - - @Test - void resolveMultiDimensionalObjectArrayType() { - RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); - - MethodType signature = - pointcutParser.parse( - "java.lang.Object[][][] datadog.trace.plugin.csi.samples.SignatureParserExample.objectArray()"); - - assertEquals( - "datadog.trace.plugin.csi.samples.SignatureParserExample", - signature.getOwner().getClassName()); - assertEquals("objectArray", signature.getMethodName()); - assertEquals("()[[[Ljava/lang/Object;", signature.getMethodType().getDescriptor()); - } + @Test + void resolveConstructor() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = + pointcutParser.parse("void datadog.trace.plugin.csi.samples.SignatureParserExample.()"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("", signature.getMethodName()); + assertEquals("()V", signature.getMethodType().getDescriptor()); + } + + @Test + void resolveConstructorWithArgs() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = pointcutParser.parse( + "void datadog.trace.plugin.csi.samples.SignatureParserExample.(java.lang.String)"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("", signature.getMethodName()); + assertEquals("(Ljava/lang/String;)V", signature.getMethodType().getDescriptor()); + } + + @Test + void resolveWithoutArgs() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = pointcutParser.parse( + "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.noParams()"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("noParams", signature.getMethodName()); + assertEquals("()Ljava/lang/String;", signature.getMethodType().getDescriptor()); + } + + @Test + void resolveOneParam() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = pointcutParser.parse( + "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.oneParam(java.util.Map)"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("oneParam", signature.getMethodName()); + assertEquals( + "(Ljava/util/Map;)Ljava/lang/String;", signature.getMethodType().getDescriptor()); + } + + @Test + void resolveMultipleParams() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = pointcutParser.parse( + "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.multipleParams(java.lang.String, int, java.util.List)"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("multipleParams", signature.getMethodName()); + assertEquals( + "(Ljava/lang/String;ILjava/util/List;)Ljava/lang/String;", + signature.getMethodType().getDescriptor()); + } + + @Test + void resolveVarargs() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = pointcutParser.parse( + "java.lang.String datadog.trace.plugin.csi.samples.SignatureParserExample.varargs(java.lang.String[])"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("varargs", signature.getMethodName()); + assertEquals( + "([Ljava/lang/String;)Ljava/lang/String;", + signature.getMethodType().getDescriptor()); + } + + @Test + void resolvePrimitive() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = + pointcutParser.parse("int datadog.trace.plugin.csi.samples.SignatureParserExample.primitive()"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("primitive", signature.getMethodName()); + assertEquals("()I", signature.getMethodType().getDescriptor()); + } + + @Test + void resolvePrimitiveArrayType() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = + pointcutParser.parse("byte[] datadog.trace.plugin.csi.samples.SignatureParserExample.primitiveArray()"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("primitiveArray", signature.getMethodName()); + assertEquals("()[B", signature.getMethodType().getDescriptor()); + } + + @Test + void resolveObjectArrayType() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = pointcutParser.parse( + "java.lang.Object[] datadog.trace.plugin.csi.samples.SignatureParserExample.objectArray()"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("objectArray", signature.getMethodName()); + assertEquals("()[Ljava/lang/Object;", signature.getMethodType().getDescriptor()); + } + + @Test + void resolveMultiDimensionalObjectArrayType() { + RegexpAdvicePointcutParser pointcutParser = new RegexpAdvicePointcutParser(); + + MethodType signature = pointcutParser.parse( + "java.lang.Object[][][] datadog.trace.plugin.csi.samples.SignatureParserExample.objectArray()"); + + assertEquals( + "datadog.trace.plugin.csi.samples.SignatureParserExample", + signature.getOwner().getClassName()); + assertEquals("objectArray", signature.getMethodName()); + assertEquals("()[[[Ljava/lang/Object;", signature.getMethodType().getDescriptor()); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.java index 39a49d26c8b..8a477d705de 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/TypeResolverPoolTest.java @@ -11,57 +11,56 @@ class TypeResolverPoolTest { - TypeResolverPool resolver = new TypeResolverPool(); + TypeResolverPool resolver = new TypeResolverPool(); - @Test - void testResolvePrimitive() { - assertEquals(int.class, resolver.resolveType(Type.INT_TYPE)); - } + @Test + void testResolvePrimitive() { + assertEquals(int.class, resolver.resolveType(Type.INT_TYPE)); + } - @Test - void testResolvePrimitiveArray() { - Type type = Type.getType("[I"); - assertEquals(int[].class, resolver.resolveType(type)); - } + @Test + void testResolvePrimitiveArray() { + Type type = Type.getType("[I"); + assertEquals(int[].class, resolver.resolveType(type)); + } - @Test - void testResolvePrimitiveMultidimensionalArray() { - Type type = Type.getType("[[[I"); - assertEquals(int[][][].class, resolver.resolveType(type)); - } + @Test + void testResolvePrimitiveMultidimensionalArray() { + Type type = Type.getType("[[[I"); + assertEquals(int[][][].class, resolver.resolveType(type)); + } - @Test - void testResolveClass() { - Type type = Type.getType(String.class); - assertEquals(String.class, resolver.resolveType(type)); - } + @Test + void testResolveClass() { + Type type = Type.getType(String.class); + assertEquals(String.class, resolver.resolveType(type)); + } - @Test - void testResolveClassArray() { - Type type = Type.getType(String[].class); - assertEquals(String[].class, resolver.resolveType(type)); - } + @Test + void testResolveClassArray() { + Type type = Type.getType(String[].class); + assertEquals(String[].class, resolver.resolveType(type)); + } - @Test - void testResolveClassMultidimensionalArray() { - Type type = Type.getType(String[][][].class); - assertEquals(String[][][].class, resolver.resolveType(type)); - } + @Test + void testResolveClassMultidimensionalArray() { + Type type = Type.getType(String[][][].class); + assertEquals(String[][][].class, resolver.resolveType(type)); + } - @Test - void testTypeResolverFromMethod() { - Type type = - Type.getMethodType( - Type.getType(String[].class), Type.getType(String.class), Type.getType(String.class)); - assertEquals(String[].class, resolver.resolveType(type.getReturnType())); - } + @Test + void testTypeResolverFromMethod() { + Type type = Type.getMethodType( + Type.getType(String[].class), Type.getType(String.class), Type.getType(String.class)); + assertEquals(String[].class, resolver.resolveType(type.getReturnType())); + } - @Test - void testInheritedMethods() throws Exception { - Type owner = Type.getType(HttpServletRequest.class); - String name = "getParameter"; - Type descriptor = Type.getMethodType(Type.getType(String.class), Type.getType(String.class)); - Method result = (Method) resolver.resolveMethod(new MethodType(owner, name, descriptor)); - assertEquals(ServletRequest.class.getDeclaredMethod("getParameter", String.class), result); - } + @Test + void testInheritedMethods() throws Exception { + Type owner = Type.getType(HttpServletRequest.class); + String name = "getParameter"; + Type descriptor = Type.getMethodType(Type.getType(String.class), Type.getType(String.class)); + Method result = (Method) resolver.resolveMethod(new MethodType(owner, name, descriptor)); + assertEquals(ServletRequest.class.getDeclaredMethod("getParameter", String.class), result); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AdviceAssert.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AdviceAssert.java index 9f814d7171d..29b2f057956 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AdviceAssert.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AdviceAssert.java @@ -6,32 +6,31 @@ import java.util.List; public class AdviceAssert { - protected String type; - protected String owner; - protected String method; - protected String descriptor; - protected List statements; + protected String type; + protected String owner; + protected String method; + protected String descriptor; + protected List statements; - public AdviceAssert( - String type, String owner, String method, String descriptor, List statements) { - this.type = type; - this.owner = owner; - this.method = method; - this.descriptor = descriptor; - this.statements = statements; - } + public AdviceAssert(String type, String owner, String method, String descriptor, List statements) { + this.type = type; + this.owner = owner; + this.method = method; + this.descriptor = descriptor; + this.statements = statements; + } - public void type(String type) { - assertEquals(type, this.type); - } + public void type(String type) { + assertEquals(type, this.type); + } - public void pointcut(String owner, String method, String descriptor) { - assertEquals(owner, this.owner); - assertEquals(method, this.method); - assertEquals(descriptor, this.descriptor); - } + public void pointcut(String owner, String method, String descriptor) { + assertEquals(owner, this.owner); + assertEquals(method, this.method); + assertEquals(descriptor, this.descriptor); + } - public void statements(String... values) { - assertArrayEquals(values, statements.toArray(new String[0])); - } + public void statements(String... values) { + assertArrayEquals(values, statements.toArray(new String[0])); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AssertBuilder.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AssertBuilder.java index 42e87842adc..538de75953e 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AssertBuilder.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/AssertBuilder.java @@ -24,182 +24,158 @@ import java.util.stream.Collectors; public class AssertBuilder { - protected final File file; + protected final File file; - public AssertBuilder(File file) { - this.file = file; - } + public AssertBuilder(File file) { + this.file = file; + } - public CallSiteAssert build() { - CompilationUnit javaFile; - javaFile = parseJavaFile(file); - assertEquals(Node.Parsedness.PARSED, javaFile.getParsed()); - ClassOrInterfaceDeclaration targetType = - javaFile.getPrimaryType().get().asClassOrInterfaceDeclaration(); - Set> interfaces = getInterfaces(targetType); - Method enabled = null; - Set enabledArgs = null; - Object[] enabledDeclaration = getEnabledDeclaration(targetType, interfaces); - enabled = (Method) enabledDeclaration[0]; - enabledArgs = (Set) enabledDeclaration[1]; - return new CallSiteAssert( - interfaces, - getSpi(targetType), - getHelpers(targetType), - getAdvices(targetType), - enabled, - enabledArgs); - } + public CallSiteAssert build() { + CompilationUnit javaFile; + javaFile = parseJavaFile(file); + assertEquals(Node.Parsedness.PARSED, javaFile.getParsed()); + ClassOrInterfaceDeclaration targetType = javaFile.getPrimaryType().get().asClassOrInterfaceDeclaration(); + Set> interfaces = getInterfaces(targetType); + Method enabled = null; + Set enabledArgs = null; + Object[] enabledDeclaration = getEnabledDeclaration(targetType, interfaces); + enabled = (Method) enabledDeclaration[0]; + enabledArgs = (Set) enabledDeclaration[1]; + return new CallSiteAssert( + interfaces, getSpi(targetType), getHelpers(targetType), getAdvices(targetType), enabled, enabledArgs); + } - protected Set> getSpi(ClassOrInterfaceDeclaration type) { - return type.getAnnotationByName("AutoService") - .>>map( - annotation -> - annotation.asNormalAnnotationExpr().getPairs().stream() - .filter(pair -> pair.getNameAsString().equals("value")) - .flatMap( - pair -> - pair.getValue().asArrayInitializerExpr().getValues().stream() - .map( - value -> - value - .asClassExpr() - .getType() - .resolve() - .asReferenceType() - .getTypeDeclaration() - .get() - .getQualifiedName())) - .map(AssertBuilder::loadClass) - .collect(Collectors.toSet())) - .orElse(Collections.emptySet()); - } + protected Set> getSpi(ClassOrInterfaceDeclaration type) { + return type.getAnnotationByName("AutoService") + .>>map(annotation -> annotation.asNormalAnnotationExpr().getPairs().stream() + .filter(pair -> pair.getNameAsString().equals("value")) + .flatMap(pair -> pair.getValue().asArrayInitializerExpr().getValues().stream() + .map(value -> value.asClassExpr() + .getType() + .resolve() + .asReferenceType() + .getTypeDeclaration() + .get() + .getQualifiedName())) + .map(AssertBuilder::loadClass) + .collect(Collectors.toSet())) + .orElse(Collections.emptySet()); + } - protected Set> getInterfaces(ClassOrInterfaceDeclaration type) { - return type.getImplementedTypes().stream() - .map( - implementedType -> { - String qualifiedName = - implementedType - .asClassOrInterfaceType() - .resolve() - .asReferenceType() - .getTypeDeclaration() - .get() - .getQualifiedName(); - return loadClass(qualifiedName); - }) - .collect(Collectors.toSet()); - } + protected Set> getInterfaces(ClassOrInterfaceDeclaration type) { + return type.getImplementedTypes().stream() + .map(implementedType -> { + String qualifiedName = implementedType + .asClassOrInterfaceType() + .resolve() + .asReferenceType() + .getTypeDeclaration() + .get() + .getQualifiedName(); + return loadClass(qualifiedName); + }) + .collect(Collectors.toSet()); + } - private static Class loadClass(String qualifiedName) { - // Try progressively replacing dots with $ from right to left for inner classes - String current = qualifiedName; - int lastDot = current.lastIndexOf('.'); - do { - try { - return Class.forName(current); - } catch (ClassNotFoundException e) { - if (lastDot <= 0) { - throw new RuntimeException(new ClassNotFoundException(qualifiedName)); - } - current = current.substring(0, lastDot) + "$" + current.substring(lastDot + 1); - lastDot = current.lastIndexOf('.', lastDot - 1); - } - } while (true); - } + private static Class loadClass(String qualifiedName) { + // Try progressively replacing dots with $ from right to left for inner classes + String current = qualifiedName; + int lastDot = current.lastIndexOf('.'); + do { + try { + return Class.forName(current); + } catch (ClassNotFoundException e) { + if (lastDot <= 0) { + throw new RuntimeException(new ClassNotFoundException(qualifiedName)); + } + current = current.substring(0, lastDot) + "$" + current.substring(lastDot + 1); + lastDot = current.lastIndexOf('.', lastDot - 1); + } + } while (true); + } - protected Object[] getEnabledDeclaration( - ClassOrInterfaceDeclaration type, Set> interfaces) { - if (!interfaces.contains(CallSites.HasEnabledProperty.class)) { - return new Object[] {null, null}; + protected Object[] getEnabledDeclaration(ClassOrInterfaceDeclaration type, Set> interfaces) { + if (!interfaces.contains(CallSites.HasEnabledProperty.class)) { + return new Object[] {null, null}; + } + MethodDeclaration isEnabled = type.getMethodsByName("isEnabled").get(0); + MethodCallExpr enabledMethodCall = isEnabled + .getBody() + .get() + .getStatements() + .get(0) + .asReturnStmt() + .getExpression() + .get() + .asMethodCallExpr(); + Method enabled = resolveMethod(enabledMethodCall); + Set enabledArgs = enabledMethodCall.getArguments().stream() + .map(arg -> arg.asStringLiteralExpr().asString()) + .collect(Collectors.toSet()); + return new Object[] {enabled, enabledArgs}; } - MethodDeclaration isEnabled = type.getMethodsByName("isEnabled").get(0); - MethodCallExpr enabledMethodCall = - isEnabled - .getBody() - .get() - .getStatements() - .get(0) - .asReturnStmt() - .getExpression() - .get() - .asMethodCallExpr(); - Method enabled = resolveMethod(enabledMethodCall); - Set enabledArgs = - enabledMethodCall.getArguments().stream() - .map(arg -> arg.asStringLiteralExpr().asString()) - .collect(Collectors.toSet()); - return new Object[] {enabled, enabledArgs}; - } - protected Set> getHelpers(ClassOrInterfaceDeclaration type) { - MethodDeclaration acceptMethod = type.getMethodsByName("accept").get(0); - List methodCalls = getMethodCalls(acceptMethod); - return methodCalls.stream() - .filter(methodCall -> methodCall.getNameAsString().equals("addHelpers")) - .flatMap(methodCall -> methodCall.getArguments().stream()) - .map( - arg -> { - String className = arg.asStringLiteralExpr().asString(); - return typeResolver().resolveType(classNameToType(className)); - }) - .collect(Collectors.toSet()); - } + protected Set> getHelpers(ClassOrInterfaceDeclaration type) { + MethodDeclaration acceptMethod = type.getMethodsByName("accept").get(0); + List methodCalls = getMethodCalls(acceptMethod); + return methodCalls.stream() + .filter(methodCall -> methodCall.getNameAsString().equals("addHelpers")) + .flatMap(methodCall -> methodCall.getArguments().stream()) + .map(arg -> { + String className = arg.asStringLiteralExpr().asString(); + return typeResolver().resolveType(classNameToType(className)); + }) + .collect(Collectors.toSet()); + } - protected List getAdvices(ClassOrInterfaceDeclaration type) { - MethodDeclaration acceptMethod = type.getMethodsByName("accept").get(0); - return getMethodCalls(acceptMethod).stream() - .filter(methodCall -> methodCall.getNameAsString().equals("addAdvice")) - .map( - methodCall -> { - String adviceType = methodCall.getArgument(0).asFieldAccessExpr().getNameAsString(); - String owner = methodCall.getArgument(1).asStringLiteralExpr().asString(); - String method = methodCall.getArgument(2).asStringLiteralExpr().asString(); - String descriptor = methodCall.getArgument(3).asStringLiteralExpr().asString(); - List statements = - methodCall - .getArgument(4) - .asLambdaExpr() - .getBody() - .asBlockStmt() - .getStatements() - .stream() - .map(Object::toString) - .collect(Collectors.toList()); - return new AdviceAssert(adviceType, owner, method, descriptor, statements); - }) - .collect(Collectors.toList()); - } + protected List getAdvices(ClassOrInterfaceDeclaration type) { + MethodDeclaration acceptMethod = type.getMethodsByName("accept").get(0); + return getMethodCalls(acceptMethod).stream() + .filter(methodCall -> methodCall.getNameAsString().equals("addAdvice")) + .map(methodCall -> { + String adviceType = + methodCall.getArgument(0).asFieldAccessExpr().getNameAsString(); + String owner = + methodCall.getArgument(1).asStringLiteralExpr().asString(); + String method = + methodCall.getArgument(2).asStringLiteralExpr().asString(); + String descriptor = + methodCall.getArgument(3).asStringLiteralExpr().asString(); + List statements = + methodCall.getArgument(4).asLambdaExpr().getBody().asBlockStmt().getStatements().stream() + .map(Object::toString) + .collect(Collectors.toList()); + return new AdviceAssert(adviceType, owner, method, descriptor, statements); + }) + .collect(Collectors.toList()); + } - protected static List getMethodCalls(MethodDeclaration method) { - return method.getBody().get().asBlockStmt().getStatements().stream() - .filter( - stmt -> - stmt.isExpressionStmt() - && stmt.asExpressionStmt().getExpression().isMethodCallExpr()) - .map(stmt -> stmt.asExpressionStmt().getExpression().asMethodCallExpr()) - .collect(Collectors.toList()); - } + protected static List getMethodCalls(MethodDeclaration method) { + return method.getBody().get().asBlockStmt().getStatements().stream() + .filter(stmt -> stmt.isExpressionStmt() + && stmt.asExpressionStmt().getExpression().isMethodCallExpr()) + .map(stmt -> stmt.asExpressionStmt().getExpression().asMethodCallExpr()) + .collect(Collectors.toList()); + } - private static Method resolveMethod(MethodCallExpr methodCallExpr) { - ResolvedMethodDeclaration resolved = methodCallExpr.resolve(); - try { - Field methodField = resolved.getClass().getDeclaredField("method"); - methodField.setAccessible(true); - return (Method) methodField.get(resolved); - } catch (IllegalAccessException | NoSuchFieldException e) { - throw new RuntimeException(e); + private static Method resolveMethod(MethodCallExpr methodCallExpr) { + ResolvedMethodDeclaration resolved = methodCallExpr.resolve(); + try { + Field methodField = resolved.getClass().getDeclaredField("method"); + methodField.setAccessible(true); + return (Method) methodField.get(resolved); + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException(e); + } } - } - private static CompilationUnit parseJavaFile(File file) { - JavaSymbolSolver solver = new JavaSymbolSolver(typeResolver()); - JavaParser parser = new JavaParser(new ParserConfiguration().setSymbolResolver(solver)); - try { - return parser.parse(file).getResult().get(); - } catch (FileNotFoundException e) { - throw new RuntimeException(e); + private static CompilationUnit parseJavaFile(File file) { + JavaSymbolSolver solver = new JavaSymbolSolver(typeResolver()); + JavaParser parser = new JavaParser(new ParserConfiguration().setSymbolResolver(solver)); + try { + return parser.parse(file).getResult().get(); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/CallSiteAssert.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/CallSiteAssert.java index 57ce0146921..bf75f37acfe 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/CallSiteAssert.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/assertion/CallSiteAssert.java @@ -12,77 +12,77 @@ public class CallSiteAssert { - protected Set> interfaces; - protected Set> spi; - protected Set> helpers; - protected List advices; - protected Method enabled; - protected Set enabledArgs; - - public CallSiteAssert( - Set> interfaces, - Set> spi, - Set> helpers, - List advices, - Method enabled, - Set enabledArgs) { - this.interfaces = interfaces; - this.spi = spi; - this.helpers = helpers; - this.advices = advices; - this.enabled = enabled; - this.enabledArgs = enabledArgs; - } - - public void interfaces(Class... values) { - assertSameElements(interfaces, values); - } - - public void helpers(Class... values) { - assertSameElements(helpers, values); - } - - public void spi(Class... values) { - assertSameElements(spi, values); - } - - public void advices(int index, Consumer assertions) { - AdviceAssert asserter = advices.get(index); - assertions.accept(asserter); - } - - public void enabled(Method method, String... args) { - assertEquals(method, enabled); - assertSameElements(enabledArgs, args); - } - - private static void assertSameElements(Set expected, E... received) { - assertEquals(received.length, expected.size()); - Set receivedSet = new HashSet<>(Arrays.asList(received)); - assertTrue(expected.containsAll(receivedSet) && receivedSet.containsAll(expected)); - } - - public Set> getInterfaces() { - return interfaces; - } - - public Set> getSpi() { - return spi; - } - - public Set> getHelpers() { - return helpers; - } - - public List getAdvices() { - return advices; - } - - public Method getEnabled() { - return enabled; - } - - public Set getEnabledArgs() { - return enabledArgs; - } + protected Set> interfaces; + protected Set> spi; + protected Set> helpers; + protected List advices; + protected Method enabled; + protected Set enabledArgs; + + public CallSiteAssert( + Set> interfaces, + Set> spi, + Set> helpers, + List advices, + Method enabled, + Set enabledArgs) { + this.interfaces = interfaces; + this.spi = spi; + this.helpers = helpers; + this.advices = advices; + this.enabled = enabled; + this.enabledArgs = enabledArgs; + } + + public void interfaces(Class... values) { + assertSameElements(interfaces, values); + } + + public void helpers(Class... values) { + assertSameElements(helpers, values); + } + + public void spi(Class... values) { + assertSameElements(spi, values); + } + + public void advices(int index, Consumer assertions) { + AdviceAssert asserter = advices.get(index); + assertions.accept(asserter); + } + + public void enabled(Method method, String... args) { + assertEquals(method, enabled); + assertSameElements(enabledArgs, args); + } + + private static void assertSameElements(Set expected, E... received) { + assertEquals(received.length, expected.size()); + Set receivedSet = new HashSet<>(Arrays.asList(received)); + assertTrue(expected.containsAll(receivedSet) && receivedSet.containsAll(expected)); + } + + public Set> getInterfaces() { + return interfaces; + } + + public Set> getSpi() { + return spi; + } + + public Set> getHelpers() { + return helpers; + } + + public List getAdvices() { + return advices; + } + + public Method getEnabled() { + return enabled; + } + + public Set getEnabledArgs() { + return enabledArgs; + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/IastExtensionTest.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/IastExtensionTest.java index cf42e18cbd5..ab87e925de6 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/IastExtensionTest.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/IastExtensionTest.java @@ -40,275 +40,251 @@ class IastExtensionTest extends BaseCsiPluginTest { - @TempDir private File buildDir; - private Path targetFolder; - private Path projectFolder; - private Path srcFolder; - - @BeforeEach - void setup() throws Exception { - targetFolder = createFolder("target"); - projectFolder = createFolder("project"); - srcFolder = createFolder("src/main/java"); - } - - private Path createFolder(String folderName) throws IOException { - Path folder = buildDir.toPath().resolve(folderName); - Files.createDirectories(folder); - return folder; - } - - @ParameterizedTest - @CsvSource( - delimiter = '|', - nullValues = "null", - value = { - "datadog.trace.agent.tooling.csi.CallSites | false", - "datadog.trace.api.iast.IastCallSites | true" - }) - void testThatExtensionOnlyAppliesToIastAdvices(String typeName, boolean expected) { - Type type = classNameToType(typeName); - Type[] types = new Type[] {type}; - CallSiteSpecification callSite = mock(CallSiteSpecification.class); - when(callSite.getSpi()).thenReturn(types); - IastExtension extension = new IastExtension(); - - boolean applies = extension.appliesTo(callSite); - - assertEquals(expected, applies); - } - - @Test - void testThatExtensionGeneratesACallSiteWithTelemetry() throws Exception { - Configuration config = mock(Configuration.class); - when(config.getTargetFolder()).thenReturn(targetFolder); - when(config.getSrcFolder()).thenReturn(getCallSiteSrcFolder()); - when(config.getClassPath()).thenReturn(Collections.emptyList()); - CallSiteSpecification spec = buildClassSpecification(IastExtensionCallSite.class); - AdviceGenerator generator = buildAdviceGenerator(buildDir); - CallSiteResult result = generator.generate(spec); - assertTrue(result.isSuccess()); - IastExtension extension = new IastExtension(); - - extension.apply(config, result); - - assertNoErrors(result); - IastExtensionCallSiteAssert asserter = assertCallSites(result.getFile()); - asserter.iastAdvices( - 0, - advice -> { - advice.pointcut( - "javax/servlet/http/HttpServletRequest", - "getHeader", - "(Ljava/lang/String;)Ljava/lang/String;"); - advice.instrumentedMetric( - "IastMetric.INSTRUMENTED_SOURCE", - metric -> { - metric.metricStatements( - "IastMetricCollector.add(IastMetric.INSTRUMENTED_SOURCE, (byte) 3, 1);"); - }); - advice.executedMetric( - "IastMetric.EXECUTED_SOURCE", - metric -> { + @TempDir + private File buildDir; + + private Path targetFolder; + private Path projectFolder; + private Path srcFolder; + + @BeforeEach + void setup() throws Exception { + targetFolder = createFolder("target"); + projectFolder = createFolder("project"); + srcFolder = createFolder("src/main/java"); + } + + private Path createFolder(String folderName) throws IOException { + Path folder = buildDir.toPath().resolve(folderName); + Files.createDirectories(folder); + return folder; + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + nullValues = "null", + value = { + "datadog.trace.agent.tooling.csi.CallSites | false", + "datadog.trace.api.iast.IastCallSites | true" + }) + void testThatExtensionOnlyAppliesToIastAdvices(String typeName, boolean expected) { + Type type = classNameToType(typeName); + Type[] types = new Type[] {type}; + CallSiteSpecification callSite = mock(CallSiteSpecification.class); + when(callSite.getSpi()).thenReturn(types); + IastExtension extension = new IastExtension(); + + boolean applies = extension.appliesTo(callSite); + + assertEquals(expected, applies); + } + + @Test + void testThatExtensionGeneratesACallSiteWithTelemetry() throws Exception { + Configuration config = mock(Configuration.class); + when(config.getTargetFolder()).thenReturn(targetFolder); + when(config.getSrcFolder()).thenReturn(getCallSiteSrcFolder()); + when(config.getClassPath()).thenReturn(Collections.emptyList()); + CallSiteSpecification spec = buildClassSpecification(IastExtensionCallSite.class); + AdviceGenerator generator = buildAdviceGenerator(buildDir); + CallSiteResult result = generator.generate(spec); + assertTrue(result.isSuccess()); + IastExtension extension = new IastExtension(); + + extension.apply(config, result); + + assertNoErrors(result); + IastExtensionCallSiteAssert asserter = assertCallSites(result.getFile()); + asserter.iastAdvices(0, advice -> { + advice.pointcut( + "javax/servlet/http/HttpServletRequest", "getHeader", "(Ljava/lang/String;)Ljava/lang/String;"); + advice.instrumentedMetric("IastMetric.INSTRUMENTED_SOURCE", metric -> { + metric.metricStatements("IastMetricCollector.add(IastMetric.INSTRUMENTED_SOURCE, (byte) 3, 1);"); + }); + advice.executedMetric("IastMetric.EXECUTED_SOURCE", metric -> { metric.metricStatements( - "handler.field(net.bytebuddy.jar.asm.Opcodes.GETSTATIC, \"datadog/trace/api/iast/telemetry/IastMetric\", \"EXECUTED_SOURCE\", \"Ldatadog/trace/api/iast/telemetry/IastMetric;\");", - "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_3);", - "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_1);", - "handler.method(net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC, \"datadog/trace/api/iast/telemetry/IastMetricCollector\", \"add\", \"(Ldatadog/trace/api/iast/telemetry/IastMetric;BI)V\", false);"); - }); + "handler.field(net.bytebuddy.jar.asm.Opcodes.GETSTATIC, \"datadog/trace/api/iast/telemetry/IastMetric\", \"EXECUTED_SOURCE\", \"Ldatadog/trace/api/iast/telemetry/IastMetric;\");", + "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_3);", + "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_1);", + "handler.method(net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC, \"datadog/trace/api/iast/telemetry/IastMetricCollector\", \"add\", \"(Ldatadog/trace/api/iast/telemetry/IastMetric;BI)V\", false);"); + }); }); - asserter.iastAdvices( - 1, - advice -> { - advice.pointcut( - "javax/servlet/http/HttpServletRequest", - "getInputStream", - "()Ljavax/servlet/ServletInputStream;"); - advice.instrumentedMetric( - "IastMetric.INSTRUMENTED_SOURCE", - metric -> { - metric.metricStatements( - "IastMetricCollector.add(IastMetric.INSTRUMENTED_SOURCE, (byte) 127, 1);"); - }); - advice.executedMetric( - "IastMetric.EXECUTED_SOURCE", - metric -> { + asserter.iastAdvices(1, advice -> { + advice.pointcut( + "javax/servlet/http/HttpServletRequest", "getInputStream", "()Ljavax/servlet/ServletInputStream;"); + advice.instrumentedMetric("IastMetric.INSTRUMENTED_SOURCE", metric -> { + metric.metricStatements("IastMetricCollector.add(IastMetric.INSTRUMENTED_SOURCE, (byte) 127, 1);"); + }); + advice.executedMetric("IastMetric.EXECUTED_SOURCE", metric -> { metric.metricStatements( - "handler.field(net.bytebuddy.jar.asm.Opcodes.GETSTATIC, \"datadog/trace/api/iast/telemetry/IastMetric\", \"EXECUTED_SOURCE\", \"Ldatadog/trace/api/iast/telemetry/IastMetric;\");", - "handler.instruction(net.bytebuddy.jar.asm.Opcodes.BIPUSH, 127);", - "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_1);", - "handler.method(net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC, \"datadog/trace/api/iast/telemetry/IastMetricCollector\", \"add\", \"(Ldatadog/trace/api/iast/telemetry/IastMetric;BI)V\", false);"); - }); + "handler.field(net.bytebuddy.jar.asm.Opcodes.GETSTATIC, \"datadog/trace/api/iast/telemetry/IastMetric\", \"EXECUTED_SOURCE\", \"Ldatadog/trace/api/iast/telemetry/IastMetric;\");", + "handler.instruction(net.bytebuddy.jar.asm.Opcodes.BIPUSH, 127);", + "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_1);", + "handler.method(net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC, \"datadog/trace/api/iast/telemetry/IastMetricCollector\", \"add\", \"(Ldatadog/trace/api/iast/telemetry/IastMetric;BI)V\", false);"); + }); }); - asserter.iastAdvices( - 2, - advice -> { - advice.pointcut( - "javax/servlet/ServletRequest", "getReader", "()Ljava/io/BufferedReader;"); - advice.instrumentedMetric( - "IastMetric.INSTRUMENTED_PROPAGATION", - metric -> { + asserter.iastAdvices(2, advice -> { + advice.pointcut("javax/servlet/ServletRequest", "getReader", "()Ljava/io/BufferedReader;"); + advice.instrumentedMetric("IastMetric.INSTRUMENTED_PROPAGATION", metric -> { + metric.metricStatements("IastMetricCollector.add(IastMetric.INSTRUMENTED_PROPAGATION, 1);"); + }); + advice.executedMetric("IastMetric.EXECUTED_PROPAGATION", metric -> { metric.metricStatements( - "IastMetricCollector.add(IastMetric.INSTRUMENTED_PROPAGATION, 1);"); - }); - advice.executedMetric( - "IastMetric.EXECUTED_PROPAGATION", - metric -> { - metric.metricStatements( - "handler.field(net.bytebuddy.jar.asm.Opcodes.GETSTATIC, \"datadog/trace/api/iast/telemetry/IastMetric\", \"EXECUTED_PROPAGATION\", \"Ldatadog/trace/api/iast/telemetry/IastMetric;\");", - "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_1);", - "handler.method(net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC, \"datadog/trace/api/iast/telemetry/IastMetricCollector\", \"add\", \"(Ldatadog/trace/api/iast/telemetry/IastMetric;I)V\", false);"); - }); + "handler.field(net.bytebuddy.jar.asm.Opcodes.GETSTATIC, \"datadog/trace/api/iast/telemetry/IastMetric\", \"EXECUTED_PROPAGATION\", \"Ldatadog/trace/api/iast/telemetry/IastMetric;\");", + "handler.instruction(net.bytebuddy.jar.asm.Opcodes.ICONST_1);", + "handler.method(net.bytebuddy.jar.asm.Opcodes.INVOKESTATIC, \"datadog/trace/api/iast/telemetry/IastMetricCollector\", \"add\", \"(Ldatadog/trace/api/iast/telemetry/IastMetric;I)V\", false);"); + }); }); - } - - private static AdviceGenerator buildAdviceGenerator(File targetFolder) { - return new AdviceGeneratorImpl(targetFolder, pointcutParser()); - } - - private static Path getCallSiteSrcFolder() throws Exception { - File file = new File(Thread.currentThread().getContextClassLoader().getResource("").toURI()); - return file.toPath().resolve("../../../../src/test/java"); - } - - private static ClassOrInterfaceDeclaration parse(File path) throws Exception { - return new JavaParser() - .parse(path) - .getResult() - .get() - .getPrimaryType() - .get() - .asClassOrInterfaceDeclaration(); - } - - private static IastExtensionCallSiteAssert assertCallSites(File generated) { - try { - return new IastExtensionAssertBuilder(generated).build(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - static class IastExtensionCallSiteAssert extends CallSiteAssert { - - IastExtensionCallSiteAssert( - Set> interfaces, - Set> spi, - Set> helpers, - List advices, - Method enabled, - Set enabledArgs) { - super(interfaces, spi, helpers, advices, enabled, enabledArgs); } - public void iastAdvices(int index, Consumer assertions) { - IastExtensionAdviceAssert asserter = (IastExtensionAdviceAssert) advices.get(index); - assertions.accept(asserter); - } - } - - static class IastExtensionAdviceAssert extends AdviceAssert { - - protected IastExtensionMetricAsserter instrumented; - protected IastExtensionMetricAsserter executed; - - IastExtensionAdviceAssert( - String owner, - String method, - String descriptor, - IastExtensionMetricAsserter instrumented, - IastExtensionMetricAsserter executed, - List statements) { - super(null, owner, method, descriptor, statements); - this.instrumented = instrumented; - this.executed = executed; + private static AdviceGenerator buildAdviceGenerator(File targetFolder) { + return new AdviceGeneratorImpl(targetFolder, pointcutParser()); } - public void instrumentedMetric( - String metric, Consumer assertions) { - assertEquals(metric, instrumented.metric); - assertions.accept(instrumented); + private static Path getCallSiteSrcFolder() throws Exception { + File file = new File( + Thread.currentThread().getContextClassLoader().getResource("").toURI()); + return file.toPath().resolve("../../../../src/test/java"); } - public void executedMetric(String metric, Consumer assertions) { - assertEquals(metric, executed.metric); - assertions.accept(executed); + private static ClassOrInterfaceDeclaration parse(File path) throws Exception { + return new JavaParser() + .parse(path) + .getResult() + .get() + .getPrimaryType() + .get() + .asClassOrInterfaceDeclaration(); } - } - - static class IastExtensionMetricAsserter { - protected String metric; - protected List statements; - IastExtensionMetricAsserter(String metric, List statements) { - this.metric = metric; - this.statements = statements; + private static IastExtensionCallSiteAssert assertCallSites(File generated) { + try { + return new IastExtensionAssertBuilder(generated).build(); + } catch (Exception e) { + throw new RuntimeException(e); + } } - public void metricStatements(String... values) { - assertArrayEquals(values, statements.toArray(new String[0])); + static class IastExtensionCallSiteAssert extends CallSiteAssert { + + IastExtensionCallSiteAssert( + Set> interfaces, + Set> spi, + Set> helpers, + List advices, + Method enabled, + Set enabledArgs) { + super(interfaces, spi, helpers, advices, enabled, enabledArgs); + } + + public void iastAdvices(int index, Consumer assertions) { + IastExtensionAdviceAssert asserter = (IastExtensionAdviceAssert) advices.get(index); + assertions.accept(asserter); + } } - } - static class IastExtensionAssertBuilder extends AssertBuilder { - - IastExtensionAssertBuilder(File file) { - super(file); + static class IastExtensionAdviceAssert extends AdviceAssert { + + protected IastExtensionMetricAsserter instrumented; + protected IastExtensionMetricAsserter executed; + + IastExtensionAdviceAssert( + String owner, + String method, + String descriptor, + IastExtensionMetricAsserter instrumented, + IastExtensionMetricAsserter executed, + List statements) { + super(null, owner, method, descriptor, statements); + this.instrumented = instrumented; + this.executed = executed; + } + + public void instrumentedMetric(String metric, Consumer assertions) { + assertEquals(metric, instrumented.metric); + assertions.accept(instrumented); + } + + public void executedMetric(String metric, Consumer assertions) { + assertEquals(metric, executed.metric); + assertions.accept(executed); + } } - @Override - public IastExtensionCallSiteAssert build() { - CallSiteAssert base = super.build(); - return new IastExtensionCallSiteAssert( - base.getInterfaces(), - base.getSpi(), - base.getHelpers(), - base.getAdvices(), - base.getEnabled(), - base.getEnabledArgs()); - } + static class IastExtensionMetricAsserter { + protected String metric; + protected List statements; + + IastExtensionMetricAsserter(String metric, List statements) { + this.metric = metric; + this.statements = statements; + } - @Override - protected List getAdvices(ClassOrInterfaceDeclaration type) { - return getMethodCalls(type.getMethodsByName("accept").get(0)).stream() - .filter(methodCall -> methodCall.getNameAsString().equals("addAdvice")) - .map( - methodCall -> { - String owner = methodCall.getArgument(1).asStringLiteralExpr().asString(); - String method = methodCall.getArgument(2).asStringLiteralExpr().asString(); - String descriptor = methodCall.getArgument(3).asStringLiteralExpr().asString(); - List statements = - methodCall - .getArgument(4) - .asLambdaExpr() - .getBody() - .asBlockStmt() - .getStatements(); - IfStmt instrumentedStmt = statements.get(0).asIfStmt(); - IfStmt executedStmt = statements.get(1).asIfStmt(); - List nonIfStatements = - statements.stream() - .filter(stmt -> !stmt.isIfStmt()) - .map(Object::toString) - .collect(Collectors.toList()); - return new IastExtensionAdviceAssert( - owner, - method, - descriptor, - buildMetricAsserter(instrumentedStmt), - buildMetricAsserter(executedStmt), - nonIfStatements); - }) - .collect(Collectors.toList()); + public void metricStatements(String... values) { + assertArrayEquals(values, statements.toArray(new String[0])); + } } - protected IastExtensionMetricAsserter buildMetricAsserter(IfStmt ifStmt) { - String metric = ifStmt.getCondition().asMethodCallExpr().getScope().get().toString(); - List statements = - ifStmt.getThenStmt().asBlockStmt().getStatements().stream() - .map(Object::toString) - .collect(Collectors.toList()); - return new IastExtensionMetricAsserter(metric, statements); + static class IastExtensionAssertBuilder extends AssertBuilder { + + IastExtensionAssertBuilder(File file) { + super(file); + } + + @Override + public IastExtensionCallSiteAssert build() { + CallSiteAssert base = super.build(); + return new IastExtensionCallSiteAssert( + base.getInterfaces(), + base.getSpi(), + base.getHelpers(), + base.getAdvices(), + base.getEnabled(), + base.getEnabledArgs()); + } + + @Override + protected List getAdvices(ClassOrInterfaceDeclaration type) { + return getMethodCalls(type.getMethodsByName("accept").get(0)).stream() + .filter(methodCall -> methodCall.getNameAsString().equals("addAdvice")) + .map(methodCall -> { + String owner = + methodCall.getArgument(1).asStringLiteralExpr().asString(); + String method = + methodCall.getArgument(2).asStringLiteralExpr().asString(); + String descriptor = + methodCall.getArgument(3).asStringLiteralExpr().asString(); + List statements = methodCall + .getArgument(4) + .asLambdaExpr() + .getBody() + .asBlockStmt() + .getStatements(); + IfStmt instrumentedStmt = statements.get(0).asIfStmt(); + IfStmt executedStmt = statements.get(1).asIfStmt(); + List nonIfStatements = statements.stream() + .filter(stmt -> !stmt.isIfStmt()) + .map(Object::toString) + .collect(Collectors.toList()); + return new IastExtensionAdviceAssert( + owner, + method, + descriptor, + buildMetricAsserter(instrumentedStmt), + buildMetricAsserter(executedStmt), + nonIfStatements); + }) + .collect(Collectors.toList()); + } + + protected IastExtensionMetricAsserter buildMetricAsserter(IfStmt ifStmt) { + String metric = + ifStmt.getCondition().asMethodCallExpr().getScope().get().toString(); + List statements = ifStmt.getThenStmt().asBlockStmt().getStatements().stream() + .map(Object::toString) + .collect(Collectors.toList()); + return new IastExtensionMetricAsserter(metric, statements); + } } - } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastCallSites.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastCallSites.java index f5c2caefa79..cfedf8bf3f9 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastCallSites.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastCallSites.java @@ -2,7 +2,7 @@ public interface IastCallSites { - interface HasTelemetry { - void setVerbosity(Object verbosity); - } + interface HasTelemetry { + void setVerbosity(Object verbosity); + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastExtensionCallSite.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastExtensionCallSite.java index 34bc4a5be9d..b44f26835e9 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastExtensionCallSite.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/IastExtensionCallSite.java @@ -9,30 +9,26 @@ @CallSite(spi = IastCallSites.class) public class IastExtensionCallSite { - @Source(SourceTypes.REQUEST_HEADER_NAME) - @CallSite.After( - "java.lang.String javax.servlet.http.HttpServletRequest.getHeader(java.lang.String)") - public static String afterGetHeader( - @CallSite.This final HttpServletRequest self, - @CallSite.Argument final String headerName, - @CallSite.Return final String headerValue) { - return headerValue; - } + @Source(SourceTypes.REQUEST_HEADER_NAME) + @CallSite.After("java.lang.String javax.servlet.http.HttpServletRequest.getHeader(java.lang.String)") + public static String afterGetHeader( + @CallSite.This final HttpServletRequest self, + @CallSite.Argument final String headerName, + @CallSite.Return final String headerValue) { + return headerValue; + } - @Source(SourceTypes.REQUEST_BODY) - @CallSite.After( - "javax.servlet.ServletInputStream javax.servlet.http.HttpServletRequest.getInputStream()") - public static ServletInputStream afterGetInputStream( - @CallSite.This final HttpServletRequest self, - @CallSite.Return final ServletInputStream stream) { - return stream; - } + @Source(SourceTypes.REQUEST_BODY) + @CallSite.After("javax.servlet.ServletInputStream javax.servlet.http.HttpServletRequest.getInputStream()") + public static ServletInputStream afterGetInputStream( + @CallSite.This final HttpServletRequest self, @CallSite.Return final ServletInputStream stream) { + return stream; + } - @Propagation - @CallSite.After("java.io.BufferedReader javax.servlet.ServletRequest.getReader()") - public static BufferedReader afterGetReader( - @CallSite.This final ServletRequest self, - @CallSite.Return final BufferedReader bufferedReader) { - return bufferedReader; - } + @Propagation + @CallSite.After("java.io.BufferedReader javax.servlet.ServletRequest.getReader()") + public static BufferedReader afterGetReader( + @CallSite.This final ServletRequest self, @CallSite.Return final BufferedReader bufferedReader) { + return bufferedReader; + } } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/Source.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/Source.java index 5cb5503fd9a..0d0434f3ddb 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/Source.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/Source.java @@ -8,6 +8,6 @@ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface Source { - /** Source type */ - byte value(); + /** Source type */ + byte value(); } diff --git a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/SourceTypes.java b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/SourceTypes.java index e5e2d68f773..90de4b27a9c 100644 --- a/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/SourceTypes.java +++ b/buildSrc/call-site-instrumentation-plugin/src/test/java/datadog/trace/plugin/csi/impl/ext/tests/SourceTypes.java @@ -2,7 +2,7 @@ public class SourceTypes { - public static final byte REQUEST_HEADER_NAME = 3; + public static final byte REQUEST_HEADER_NAME = 3; - public static final byte REQUEST_BODY = 127; + public static final byte REQUEST_BODY = 127; } diff --git a/buildSrc/modifiable-config-agent/src/main/java/datadog/trace/agent/test/config/ModifiableConfigAgent.java b/buildSrc/modifiable-config-agent/src/main/java/datadog/trace/agent/test/config/ModifiableConfigAgent.java index dcc420ac8ba..66d1adbdb6e 100644 --- a/buildSrc/modifiable-config-agent/src/main/java/datadog/trace/agent/test/config/ModifiableConfigAgent.java +++ b/buildSrc/modifiable-config-agent/src/main/java/datadog/trace/agent/test/config/ModifiableConfigAgent.java @@ -21,59 +21,57 @@ */ public final class ModifiableConfigAgent { - private static final String CONFIG = "datadog/trace/api/Config"; - private static final String INST_CONFIG = "datadog/trace/api/InstrumenterConfig"; - private static final String INSTANCE = "INSTANCE"; + private static final String CONFIG = "datadog/trace/api/Config"; + private static final String INST_CONFIG = "datadog/trace/api/InstrumenterConfig"; + private static final String INSTANCE = "INSTANCE"; - private ModifiableConfigAgent() {} + private ModifiableConfigAgent() {} - public static void premain(String args, Instrumentation inst) { - inst.addTransformer(new InstanceFieldRewriter(), false); - } - - static final class InstanceFieldRewriter implements ClassFileTransformer { - @Override - public byte[] transform( - ClassLoader loader, - String className, - Class classBeingRedefined, - ProtectionDomain protectionDomain, - byte[] classfileBuffer) - throws IllegalClassFormatException { - if (className == null) { - return null; - } - if (!CONFIG.equals(className) && !INST_CONFIG.equals(className)) { - return null; - } - try { - ClassReader reader = new ClassReader(classfileBuffer); - ClassWriter writer = new ClassWriter(reader, 0); - reader.accept(new InstanceFieldClassVisitor(writer), 0); - return writer.toByteArray(); - } catch (Throwable t) { - System.err.println("[modifiable-config-agent] failed to rewrite " + className + ": " + t); - return null; - } + public static void premain(String args, Instrumentation inst) { + inst.addTransformer(new InstanceFieldRewriter(), false); } - } - static final class InstanceFieldClassVisitor extends ClassVisitor { - InstanceFieldClassVisitor(ClassVisitor cv) { - super(Opcodes.ASM9, cv); + static final class InstanceFieldRewriter implements ClassFileTransformer { + @Override + public byte[] transform( + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer) + throws IllegalClassFormatException { + if (className == null) { + return null; + } + if (!CONFIG.equals(className) && !INST_CONFIG.equals(className)) { + return null; + } + try { + ClassReader reader = new ClassReader(classfileBuffer); + ClassWriter writer = new ClassWriter(reader, 0); + reader.accept(new InstanceFieldClassVisitor(writer), 0); + return writer.toByteArray(); + } catch (Throwable t) { + System.err.println("[modifiable-config-agent] failed to rewrite " + className + ": " + t); + return null; + } + } } - @Override - public FieldVisitor visitField( - int access, String name, String descriptor, String signature, Object value) { - if (INSTANCE.equals(name)) { - int rewritten = - (access & ~(Opcodes.ACC_PRIVATE | Opcodes.ACC_PROTECTED | Opcodes.ACC_FINAL)) - | Opcodes.ACC_PUBLIC - | Opcodes.ACC_VOLATILE; - return super.visitField(rewritten, name, descriptor, signature, value); - } - return super.visitField(access, name, descriptor, signature, value); + static final class InstanceFieldClassVisitor extends ClassVisitor { + InstanceFieldClassVisitor(ClassVisitor cv) { + super(Opcodes.ASM9, cv); + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { + if (INSTANCE.equals(name)) { + int rewritten = (access & ~(Opcodes.ACC_PRIVATE | Opcodes.ACC_PROTECTED | Opcodes.ACC_FINAL)) + | Opcodes.ACC_PUBLIC + | Opcodes.ACC_VOLATILE; + return super.visitField(rewritten, name, descriptor, signature, value); + } + return super.visitField(access, name, descriptor, signature, value); + } } - } } diff --git a/communication/src/main/java/datadog/communication/BackendApi.java b/communication/src/main/java/datadog/communication/BackendApi.java index aa4385d6d75..ff81288e82c 100644 --- a/communication/src/main/java/datadog/communication/BackendApi.java +++ b/communication/src/main/java/datadog/communication/BackendApi.java @@ -10,11 +10,11 @@ /** API for posting HTTP requests to backend */ public interface BackendApi { - T post( - String uri, - RequestBody requestBody, - IOThrowingFunction responseParser, - @Nullable OkHttpUtils.CustomListener requestListener, - boolean requestCompression) - throws IOException; + T post( + String uri, + RequestBody requestBody, + IOThrowingFunction responseParser, + @Nullable OkHttpUtils.CustomListener requestListener, + boolean requestCompression) + throws IOException; } diff --git a/communication/src/main/java/datadog/communication/BackendApiFactory.java b/communication/src/main/java/datadog/communication/BackendApiFactory.java index c1b471ebfc8..6c39b79a4e0 100644 --- a/communication/src/main/java/datadog/communication/BackendApiFactory.java +++ b/communication/src/main/java/datadog/communication/BackendApiFactory.java @@ -14,137 +14,132 @@ public class BackendApiFactory { - private static final Logger log = LoggerFactory.getLogger(BackendApiFactory.class); + private static final Logger log = LoggerFactory.getLogger(BackendApiFactory.class); - private final Config config; - private final SharedCommunicationObjects sharedCommunicationObjects; + private final Config config; + private final SharedCommunicationObjects sharedCommunicationObjects; - public BackendApiFactory(Config config, SharedCommunicationObjects sharedCommunicationObjects) { - this.config = config; - this.sharedCommunicationObjects = sharedCommunicationObjects; - } + public BackendApiFactory(Config config, SharedCommunicationObjects sharedCommunicationObjects) { + this.config = config; + this.sharedCommunicationObjects = sharedCommunicationObjects; + } + + public @Nullable BackendApi createBackendApi(Intake intake) { + return createBackendApi(intake, true); + } - public @Nullable BackendApi createBackendApi(Intake intake) { - return createBackendApi(intake, true); - } + public @Nullable BackendApi createBackendApi(Intake intake, boolean responseCompression) { + if (intake.isAgentlessEnabled(config)) { + return createDirectIntakeApi(intake, responseCompression); + } + + BackendApi backendApi = createEvpProxyApi(intake, responseCompression); + if (backendApi == null) { + log.warn("Cannot create backend API client since agentless mode is disabled, " + + "and agent does not support EVP proxy"); + } + return backendApi; + } - public @Nullable BackendApi createBackendApi(Intake intake, boolean responseCompression) { - if (intake.isAgentlessEnabled(config)) { - return createDirectIntakeApi(intake, responseCompression); + /** Creates an authenticated API client that sends data directly to a Datadog intake. */ + public BackendApi createDirectIntakeApi(Intake intake) { + return createDirectIntakeApi(intake, true); } - BackendApi backendApi = createEvpProxyApi(intake, responseCompression); - if (backendApi == null) { - log.warn( - "Cannot create backend API client since agentless mode is disabled, " - + "and agent does not support EVP proxy"); + /** Creates an authenticated API client that sends data directly to a Datadog intake. */ + public BackendApi createDirectIntakeApi(Intake intake, boolean responseCompression) { + return createDirectIntakeApi(intake, responseCompression, true); } - return backendApi; - } - - /** Creates an authenticated API client that sends data directly to a Datadog intake. */ - public BackendApi createDirectIntakeApi(Intake intake) { - return createDirectIntakeApi(intake, true); - } - - /** Creates an authenticated API client that sends data directly to a Datadog intake. */ - public BackendApi createDirectIntakeApi(Intake intake, boolean responseCompression) { - return createDirectIntakeApi(intake, responseCompression, true); - } - - /** Creates an authenticated API client that sends data directly to a Datadog intake. */ - public BackendApi createDirectIntakeApi( - Intake intake, boolean responseCompression, boolean followRedirects) { - HttpUrl agentlessUrl = buildDirectIntakeUrl(intake, config); - String apiKey = config.getApiKey(); - if (apiKey == null || apiKey.isEmpty()) { - throw new FatalAgentMisconfigurationError( - "Agentless mode is enabled and API key is not set. Please set DD_API_KEY"); + + /** Creates an authenticated API client that sends data directly to a Datadog intake. */ + public BackendApi createDirectIntakeApi(Intake intake, boolean responseCompression, boolean followRedirects) { + HttpUrl agentlessUrl = buildDirectIntakeUrl(intake, config); + String apiKey = config.getApiKey(); + if (apiKey == null || apiKey.isEmpty()) { + throw new FatalAgentMisconfigurationError( + "Agentless mode is enabled and API key is not set. Please set DD_API_KEY"); + } + String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); + return new IntakeApi( + agentlessUrl, + apiKey, + traceId, + retryPolicyFactory(), + directIntakeHttpClient(sharedCommunicationObjects.getIntakeHttpClient(), followRedirects), + responseCompression); } - String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); - return new IntakeApi( - agentlessUrl, - apiKey, - traceId, - retryPolicyFactory(), - directIntakeHttpClient(sharedCommunicationObjects.getIntakeHttpClient(), followRedirects), - responseCompression); - } - - static OkHttpClient directIntakeHttpClient( - final OkHttpClient intakeHttpClient, final boolean followRedirects) { - if (followRedirects) { - return intakeHttpClient; + + static OkHttpClient directIntakeHttpClient(final OkHttpClient intakeHttpClient, final boolean followRedirects) { + if (followRedirects) { + return intakeHttpClient; + } + return intakeHttpClient.newBuilder().followRedirects(false).build(); } - return intakeHttpClient.newBuilder().followRedirects(false).build(); - } - private static HttpUrl buildDirectIntakeUrl(Intake intake, Config config) { - if (intake != Intake.EVENT_PLATFORM) { - return HttpUrl.get(intake.getAgentlessUrl(config)); + private static HttpUrl buildDirectIntakeUrl(Intake intake, Config config) { + if (intake != Intake.EVENT_PLATFORM) { + return HttpUrl.get(intake.getAgentlessUrl(config)); + } + return buildEventPlatformIntakeUrl(config.getSite()); } - return buildEventPlatformIntakeUrl(config.getSite()); - } - static HttpUrl buildEventPlatformIntakeUrl(String site) { - if (site == null || site.isEmpty()) { - throw new IllegalArgumentException("Invalid Datadog site"); + static HttpUrl buildEventPlatformIntakeUrl(String site) { + if (site == null || site.isEmpty()) { + throw new IllegalArgumentException("Invalid Datadog site"); + } + + String expectedHost = Intake.EVENT_PLATFORM.getUrlPrefix() + "." + site; + HttpUrl url = new HttpUrl.Builder() + .scheme("https") + .host(expectedHost) + .addPathSegment("api") + .addPathSegment(Intake.EVENT_PLATFORM.getVersion()) + .addPathSegment("") + .build(); + if (!url.host().equalsIgnoreCase(expectedHost)) { + throw new IllegalArgumentException("Invalid Datadog site"); + } + return url; } - String expectedHost = Intake.EVENT_PLATFORM.getUrlPrefix() + "." + site; - HttpUrl url = - new HttpUrl.Builder() - .scheme("https") - .host(expectedHost) - .addPathSegment("api") - .addPathSegment(Intake.EVENT_PLATFORM.getVersion()) - .addPathSegment("") - .build(); - if (!url.host().equalsIgnoreCase(expectedHost)) { - throw new IllegalArgumentException("Invalid Datadog site"); + /** Creates an API client that uses the specified retry policy with a compatible local proxy. */ + public @Nullable BackendApi createEvpProxyApi(Intake intake) { + return createEvpProxyApi(intake, true); } - return url; - } - - /** Creates an API client that uses the specified retry policy with a compatible local proxy. */ - public @Nullable BackendApi createEvpProxyApi(Intake intake) { - return createEvpProxyApi(intake, true); - } - - /** Creates an API client that sends data through a compatible local EVP proxy. */ - public @Nullable BackendApi createEvpProxyApi(Intake intake, boolean responseCompression) { - return createEvpProxyApi(intake, responseCompression, retryPolicyFactory()); - } - - /** Creates an API client that sends data through a compatible local EVP proxy. */ - public @Nullable BackendApi createEvpProxyApi( - Intake intake, boolean responseCompression, HttpRetryPolicy.Factory retryPolicyFactory) { - DDAgentFeaturesDiscovery featuresDiscovery = - sharedCommunicationObjects.featuresDiscovery(config); - featuresDiscovery.discoverIfOutdated(); - if (!featuresDiscovery.supportsEvpProxy()) { - return null; + + /** Creates an API client that sends data through a compatible local EVP proxy. */ + public @Nullable BackendApi createEvpProxyApi(Intake intake, boolean responseCompression) { + return createEvpProxyApi(intake, responseCompression, retryPolicyFactory()); + } + + /** Creates an API client that sends data through a compatible local EVP proxy. */ + public @Nullable BackendApi createEvpProxyApi( + Intake intake, boolean responseCompression, HttpRetryPolicy.Factory retryPolicyFactory) { + DDAgentFeaturesDiscovery featuresDiscovery = sharedCommunicationObjects.featuresDiscovery(config); + featuresDiscovery.discoverIfOutdated(); + if (!featuresDiscovery.supportsEvpProxy()) { + return null; + } + String evpProxyEndpoint = featuresDiscovery.getEvpProxyEndpoint(); + + String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); + log.debug( + "Creating EVP proxy client for {} using endpoint {} with responseCompression={}", + intake, + evpProxyEndpoint, + responseCompression); + HttpUrl evpProxyUrl = sharedCommunicationObjects.agentUrl.resolve(evpProxyEndpoint); + String subdomain = intake.getUrlPrefix(); + return new EvpProxyApi( + traceId, + evpProxyUrl, + subdomain, + retryPolicyFactory, + sharedCommunicationObjects.agentHttpClient, + responseCompression); + } + + private static HttpRetryPolicy.Factory retryPolicyFactory() { + return new HttpRetryPolicy.Factory(5, 100, 2.0, true); } - String evpProxyEndpoint = featuresDiscovery.getEvpProxyEndpoint(); - - String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); - log.debug( - "Creating EVP proxy client for {} using endpoint {} with responseCompression={}", - intake, - evpProxyEndpoint, - responseCompression); - HttpUrl evpProxyUrl = sharedCommunicationObjects.agentUrl.resolve(evpProxyEndpoint); - String subdomain = intake.getUrlPrefix(); - return new EvpProxyApi( - traceId, - evpProxyUrl, - subdomain, - retryPolicyFactory, - sharedCommunicationObjects.agentHttpClient, - responseCompression); - } - - private static HttpRetryPolicy.Factory retryPolicyFactory() { - return new HttpRetryPolicy.Factory(5, 100, 2.0, true); - } } diff --git a/communication/src/main/java/datadog/communication/EvpProxy.java b/communication/src/main/java/datadog/communication/EvpProxy.java index c2453bccb25..5f0b482ff62 100644 --- a/communication/src/main/java/datadog/communication/EvpProxy.java +++ b/communication/src/main/java/datadog/communication/EvpProxy.java @@ -3,13 +3,13 @@ /** Shared EVP proxy constants. */ public final class EvpProxy { - public static final String SUBDOMAIN_HEADER = "X-Datadog-EVP-Subdomain"; + public static final String SUBDOMAIN_HEADER = "X-Datadog-EVP-Subdomain"; - /** - * Default SDK-side target for uncompressed EVP request bodies. Writers may split batches at or - * below this size to keep Agent proxy requests comfortably bounded. - */ - public static final int PAYLOAD_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; + /** + * Default SDK-side target for uncompressed EVP request bodies. Writers may split batches at or + * below this size to keep Agent proxy requests comfortably bounded. + */ + public static final int PAYLOAD_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; - private EvpProxy() {} + private EvpProxy() {} } diff --git a/communication/src/main/java/datadog/communication/EvpProxyApi.java b/communication/src/main/java/datadog/communication/EvpProxyApi.java index 8bb768b7e4e..68fb691f7ef 100644 --- a/communication/src/main/java/datadog/communication/EvpProxyApi.java +++ b/communication/src/main/java/datadog/communication/EvpProxyApi.java @@ -17,105 +17,103 @@ /** API that uses DD Agent as a proxy to post request to backend. */ public class EvpProxyApi implements BackendApi { - private static final Logger log = LoggerFactory.getLogger(EvpProxyApi.class); - - private static final String API_VERSION = "v2"; - private static final String X_DATADOG_TRACE_ID_HEADER = "x-datadog-trace-id"; - private static final String X_DATADOG_PARENT_ID_HEADER = "x-datadog-parent-id"; - private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding"; - private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; - private static final String GZIP_ENCODING = "gzip"; - private static final String IDENTITY_ENCODING = "identity"; - - private final String traceId; - private final HttpRetryPolicy.Factory retryPolicyFactory; - private final HttpUrl evpProxyUrl; - private final String subdomain; - private final OkHttpClient httpClient; - private final boolean responseCompression; - - public EvpProxyApi( - String traceId, - HttpUrl evpProxyUrl, - String subdomain, - HttpRetryPolicy.Factory retryPolicyFactory, - OkHttpClient httpClient, - boolean responseCompression) { - this.traceId = traceId; - this.evpProxyUrl = evpProxyUrl.resolve("api/" + API_VERSION + "/"); - this.subdomain = subdomain; - this.retryPolicyFactory = retryPolicyFactory; - this.httpClient = httpClient; - this.responseCompression = responseCompression; - } - - @Override - public T post( - String uri, - RequestBody requestBody, - IOThrowingFunction responseParser, - @Nullable OkHttpUtils.CustomListener requestListener, - boolean requestCompression) - throws IOException { - final HttpUrl url = evpProxyUrl.resolve(uri); - - Request.Builder requestBuilder = - new Request.Builder() - .url(url) - .addHeader(EvpProxy.SUBDOMAIN_HEADER, subdomain) - .addHeader(X_DATADOG_TRACE_ID_HEADER, traceId) - .addHeader(X_DATADOG_PARENT_ID_HEADER, traceId); - - if (requestListener != null) { - requestBuilder.tag(OkHttpUtils.CustomListener.class, requestListener); + private static final Logger log = LoggerFactory.getLogger(EvpProxyApi.class); + + private static final String API_VERSION = "v2"; + private static final String X_DATADOG_TRACE_ID_HEADER = "x-datadog-trace-id"; + private static final String X_DATADOG_PARENT_ID_HEADER = "x-datadog-parent-id"; + private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding"; + private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; + private static final String GZIP_ENCODING = "gzip"; + private static final String IDENTITY_ENCODING = "identity"; + + private final String traceId; + private final HttpRetryPolicy.Factory retryPolicyFactory; + private final HttpUrl evpProxyUrl; + private final String subdomain; + private final OkHttpClient httpClient; + private final boolean responseCompression; + + public EvpProxyApi( + String traceId, + HttpUrl evpProxyUrl, + String subdomain, + HttpRetryPolicy.Factory retryPolicyFactory, + OkHttpClient httpClient, + boolean responseCompression) { + this.traceId = traceId; + this.evpProxyUrl = evpProxyUrl.resolve("api/" + API_VERSION + "/"); + this.subdomain = subdomain; + this.retryPolicyFactory = retryPolicyFactory; + this.httpClient = httpClient; + this.responseCompression = responseCompression; } - if (requestCompression) { - requestBuilder.addHeader(CONTENT_ENCODING_HEADER, GZIP_ENCODING); - } - - // OkHttp's BridgeInterceptor adds a transparent Accept-Encoding: gzip when the caller does not - // set one. Set the header explicitly on both paths so responseCompression=false actually - // suppresses gzip negotiation on the wire. - if (responseCompression) { - requestBuilder.addHeader(ACCEPT_ENCODING_HEADER, GZIP_ENCODING); - } else { - requestBuilder.addHeader(ACCEPT_ENCODING_HEADER, IDENTITY_ENCODING); - } - - final Request request = requestBuilder.post(requestBody).build(); - log.debug( - "Posting EVP request to {} with responseCompression={} requestCompression={}", - url, - responseCompression, - requestCompression); - - try (okhttp3.Response response = - OkHttpUtils.sendWithRetries(httpClient, retryPolicyFactory, request)) { - if (response.isSuccessful()) { - log.debug("Request to {} returned successful response: {}", uri, response.code()); + @Override + public T post( + String uri, + RequestBody requestBody, + IOThrowingFunction responseParser, + @Nullable OkHttpUtils.CustomListener requestListener, + boolean requestCompression) + throws IOException { + final HttpUrl url = evpProxyUrl.resolve(uri); + + Request.Builder requestBuilder = new Request.Builder() + .url(url) + .addHeader(EvpProxy.SUBDOMAIN_HEADER, subdomain) + .addHeader(X_DATADOG_TRACE_ID_HEADER, traceId) + .addHeader(X_DATADOG_PARENT_ID_HEADER, traceId); + + if (requestListener != null) { + requestBuilder.tag(OkHttpUtils.CustomListener.class, requestListener); + } - InputStream responseBodyStream = response.body().byteStream(); + if (requestCompression) { + requestBuilder.addHeader(CONTENT_ENCODING_HEADER, GZIP_ENCODING); + } - String contentEncoding = response.header(CONTENT_ENCODING_HEADER); - if (GZIP_ENCODING.equalsIgnoreCase(contentEncoding)) { - log.debug("Response content encoding is {}, unzipping response body", contentEncoding); - responseBodyStream = new GZIPInputStream(responseBodyStream); + // OkHttp's BridgeInterceptor adds a transparent Accept-Encoding: gzip when the caller does not + // set one. Set the header explicitly on both paths so responseCompression=false actually + // suppresses gzip negotiation on the wire. + if (responseCompression) { + requestBuilder.addHeader(ACCEPT_ENCODING_HEADER, GZIP_ENCODING); + } else { + requestBuilder.addHeader(ACCEPT_ENCODING_HEADER, IDENTITY_ENCODING); } - return responseParser.apply(responseBodyStream); - } else { - throw new HttpResponseException( - response.code(), - "Request to " - + uri - + " returned error response " - + response.code() - + ": " - + response.message() - + "; " - + (response.body() != null ? response.body().string() : "")); - } + final Request request = requestBuilder.post(requestBody).build(); + log.debug( + "Posting EVP request to {} with responseCompression={} requestCompression={}", + url, + responseCompression, + requestCompression); + + try (okhttp3.Response response = OkHttpUtils.sendWithRetries(httpClient, retryPolicyFactory, request)) { + if (response.isSuccessful()) { + log.debug("Request to {} returned successful response: {}", uri, response.code()); + + InputStream responseBodyStream = response.body().byteStream(); + + String contentEncoding = response.header(CONTENT_ENCODING_HEADER); + if (GZIP_ENCODING.equalsIgnoreCase(contentEncoding)) { + log.debug("Response content encoding is {}, unzipping response body", contentEncoding); + responseBodyStream = new GZIPInputStream(responseBodyStream); + } + + return responseParser.apply(responseBodyStream); + } else { + throw new HttpResponseException( + response.code(), + "Request to " + + uri + + " returned error response " + + response.code() + + ": " + + response.message() + + "; " + + (response.body() != null ? response.body().string() : "")); + } + } } - } } diff --git a/communication/src/main/java/datadog/communication/HttpResponseException.java b/communication/src/main/java/datadog/communication/HttpResponseException.java index ac9b62cdb2d..b74e066f420 100644 --- a/communication/src/main/java/datadog/communication/HttpResponseException.java +++ b/communication/src/main/java/datadog/communication/HttpResponseException.java @@ -5,14 +5,14 @@ /** An HTTP request failed with a non-success response. */ public final class HttpResponseException extends IOException { - private final int statusCode; + private final int statusCode; - public HttpResponseException(final int statusCode, final String message) { - super(message); - this.statusCode = statusCode; - } + public HttpResponseException(final int statusCode, final String message) { + super(message); + this.statusCode = statusCode; + } - public int getStatusCode() { - return statusCode; - } + public int getStatusCode() { + return statusCode; + } } diff --git a/communication/src/main/java/datadog/communication/IntakeApi.java b/communication/src/main/java/datadog/communication/IntakeApi.java index 1a6f3f91bc7..a880dc7d413 100644 --- a/communication/src/main/java/datadog/communication/IntakeApi.java +++ b/communication/src/main/java/datadog/communication/IntakeApi.java @@ -17,93 +17,89 @@ /** API for posting HTTP requests directly to backend, without the need for DD Agent */ public class IntakeApi implements BackendApi { - private static final Logger log = LoggerFactory.getLogger(IntakeApi.class); + private static final Logger log = LoggerFactory.getLogger(IntakeApi.class); - private static final String DD_API_KEY_HEADER = "dd-api-key"; - private static final String X_DATADOG_TRACE_ID_HEADER = "x-datadog-trace-id"; - private static final String X_DATADOG_PARENT_ID_HEADER = "x-datadog-parent-id"; - private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding"; - private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; - private static final String GZIP_ENCODING = "gzip"; - private static final String IDENTITY_ENCODING = "identity"; + private static final String DD_API_KEY_HEADER = "dd-api-key"; + private static final String X_DATADOG_TRACE_ID_HEADER = "x-datadog-trace-id"; + private static final String X_DATADOG_PARENT_ID_HEADER = "x-datadog-parent-id"; + private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding"; + private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; + private static final String GZIP_ENCODING = "gzip"; + private static final String IDENTITY_ENCODING = "identity"; - private final String apiKey; - private final String traceId; - private final HttpRetryPolicy.Factory retryPolicyFactory; - private final boolean responseCompression; - private final HttpUrl hostUrl; - private final OkHttpClient httpClient; + private final String apiKey; + private final String traceId; + private final HttpRetryPolicy.Factory retryPolicyFactory; + private final boolean responseCompression; + private final HttpUrl hostUrl; + private final OkHttpClient httpClient; - public IntakeApi( - HttpUrl hostUrl, - String apiKey, - String traceId, - HttpRetryPolicy.Factory retryPolicyFactory, - OkHttpClient httpClient, - boolean responseCompression) { - this.hostUrl = hostUrl; - this.apiKey = apiKey; - this.traceId = traceId; - this.retryPolicyFactory = retryPolicyFactory; - this.responseCompression = responseCompression; - this.httpClient = httpClient; - } + public IntakeApi( + HttpUrl hostUrl, + String apiKey, + String traceId, + HttpRetryPolicy.Factory retryPolicyFactory, + OkHttpClient httpClient, + boolean responseCompression) { + this.hostUrl = hostUrl; + this.apiKey = apiKey; + this.traceId = traceId; + this.retryPolicyFactory = retryPolicyFactory; + this.responseCompression = responseCompression; + this.httpClient = httpClient; + } - @Override - public T post( - String uri, - RequestBody requestBody, - IOThrowingFunction responseParser, - @Nullable OkHttpUtils.CustomListener requestListener, - boolean requestCompression) - throws IOException { - HttpUrl url = hostUrl.resolve(uri); - Request.Builder requestBuilder = - new Request.Builder() - .url(url) - .post(requestBody) - .addHeader(DD_API_KEY_HEADER, apiKey) - .addHeader(X_DATADOG_TRACE_ID_HEADER, traceId) - .addHeader(X_DATADOG_PARENT_ID_HEADER, traceId); + @Override + public T post( + String uri, + RequestBody requestBody, + IOThrowingFunction responseParser, + @Nullable OkHttpUtils.CustomListener requestListener, + boolean requestCompression) + throws IOException { + HttpUrl url = hostUrl.resolve(uri); + Request.Builder requestBuilder = new Request.Builder() + .url(url) + .post(requestBody) + .addHeader(DD_API_KEY_HEADER, apiKey) + .addHeader(X_DATADOG_TRACE_ID_HEADER, traceId) + .addHeader(X_DATADOG_PARENT_ID_HEADER, traceId); - if (requestListener != null) { - requestBuilder.tag(OkHttpUtils.CustomListener.class, requestListener); - } + if (requestListener != null) { + requestBuilder.tag(OkHttpUtils.CustomListener.class, requestListener); + } - if (requestCompression) { - requestBuilder.addHeader(CONTENT_ENCODING_HEADER, GZIP_ENCODING); - } + if (requestCompression) { + requestBuilder.addHeader(CONTENT_ENCODING_HEADER, GZIP_ENCODING); + } - // OkHttp adds Accept-Encoding: gzip when this header is absent. Always set the header so a - // caller can disable response compression on the wire. - requestBuilder.addHeader( - ACCEPT_ENCODING_HEADER, responseCompression ? GZIP_ENCODING : IDENTITY_ENCODING); + // OkHttp adds Accept-Encoding: gzip when this header is absent. Always set the header so a + // caller can disable response compression on the wire. + requestBuilder.addHeader(ACCEPT_ENCODING_HEADER, responseCompression ? GZIP_ENCODING : IDENTITY_ENCODING); - Request request = requestBuilder.build(); - try (okhttp3.Response response = - OkHttpUtils.sendWithRetries(httpClient, retryPolicyFactory, request)) { - if (response.isSuccessful()) { - log.debug("Request to {} returned successful response: {}", uri, response.code()); - InputStream responseBodyStream = response.body().byteStream(); + Request request = requestBuilder.build(); + try (okhttp3.Response response = OkHttpUtils.sendWithRetries(httpClient, retryPolicyFactory, request)) { + if (response.isSuccessful()) { + log.debug("Request to {} returned successful response: {}", uri, response.code()); + InputStream responseBodyStream = response.body().byteStream(); - String contentEncoding = response.header(CONTENT_ENCODING_HEADER); - if (GZIP_ENCODING.equalsIgnoreCase(contentEncoding)) { - log.debug("Response content encoding is {}, unzipping response body", contentEncoding); - responseBodyStream = new GZIPInputStream(responseBodyStream); - } + String contentEncoding = response.header(CONTENT_ENCODING_HEADER); + if (GZIP_ENCODING.equalsIgnoreCase(contentEncoding)) { + log.debug("Response content encoding is {}, unzipping response body", contentEncoding); + responseBodyStream = new GZIPInputStream(responseBodyStream); + } - return responseParser.apply(responseBodyStream); - } else { - throw new IOException( - "Request to " - + uri - + " returned error response " - + response.code() - + ": " - + response.message() - + "; " - + (response.body() != null ? response.body().string() : "")); - } + return responseParser.apply(responseBodyStream); + } else { + throw new IOException("Request to " + + uri + + " returned error response " + + response.code() + + ": " + + response.message() + + "; " + + (response.body() != null ? response.body().string() : "")); + } + } } - } } diff --git a/communication/src/main/java/datadog/communication/ddagent/AgentVersion.java b/communication/src/main/java/datadog/communication/ddagent/AgentVersion.java index f3b30cb7baf..2d32e9f611f 100644 --- a/communication/src/main/java/datadog/communication/ddagent/AgentVersion.java +++ b/communication/src/main/java/datadog/communication/ddagent/AgentVersion.java @@ -5,68 +5,68 @@ public class AgentVersion { - private static final Logger log = LoggerFactory.getLogger(AgentVersion.class); + private static final Logger log = LoggerFactory.getLogger(AgentVersion.class); - /** - * Checks if the given version string represents a version that is below the specified major, - * minor, and patch version. - * - * @param version the version string to check (e.g., "7.64.0") - * @param maxMajor maximum major version (exclusive) - * @param maxMinor maximum minor version (exclusive) - * @param maxPatch maximum patch version (exclusive) - * @return true if version is below the specified maximum (or if not parseable), false otherwise - */ - public static boolean isVersionBelow(String version, int maxMajor, int maxMinor, int maxPatch) { - if (version == null || version.isEmpty()) { - return true; - } + /** + * Checks if the given version string represents a version that is below the specified major, + * minor, and patch version. + * + * @param version the version string to check (e.g., "7.64.0") + * @param maxMajor maximum major version (exclusive) + * @param maxMinor maximum minor version (exclusive) + * @param maxPatch maximum patch version (exclusive) + * @return true if version is below the specified maximum (or if not parseable), false otherwise + */ + public static boolean isVersionBelow(String version, int maxMajor, int maxMinor, int maxPatch) { + if (version == null || version.isEmpty()) { + return true; + } - try { - // Parse version string in format "major.minor.patch" (e.g., "7.65.0") - // Assumes the 'version' is below if it can't be parsed. - int majorDot = version.indexOf('.'); - if (majorDot == -1) { - return true; - } + try { + // Parse version string in format "major.minor.patch" (e.g., "7.65.0") + // Assumes the 'version' is below if it can't be parsed. + int majorDot = version.indexOf('.'); + if (majorDot == -1) { + return true; + } - int major = Integer.parseInt(version.substring(0, majorDot)); + int major = Integer.parseInt(version.substring(0, majorDot)); - if (major < maxMajor) { - return true; - } else if (major > maxMajor) { - return false; - } + if (major < maxMajor) { + return true; + } else if (major > maxMajor) { + return false; + } - // major == maxMajor - int minorDot = version.indexOf('.', majorDot + 1); - if (minorDot == -1) { - return true; - } + // major == maxMajor + int minorDot = version.indexOf('.', majorDot + 1); + if (minorDot == -1) { + return true; + } - int minor = Integer.parseInt(version.substring(majorDot + 1, minorDot)); - if (minor < maxMinor) { - return true; - } else if (minor > maxMinor) { - return false; - } + int minor = Integer.parseInt(version.substring(majorDot + 1, minorDot)); + if (minor < maxMinor) { + return true; + } else if (minor > maxMinor) { + return false; + } - // major == maxMajor && minor == maxMinor - // Find end of patch version (may have suffix like "-rc.1") - int patchEnd = minorDot + 1; - while (patchEnd < version.length() && Character.isDigit(version.charAt(patchEnd))) { - patchEnd++; - } + // major == maxMajor && minor == maxMinor + // Find end of patch version (may have suffix like "-rc.1") + int patchEnd = minorDot + 1; + while (patchEnd < version.length() && Character.isDigit(version.charAt(patchEnd))) { + patchEnd++; + } - int patch = Integer.parseInt(version.substring(minorDot + 1, patchEnd)); - if (patch != maxPatch) { - return patch < maxPatch; - } else { - // If there's a suffix (like "-rc.1"), consider it below the non-suffixed version - return patchEnd < version.length(); - } - } catch (NumberFormatException | IndexOutOfBoundsException e) { - return true; + int patch = Integer.parseInt(version.substring(minorDot + 1, patchEnd)); + if (patch != maxPatch) { + return patch < maxPatch; + } else { + // If there's a suffix (like "-rc.1"), consider it below the non-suffixed version + return patchEnd < version.length(); + } + } catch (NumberFormatException | IndexOutOfBoundsException e) { + return true; + } } - } } diff --git a/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java b/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java index 16be2e84b98..3290ed95711 100644 --- a/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java +++ b/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java @@ -35,438 +35,423 @@ public class DDAgentFeaturesDiscovery implements DroppingPolicy { - private static final Logger log = LoggerFactory.getLogger(DDAgentFeaturesDiscovery.class); - - private static final JsonAdapter> RESPONSE_ADAPTER = - new Moshi.Builder() - .build() - .adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); - - // Currently all the endpoints that we probe expect a msgpack body of an array of arrays, v3/v4 - // arbitrary size and v5 two elements, so let's give them a two element array of empty arrays - private static final byte[] PROBE_MESSAGE = { - (byte) FIXARRAY | 2, (byte) FIXARRAY, (byte) FIXARRAY - }; - - public static final String V03_ENDPOINT = "v0.3/traces"; - public static final String V04_ENDPOINT = "v0.4/traces"; - public static final String V05_ENDPOINT = "v0.5/traces"; - public static final String V1_ENDPOINT = "v1.0/traces"; - - public static final String V06_METRICS_ENDPOINT = "v0.6/stats"; - public static final String V07_CONFIG_ENDPOINT = "v0.7/config"; - - public static final String V01_DATASTREAMS_ENDPOINT = "v0.1/pipeline_stats"; - - public static final String V2_EVP_PROXY_ENDPOINT = "evp_proxy/v2/"; - public static final String V4_EVP_PROXY_ENDPOINT = "evp_proxy/v4/"; - - public static final String DATADOG_AGENT_STATE = "Datadog-Agent-State"; - - public static final String DEBUGGER_ENDPOINT_V1 = "debugger/v1/input"; - public static final String DEBUGGER_ENDPOINT_V2 = "debugger/v2/input"; - public static final String DEBUGGER_DIAGNOSTICS_ENDPOINT = "debugger/v1/diagnostics"; - - public static final String TELEMETRY_PROXY_ENDPOINT = "telemetry/proxy/"; - - private static final long MIN_FEATURE_DISCOVERY_INTERVAL_MILLIS = 60 * 1000; - - private final OkHttpClient client; - private final HttpUrl agentBaseUrl; - private final Recording discoveryTimer; - private final ProtocolVersion protocolVersion; - private final String[] metricsEndpoints = {V06_METRICS_ENDPOINT}; - private final String[] configEndpoints = {V07_CONFIG_ENDPOINT}; - private final boolean metricsEnabled; - private final boolean ignoreAgentVersionForStats; - private final String[] dataStreamsEndpoints = {V01_DATASTREAMS_ENDPOINT}; - // ordered from most recent to least recent, as the logic will stick with the first one that is - // available - private final String[] evpProxyEndpoints = {V4_EVP_PROXY_ENDPOINT, V2_EVP_PROXY_ENDPOINT}; - private final String[] telemetryProxyEndpoints = {TELEMETRY_PROXY_ENDPOINT}; - - private static class State { - String traceEndpoint; - String metricsEndpoint; - String dataStreamsEndpoint; - boolean supportsLongRunning; - boolean supportsClientSideStats; - boolean supportsDropping; - String state; - String configEndpoint; - String debuggerLogEndpoint; - String debuggerSnapshotEndpoint; - String debuggerDiagnosticsEndpoint; - String evpProxyEndpoint; - String version; - String telemetryProxyEndpoint; - Set peerTags = emptySet(); - String orgPropagationMarker; - long lastTimeDiscovered; - } - - private volatile State discoveryState; - - public DDAgentFeaturesDiscovery( - OkHttpClient client, - Monitoring monitoring, - HttpUrl agentUrl, - ProtocolVersion protocolVersion, - boolean metricsEnabled, - boolean ignoreAgentVersionForStats) { - this.client = client; - this.agentBaseUrl = agentUrl; - this.metricsEnabled = metricsEnabled; - this.ignoreAgentVersionForStats = ignoreAgentVersionForStats; - this.protocolVersion = protocolVersion != null ? protocolVersion : V0_4; - this.discoveryTimer = monitoring.newTimer("trace.agent.discovery.time"); - this.discoveryState = new State(); - } - - /** Run feature discovery, unconditionally. */ - public void discover() { - discoverIfOutdated(0); - } - - /** Run feature discovery, if it was not run recently. */ - public void discoverIfOutdated() { - discoverIfOutdated(getFeaturesDiscoveryMinDelayMillis()); - } - - protected long getFeaturesDiscoveryMinDelayMillis() { - return MIN_FEATURE_DISCOVERY_INTERVAL_MILLIS; - } - - private synchronized void discoverIfOutdated(final long maxElapsedMs) { - final long now = System.currentTimeMillis(); - final long elapsed = now - discoveryState.lastTimeDiscovered; - if (elapsed > maxElapsedMs) { - final State newState = new State(); - doDiscovery(newState); - newState.lastTimeDiscovered = now; - // swap atomically states - discoveryState = newState; - } - } - - private void doDiscovery(State newState) { - // 1. try to fetch info about the agent, if the endpoint is there - // 2. try to parse the response, if it can be parsed, finish - // 3. fallback if the endpoint couldn't be found or the response couldn't be parsed - try (Recording recording = discoveryTimer.start()) { - boolean fallback = true; - final Request request = - prepareRequest(agentBaseUrl.resolve("info"), emptyMap()).get().build(); - try (Response response = client.newCall(request).execute()) { - if (response.isSuccessful()) { - processInfoResponseHeaders(response); - fallback = !processInfoResponse(newState, response.body().string()); + private static final Logger log = LoggerFactory.getLogger(DDAgentFeaturesDiscovery.class); + + private static final JsonAdapter> RESPONSE_ADAPTER = + new Moshi.Builder().build().adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); + + // Currently all the endpoints that we probe expect a msgpack body of an array of arrays, v3/v4 + // arbitrary size and v5 two elements, so let's give them a two element array of empty arrays + private static final byte[] PROBE_MESSAGE = {(byte) FIXARRAY | 2, (byte) FIXARRAY, (byte) FIXARRAY}; + + public static final String V03_ENDPOINT = "v0.3/traces"; + public static final String V04_ENDPOINT = "v0.4/traces"; + public static final String V05_ENDPOINT = "v0.5/traces"; + public static final String V1_ENDPOINT = "v1.0/traces"; + + public static final String V06_METRICS_ENDPOINT = "v0.6/stats"; + public static final String V07_CONFIG_ENDPOINT = "v0.7/config"; + + public static final String V01_DATASTREAMS_ENDPOINT = "v0.1/pipeline_stats"; + + public static final String V2_EVP_PROXY_ENDPOINT = "evp_proxy/v2/"; + public static final String V4_EVP_PROXY_ENDPOINT = "evp_proxy/v4/"; + + public static final String DATADOG_AGENT_STATE = "Datadog-Agent-State"; + + public static final String DEBUGGER_ENDPOINT_V1 = "debugger/v1/input"; + public static final String DEBUGGER_ENDPOINT_V2 = "debugger/v2/input"; + public static final String DEBUGGER_DIAGNOSTICS_ENDPOINT = "debugger/v1/diagnostics"; + + public static final String TELEMETRY_PROXY_ENDPOINT = "telemetry/proxy/"; + + private static final long MIN_FEATURE_DISCOVERY_INTERVAL_MILLIS = 60 * 1000; + + private final OkHttpClient client; + private final HttpUrl agentBaseUrl; + private final Recording discoveryTimer; + private final ProtocolVersion protocolVersion; + private final String[] metricsEndpoints = {V06_METRICS_ENDPOINT}; + private final String[] configEndpoints = {V07_CONFIG_ENDPOINT}; + private final boolean metricsEnabled; + private final boolean ignoreAgentVersionForStats; + private final String[] dataStreamsEndpoints = {V01_DATASTREAMS_ENDPOINT}; + // ordered from most recent to least recent, as the logic will stick with the first one that is + // available + private final String[] evpProxyEndpoints = {V4_EVP_PROXY_ENDPOINT, V2_EVP_PROXY_ENDPOINT}; + private final String[] telemetryProxyEndpoints = {TELEMETRY_PROXY_ENDPOINT}; + + private static class State { + String traceEndpoint; + String metricsEndpoint; + String dataStreamsEndpoint; + boolean supportsLongRunning; + boolean supportsClientSideStats; + boolean supportsDropping; + String state; + String configEndpoint; + String debuggerLogEndpoint; + String debuggerSnapshotEndpoint; + String debuggerDiagnosticsEndpoint; + String evpProxyEndpoint; + String version; + String telemetryProxyEndpoint; + Set peerTags = emptySet(); + String orgPropagationMarker; + long lastTimeDiscovered; + } + + private volatile State discoveryState; + + public DDAgentFeaturesDiscovery( + OkHttpClient client, + Monitoring monitoring, + HttpUrl agentUrl, + ProtocolVersion protocolVersion, + boolean metricsEnabled, + boolean ignoreAgentVersionForStats) { + this.client = client; + this.agentBaseUrl = agentUrl; + this.metricsEnabled = metricsEnabled; + this.ignoreAgentVersionForStats = ignoreAgentVersionForStats; + this.protocolVersion = protocolVersion != null ? protocolVersion : V0_4; + this.discoveryTimer = monitoring.newTimer("trace.agent.discovery.time"); + this.discoveryState = new State(); + } + + /** Run feature discovery, unconditionally. */ + public void discover() { + discoverIfOutdated(0); + } + + /** Run feature discovery, if it was not run recently. */ + public void discoverIfOutdated() { + discoverIfOutdated(getFeaturesDiscoveryMinDelayMillis()); + } + + protected long getFeaturesDiscoveryMinDelayMillis() { + return MIN_FEATURE_DISCOVERY_INTERVAL_MILLIS; + } + + private synchronized void discoverIfOutdated(final long maxElapsedMs) { + final long now = System.currentTimeMillis(); + final long elapsed = now - discoveryState.lastTimeDiscovered; + if (elapsed > maxElapsedMs) { + final State newState = new State(); + doDiscovery(newState); + newState.lastTimeDiscovered = now; + // swap atomically states + discoveryState = newState; } - } catch (Throwable error) { - errorQueryingEndpoint("info", error); - } - if (fallback) { - newState.supportsClientSideStats = false; - newState.supportsLongRunning = false; - log.debug("Falling back to probing, client dropping will be disabled"); - // disable metrics unless the info endpoint is present, which prevents - // sending metrics to 7.26.0, which has a bug in reporting metric origin - newState.metricsEndpoint = null; - } - - // don't want to rewire the traces pipeline - if (null == newState.traceEndpoint) { - newState.traceEndpoint = probeTracesEndpoint(newState, protocolVersion.endpointsToProbe()); - } else if (newState.state == null || newState.state.isEmpty()) { - // Still need to probe so that state is correctly assigned - probeTracesEndpoint(newState, singletonList(newState.traceEndpoint)); - } - } - - if (log.isDebugEnabled()) { - log.debug( - "discovered traceEndpoint={}, metricsEndpoint={}, supportsDropping={}, supportsClientSideStats={}, supportsLongRunning={}, dataStreamsEndpoint={}, configEndpoint={}, logEndpoint={}, snapshotEndpoint={}, diagnosticsEndpoint={}, evpProxyEndpoint={}, telemetryProxyEndpoint={}", - newState.traceEndpoint, - newState.metricsEndpoint, - newState.supportsDropping, - newState.supportsClientSideStats, - newState.supportsLongRunning, - newState.dataStreamsEndpoint, - newState.configEndpoint, - newState.debuggerLogEndpoint, - newState.debuggerSnapshotEndpoint, - newState.debuggerDiagnosticsEndpoint, - newState.evpProxyEndpoint, - newState.telemetryProxyEndpoint); - } - } - - private String probeTracesEndpoint(State newState, List endpoints) { - for (String candidate : endpoints) { - try (Response response = - client - .newCall( - prepareRequest(agentBaseUrl.resolve(candidate), emptyMap()) - .put(msgpackRequestBodyOf(singletonList(ByteBuffer.wrap(PROBE_MESSAGE)))) - .build()) - .execute()) { - if (response.code() != 404) { - newState.state = response.header(DATADOG_AGENT_STATE); - return candidate; + } + + private void doDiscovery(State newState) { + // 1. try to fetch info about the agent, if the endpoint is there + // 2. try to parse the response, if it can be parsed, finish + // 3. fallback if the endpoint couldn't be found or the response couldn't be parsed + try (Recording recording = discoveryTimer.start()) { + boolean fallback = true; + final Request request = prepareRequest(agentBaseUrl.resolve("info"), emptyMap()) + .get() + .build(); + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful()) { + processInfoResponseHeaders(response); + fallback = !processInfoResponse(newState, response.body().string()); + } + } catch (Throwable error) { + errorQueryingEndpoint("info", error); + } + if (fallback) { + newState.supportsClientSideStats = false; + newState.supportsLongRunning = false; + log.debug("Falling back to probing, client dropping will be disabled"); + // disable metrics unless the info endpoint is present, which prevents + // sending metrics to 7.26.0, which has a bug in reporting metric origin + newState.metricsEndpoint = null; + } + + // don't want to rewire the traces pipeline + if (null == newState.traceEndpoint) { + newState.traceEndpoint = probeTracesEndpoint(newState, protocolVersion.endpointsToProbe()); + } else if (newState.state == null || newState.state.isEmpty()) { + // Still need to probe so that state is correctly assigned + probeTracesEndpoint(newState, singletonList(newState.traceEndpoint)); + } } - } catch (Throwable e) { - errorQueryingEndpoint(candidate, e); - } - } - return V03_ENDPOINT; - } - - private void processInfoResponseHeaders(Response response) { - String newContainerTagsHash = response.header(DATADOG_CONTAINER_TAGS_HASH); - if (newContainerTagsHash != null) { - ContainerInfo containerInfo = ContainerInfo.get(); - synchronized (containerInfo) { - if (!newContainerTagsHash.equals(containerInfo.getContainerTagsHash())) { - containerInfo.setContainerTagsHash(newContainerTagsHash); - BaseHash.recalcBaseHash(newContainerTagsHash); + + if (log.isDebugEnabled()) { + log.debug( + "discovered traceEndpoint={}, metricsEndpoint={}, supportsDropping={}, supportsClientSideStats={}, supportsLongRunning={}, dataStreamsEndpoint={}, configEndpoint={}, logEndpoint={}, snapshotEndpoint={}, diagnosticsEndpoint={}, evpProxyEndpoint={}, telemetryProxyEndpoint={}", + newState.traceEndpoint, + newState.metricsEndpoint, + newState.supportsDropping, + newState.supportsClientSideStats, + newState.supportsLongRunning, + newState.dataStreamsEndpoint, + newState.configEndpoint, + newState.debuggerLogEndpoint, + newState.debuggerSnapshotEndpoint, + newState.debuggerDiagnosticsEndpoint, + newState.evpProxyEndpoint, + newState.telemetryProxyEndpoint); } - } } - } - @SuppressWarnings("unchecked") - private boolean processInfoResponse(State newState, String response) { - try { - Map map = RESPONSE_ADAPTER.fromJson(response); - final Object endpointObj = map.get("endpoints"); - if (!(endpointObj instanceof List)) { - log.debug("Bad response received from the agent. Ignoring it."); - return false; - } - discoverStatsDPort(map); - newState.version = (String) map.get("version"); - Set endpoints = new HashSet<>((List) endpointObj); - - String foundMetricsEndpoint = null; - if (metricsEnabled) { - for (String endpoint : metricsEndpoints) { - if (containsEndpoint(endpoints, endpoint)) { - foundMetricsEndpoint = endpoint; - break; - } + private String probeTracesEndpoint(State newState, List endpoints) { + for (String candidate : endpoints) { + try (Response response = client.newCall(prepareRequest(agentBaseUrl.resolve(candidate), emptyMap()) + .put(msgpackRequestBodyOf(singletonList(ByteBuffer.wrap(PROBE_MESSAGE)))) + .build()) + .execute()) { + if (response.code() != 404) { + newState.state = response.header(DATADOG_AGENT_STATE); + return candidate; + } + } catch (Throwable e) { + errorQueryingEndpoint(candidate, e); + } } - } + return V03_ENDPOINT; + } - // This is done outside of the loop to set metricsEndpoint to null if not found - newState.metricsEndpoint = foundMetricsEndpoint; + private void processInfoResponseHeaders(Response response) { + String newContainerTagsHash = response.header(DATADOG_CONTAINER_TAGS_HASH); + if (newContainerTagsHash != null) { + ContainerInfo containerInfo = ContainerInfo.get(); + synchronized (containerInfo) { + if (!newContainerTagsHash.equals(containerInfo.getContainerTagsHash())) { + containerInfo.setContainerTagsHash(newContainerTagsHash); + BaseHash.recalcBaseHash(newContainerTagsHash); + } + } + } + } - for (String endpoint : protocolVersion.endpointsToProbe()) { - if (containsEndpoint(endpoints, endpoint)) { - newState.traceEndpoint = endpoint; - break; + @SuppressWarnings("unchecked") + private boolean processInfoResponse(State newState, String response) { + try { + Map map = RESPONSE_ADAPTER.fromJson(response); + final Object endpointObj = map.get("endpoints"); + if (!(endpointObj instanceof List)) { + log.debug("Bad response received from the agent. Ignoring it."); + return false; + } + discoverStatsDPort(map); + newState.version = (String) map.get("version"); + Set endpoints = new HashSet<>((List) endpointObj); + + String foundMetricsEndpoint = null; + if (metricsEnabled) { + for (String endpoint : metricsEndpoints) { + if (containsEndpoint(endpoints, endpoint)) { + foundMetricsEndpoint = endpoint; + break; + } + } + } + + // This is done outside of the loop to set metricsEndpoint to null if not found + newState.metricsEndpoint = foundMetricsEndpoint; + + for (String endpoint : protocolVersion.endpointsToProbe()) { + if (containsEndpoint(endpoints, endpoint)) { + newState.traceEndpoint = endpoint; + break; + } + } + + for (String endpoint : configEndpoints) { + if (containsEndpoint(endpoints, endpoint)) { + newState.configEndpoint = endpoint; + break; + } + } + + setDebuggerEndpoints(newState, endpoints); + + for (String endpoint : dataStreamsEndpoints) { + if (containsEndpoint(endpoints, endpoint)) { + newState.dataStreamsEndpoint = endpoint; + break; + } + } + + for (String endpoint : evpProxyEndpoints) { + if (containsEndpoint(endpoints, endpoint)) { + newState.evpProxyEndpoint = endpoint; + break; + } + } + + for (String endpoint : telemetryProxyEndpoints) { + if (containsEndpoint(endpoints, endpoint)) { + newState.telemetryProxyEndpoint = endpoint; + break; + } + } + + newState.supportsLongRunning = Boolean.TRUE.equals(map.getOrDefault("long_running_spans", false)); + + if (metricsEnabled) { + Object canDrop = map.get("client_drop_p0s"); + newState.supportsDropping = null != canDrop + && ("true".equalsIgnoreCase(String.valueOf(canDrop)) || Boolean.TRUE.equals(canDrop)); + + newState.supportsClientSideStats = newState.supportsDropping + && (ignoreAgentVersionForStats || !AgentVersion.isVersionBelow(newState.version, 7, 65, 0)); + + Object peer_tags = map.get("peer_tags"); + newState.peerTags = peer_tags instanceof List + ? unmodifiableSet(new HashSet<>((List) peer_tags)) + : emptySet(); + } + Object opm = map.get("org_prop_marker"); + newState.orgPropagationMarker = (opm instanceof String) ? (String) opm : null; + try { + newState.state = Strings.sha256(response); + } catch (Throwable ex) { + log.debug("Failed to hash trace agent /info response. Will probe {}", newState.traceEndpoint, ex); + } + return true; + } catch (Throwable error) { + log.debug("Error parsing trace agent /info response", error); } - } + return false; + } - for (String endpoint : configEndpoints) { - if (containsEndpoint(endpoints, endpoint)) { - newState.configEndpoint = endpoint; - break; + private static void setDebuggerEndpoints(State newState, Set endpoints) { + // both debugger v2 and diagnostics endpoints are forwarding events to the DEBUGGER intake + // because older agents support diagnostics from DD agent 7.49 + if (containsEndpoint(endpoints, DEBUGGER_ENDPOINT_V2)) { + newState.debuggerLogEndpoint = DEBUGGER_ENDPOINT_V2; + } else if (containsEndpoint(endpoints, DEBUGGER_DIAGNOSTICS_ENDPOINT)) { + newState.debuggerLogEndpoint = DEBUGGER_DIAGNOSTICS_ENDPOINT; + } else if (containsEndpoint(endpoints, DEBUGGER_ENDPOINT_V1)) { + newState.debuggerLogEndpoint = DEBUGGER_ENDPOINT_V1; + } + if (containsEndpoint(endpoints, DEBUGGER_ENDPOINT_V2)) { + newState.debuggerSnapshotEndpoint = DEBUGGER_ENDPOINT_V2; + } else if (containsEndpoint(endpoints, DEBUGGER_DIAGNOSTICS_ENDPOINT)) { + newState.debuggerSnapshotEndpoint = DEBUGGER_DIAGNOSTICS_ENDPOINT; } - } + if (containsEndpoint(endpoints, DEBUGGER_DIAGNOSTICS_ENDPOINT)) { + newState.debuggerDiagnosticsEndpoint = DEBUGGER_DIAGNOSTICS_ENDPOINT; + } + } - setDebuggerEndpoints(newState, endpoints); + private static boolean containsEndpoint(Set endpoints, String endpoint) { + return endpoints.contains(endpoint) || endpoints.contains("/" + endpoint); + } - for (String endpoint : dataStreamsEndpoints) { - if (containsEndpoint(endpoints, endpoint)) { - newState.dataStreamsEndpoint = endpoint; - break; + @SuppressWarnings("unchecked") + private static void discoverStatsDPort(final Map info) { + try { + Map config = (Map) info.get("config"); + if (config == null) { + log.debug("config missing from trace agent /info response"); + return; + } + final Object statsdPortObj = config.get("statsd_port"); + if (statsdPortObj == null) { + log.debug("statsd_port missing from trace agent /info response"); + return; + } + int statsdPort = ((Number) statsdPortObj).intValue(); + DDAgentStatsDClientManager.setDefaultStatsDPort(statsdPort); + } catch (Throwable ex) { + log.debug("statsd_port missing from trace agent /info response", ex); } - } + } - for (String endpoint : evpProxyEndpoints) { - if (containsEndpoint(endpoints, endpoint)) { - newState.evpProxyEndpoint = endpoint; - break; - } - } + public boolean supportsMetrics() { + return metricsEnabled && null != discoveryState.metricsEndpoint && discoveryState.supportsClientSideStats; + } - for (String endpoint : telemetryProxyEndpoints) { - if (containsEndpoint(endpoints, endpoint)) { - newState.telemetryProxyEndpoint = endpoint; - break; - } - } - - newState.supportsLongRunning = - Boolean.TRUE.equals(map.getOrDefault("long_running_spans", false)); - - if (metricsEnabled) { - Object canDrop = map.get("client_drop_p0s"); - newState.supportsDropping = - null != canDrop - && ("true".equalsIgnoreCase(String.valueOf(canDrop)) - || Boolean.TRUE.equals(canDrop)); - - newState.supportsClientSideStats = - newState.supportsDropping - && (ignoreAgentVersionForStats - || !AgentVersion.isVersionBelow(newState.version, 7, 65, 0)); - - Object peer_tags = map.get("peer_tags"); - newState.peerTags = - peer_tags instanceof List - ? unmodifiableSet(new HashSet<>((List) peer_tags)) - : emptySet(); - } - Object opm = map.get("org_prop_marker"); - newState.orgPropagationMarker = (opm instanceof String) ? (String) opm : null; - try { - newState.state = Strings.sha256(response); - } catch (Throwable ex) { - log.debug( - "Failed to hash trace agent /info response. Will probe {}", newState.traceEndpoint, ex); - } - return true; - } catch (Throwable error) { - log.debug("Error parsing trace agent /info response", error); - } - return false; - } - - private static void setDebuggerEndpoints(State newState, Set endpoints) { - // both debugger v2 and diagnostics endpoints are forwarding events to the DEBUGGER intake - // because older agents support diagnostics from DD agent 7.49 - if (containsEndpoint(endpoints, DEBUGGER_ENDPOINT_V2)) { - newState.debuggerLogEndpoint = DEBUGGER_ENDPOINT_V2; - } else if (containsEndpoint(endpoints, DEBUGGER_DIAGNOSTICS_ENDPOINT)) { - newState.debuggerLogEndpoint = DEBUGGER_DIAGNOSTICS_ENDPOINT; - } else if (containsEndpoint(endpoints, DEBUGGER_ENDPOINT_V1)) { - newState.debuggerLogEndpoint = DEBUGGER_ENDPOINT_V1; - } - if (containsEndpoint(endpoints, DEBUGGER_ENDPOINT_V2)) { - newState.debuggerSnapshotEndpoint = DEBUGGER_ENDPOINT_V2; - } else if (containsEndpoint(endpoints, DEBUGGER_DIAGNOSTICS_ENDPOINT)) { - newState.debuggerSnapshotEndpoint = DEBUGGER_DIAGNOSTICS_ENDPOINT; - } - if (containsEndpoint(endpoints, DEBUGGER_DIAGNOSTICS_ENDPOINT)) { - newState.debuggerDiagnosticsEndpoint = DEBUGGER_DIAGNOSTICS_ENDPOINT; - } - } - - private static boolean containsEndpoint(Set endpoints, String endpoint) { - return endpoints.contains(endpoint) || endpoints.contains("/" + endpoint); - } - - @SuppressWarnings("unchecked") - private static void discoverStatsDPort(final Map info) { - try { - Map config = (Map) info.get("config"); - if (config == null) { - log.debug("config missing from trace agent /info response"); - return; - } - final Object statsdPortObj = config.get("statsd_port"); - if (statsdPortObj == null) { - log.debug("statsd_port missing from trace agent /info response"); - return; - } - int statsdPort = ((Number) statsdPortObj).intValue(); - DDAgentStatsDClientManager.setDefaultStatsDPort(statsdPort); - } catch (Throwable ex) { - log.debug("statsd_port missing from trace agent /info response", ex); - } - } - - public boolean supportsMetrics() { - return metricsEnabled - && null != discoveryState.metricsEndpoint - && discoveryState.supportsClientSideStats; - } - - public boolean supportsDebugger() { - return discoveryState.debuggerLogEndpoint != null; - } - - public String getDebuggerSnapshotEndpoint() { - return discoveryState.debuggerSnapshotEndpoint; - } - - public String getDebuggerLogEndpoint() { - return discoveryState.debuggerLogEndpoint; - } - - public boolean supportsDebuggerDiagnostics() { - return discoveryState.debuggerDiagnosticsEndpoint != null; - } - - public boolean supportsLongRunning() { - return discoveryState.supportsLongRunning; - } - - public Set peerTags() { - return discoveryState.peerTags; - } - - public String getMetricsEndpoint() { - return discoveryState.metricsEndpoint; - } - - public String getTraceEndpoint() { - return discoveryState.traceEndpoint; - } - - public String getDataStreamsEndpoint() { - return discoveryState.dataStreamsEndpoint; - } - - public String getEvpProxyEndpoint() { - return discoveryState.evpProxyEndpoint; - } - - public HttpUrl buildUrl(String endpoint) { - return agentBaseUrl.resolve(endpoint); - } - - public boolean supportsDataStreams() { - return discoveryState.dataStreamsEndpoint != null; - } - - public boolean supportsEvpProxy() { - return discoveryState.evpProxyEndpoint != null; - } - - public boolean supportsContentEncodingHeadersWithEvpProxy() { - // content encoding headers are supported in /v4 and above - final String evpProxyEndpoint = discoveryState.evpProxyEndpoint; - return evpProxyEndpoint != null && V4_EVP_PROXY_ENDPOINT.compareTo(evpProxyEndpoint) <= 0; - } - - public String getConfigEndpoint() { - return discoveryState.configEndpoint; - } - - public String getVersion() { - return discoveryState.version; - } - - private void errorQueryingEndpoint(String endpoint, Throwable t) { - log.debug(LogCollector.EXCLUDE_TELEMETRY, "Error querying {} at {}", endpoint, agentBaseUrl, t); - } - - public String state() { - return discoveryState.state; - } - - public String getOrgPropagationMarker() { - return discoveryState.orgPropagationMarker; - } - - @Override - public boolean active() { - return supportsMetrics(); - } - - public boolean supportsTelemetryProxy() { - return discoveryState.telemetryProxyEndpoint != null; - } + public boolean supportsDebugger() { + return discoveryState.debuggerLogEndpoint != null; + } + + public String getDebuggerSnapshotEndpoint() { + return discoveryState.debuggerSnapshotEndpoint; + } + + public String getDebuggerLogEndpoint() { + return discoveryState.debuggerLogEndpoint; + } + + public boolean supportsDebuggerDiagnostics() { + return discoveryState.debuggerDiagnosticsEndpoint != null; + } + + public boolean supportsLongRunning() { + return discoveryState.supportsLongRunning; + } + + public Set peerTags() { + return discoveryState.peerTags; + } + + public String getMetricsEndpoint() { + return discoveryState.metricsEndpoint; + } + + public String getTraceEndpoint() { + return discoveryState.traceEndpoint; + } + + public String getDataStreamsEndpoint() { + return discoveryState.dataStreamsEndpoint; + } + + public String getEvpProxyEndpoint() { + return discoveryState.evpProxyEndpoint; + } + + public HttpUrl buildUrl(String endpoint) { + return agentBaseUrl.resolve(endpoint); + } + + public boolean supportsDataStreams() { + return discoveryState.dataStreamsEndpoint != null; + } + + public boolean supportsEvpProxy() { + return discoveryState.evpProxyEndpoint != null; + } + + public boolean supportsContentEncodingHeadersWithEvpProxy() { + // content encoding headers are supported in /v4 and above + final String evpProxyEndpoint = discoveryState.evpProxyEndpoint; + return evpProxyEndpoint != null && V4_EVP_PROXY_ENDPOINT.compareTo(evpProxyEndpoint) <= 0; + } + + public String getConfigEndpoint() { + return discoveryState.configEndpoint; + } + + public String getVersion() { + return discoveryState.version; + } + + private void errorQueryingEndpoint(String endpoint, Throwable t) { + log.debug(LogCollector.EXCLUDE_TELEMETRY, "Error querying {} at {}", endpoint, agentBaseUrl, t); + } + + public String state() { + return discoveryState.state; + } + + public String getOrgPropagationMarker() { + return discoveryState.orgPropagationMarker; + } + + @Override + public boolean active() { + return supportsMetrics(); + } + + public boolean supportsTelemetryProxy() { + return discoveryState.telemetryProxyEndpoint != null; + } } diff --git a/communication/src/main/java/datadog/communication/ddagent/DroppingPolicy.java b/communication/src/main/java/datadog/communication/ddagent/DroppingPolicy.java index 2e6e26141ce..acc2b9ffaf5 100644 --- a/communication/src/main/java/datadog/communication/ddagent/DroppingPolicy.java +++ b/communication/src/main/java/datadog/communication/ddagent/DroppingPolicy.java @@ -2,15 +2,15 @@ public interface DroppingPolicy { - DroppingPolicy DISABLED = new DisabledDroppingPolicy(); + DroppingPolicy DISABLED = new DisabledDroppingPolicy(); - boolean active(); + boolean active(); - class DisabledDroppingPolicy implements DroppingPolicy { + class DisabledDroppingPolicy implements DroppingPolicy { - @Override - public boolean active() { - return false; + @Override + public boolean active() { + return false; + } } - } } diff --git a/communication/src/main/java/datadog/communication/ddagent/ExternalAgentLauncher.java b/communication/src/main/java/datadog/communication/ddagent/ExternalAgentLauncher.java index 8eea13de224..aa12e57dcd1 100644 --- a/communication/src/main/java/datadog/communication/ddagent/ExternalAgentLauncher.java +++ b/communication/src/main/java/datadog/communication/ddagent/ExternalAgentLauncher.java @@ -14,101 +14,96 @@ import org.slf4j.LoggerFactory; public class ExternalAgentLauncher implements Closeable { - private static final Logger log = LoggerFactory.getLogger(ExternalAgentLauncher.class); - - private static final ProcessBuilder.Redirect DISCARD = - ProcessBuilder.Redirect.to(new File((OperatingSystem.isWindows() ? "NUL" : "/dev/null"))); - - private ProcessSupervisor traceProcessSupervisor; - private ProcessSupervisor dogStatsDProcessSupervisor; - - public ExternalAgentLauncher(Config config) { - if (config.isAzureAppServices()) { - if (config.getTraceAgentPath() != null) { - ProcessBuilder traceProcessBuilder = new ProcessBuilder(config.getTraceAgentPath()); - traceProcessBuilder.redirectOutput(DISCARD); - traceProcessBuilder.redirectError(DISCARD); - traceProcessBuilder.command().addAll(config.getTraceAgentArgs()); - - traceProcessSupervisor = - new ProcessSupervisor( - "trace-agent", traceProcessBuilder, healthCheck(config.getAgentNamedPipe())); - } else { - log.warn("Trace agent path not set. Will not start trace agent process"); - } - - if (config.getDogStatsDPath() != null) { - ProcessBuilder dogStatsDProcessBuilder = new ProcessBuilder(config.getDogStatsDPath()); - dogStatsDProcessBuilder.redirectOutput(DISCARD); - dogStatsDProcessBuilder.redirectError(DISCARD); - dogStatsDProcessBuilder.command().addAll(config.getDogStatsDArgs()); - - dogStatsDProcessSupervisor = - new ProcessSupervisor( - "dogstatsd", dogStatsDProcessBuilder, healthCheck(config.getDogStatsDNamedPipe())); - } else { - log.warn("DogStatsD path not set. Will not start DogStatsD process"); - } + private static final Logger log = LoggerFactory.getLogger(ExternalAgentLauncher.class); + + private static final ProcessBuilder.Redirect DISCARD = + ProcessBuilder.Redirect.to(new File((OperatingSystem.isWindows() ? "NUL" : "/dev/null"))); + + private ProcessSupervisor traceProcessSupervisor; + private ProcessSupervisor dogStatsDProcessSupervisor; + + public ExternalAgentLauncher(Config config) { + if (config.isAzureAppServices()) { + if (config.getTraceAgentPath() != null) { + ProcessBuilder traceProcessBuilder = new ProcessBuilder(config.getTraceAgentPath()); + traceProcessBuilder.redirectOutput(DISCARD); + traceProcessBuilder.redirectError(DISCARD); + traceProcessBuilder.command().addAll(config.getTraceAgentArgs()); + + traceProcessSupervisor = new ProcessSupervisor( + "trace-agent", traceProcessBuilder, healthCheck(config.getAgentNamedPipe())); + } else { + log.warn("Trace agent path not set. Will not start trace agent process"); + } + + if (config.getDogStatsDPath() != null) { + ProcessBuilder dogStatsDProcessBuilder = new ProcessBuilder(config.getDogStatsDPath()); + dogStatsDProcessBuilder.redirectOutput(DISCARD); + dogStatsDProcessBuilder.redirectError(DISCARD); + dogStatsDProcessBuilder.command().addAll(config.getDogStatsDArgs()); + + dogStatsDProcessSupervisor = new ProcessSupervisor( + "dogstatsd", dogStatsDProcessBuilder, healthCheck(config.getDogStatsDNamedPipe())); + } else { + log.warn("DogStatsD path not set. Will not start DogStatsD process"); + } + } } - } - @Override - public void close() { - if (traceProcessSupervisor != null) { - traceProcessSupervisor.close(); - } + @Override + public void close() { + if (traceProcessSupervisor != null) { + traceProcessSupervisor.close(); + } - if (dogStatsDProcessSupervisor != null) { - dogStatsDProcessSupervisor.close(); + if (dogStatsDProcessSupervisor != null) { + dogStatsDProcessSupervisor.close(); + } } - } - private static ProcessSupervisor.HealthCheck healthCheck(String pipeName) { - return null != pipeName && !pipeName.trim().isEmpty() - ? new NamedPipeHealthCheck(pipeName) - : ALWAYS_READY; - } + private static ProcessSupervisor.HealthCheck healthCheck(String pipeName) { + return null != pipeName && !pipeName.trim().isEmpty() ? new NamedPipeHealthCheck(pipeName) : ALWAYS_READY; + } - static final class NamedPipeHealthCheck implements ProcessSupervisor.HealthCheck { - private static final String NAMED_PIPE_PREFIX = "\\\\.\\pipe\\"; + static final class NamedPipeHealthCheck implements ProcessSupervisor.HealthCheck { + private static final String NAMED_PIPE_PREFIX = "\\\\.\\pipe\\"; - private final File pipe; + private final File pipe; - NamedPipeHealthCheck(String pipeName) { - if (pipeName.startsWith(NAMED_PIPE_PREFIX)) { - this.pipe = new File(pipeName); - } else { - this.pipe = new File(NAMED_PIPE_PREFIX + pipeName); - } - } + NamedPipeHealthCheck(String pipeName) { + if (pipeName.startsWith(NAMED_PIPE_PREFIX)) { + this.pipe = new File(pipeName); + } else { + this.pipe = new File(NAMED_PIPE_PREFIX + pipeName); + } + } - @Override - public ProcessSupervisor.Health run(ProcessSupervisor.Health previousHealth) - throws InterruptedException { + @Override + public ProcessSupervisor.Health run(ProcessSupervisor.Health previousHealth) throws InterruptedException { - // first-time round do a more detailed check for existing bound named-pipe - if (previousHealth == NEVER_CHECKED) { + // first-time round do a more detailed check for existing bound named-pipe + if (previousHealth == NEVER_CHECKED) { - double delayMillis = 50; - for (int retries = 0; retries < 7; retries++) { - if (!pipe.exists()) { - return READY_TO_START; // no longer bound, start our own external process - } + double delayMillis = 50; + for (int retries = 0; retries < 7; retries++) { + if (!pipe.exists()) { + return READY_TO_START; // no longer bound, start our own external process + } - // check at increasing intervals to make sure it's bound to a healthy process - Thread.sleep((long) delayMillis); - delayMillis = delayMillis * 1.75; - } + // check at increasing intervals to make sure it's bound to a healthy process + Thread.sleep((long) delayMillis); + delayMillis = delayMillis * 1.75; + } - return HEALTHY; // use existing external process - } + return HEALTHY; // use existing external process + } - // otherwise just check that the pipe is still bound - if (pipe.exists()) { - return HEALTHY; // keep using external process - } else { - return READY_TO_START; // start our own process - } + // otherwise just check that the pipe is still bound + if (pipe.exists()) { + return HEALTHY; // keep using external process + } else { + return READY_TO_START; // start our own process + } + } } - } } diff --git a/communication/src/main/java/datadog/communication/ddagent/NoopFeaturesDiscovery.java b/communication/src/main/java/datadog/communication/ddagent/NoopFeaturesDiscovery.java index 41c04b9cc94..4dac6fe09dd 100644 --- a/communication/src/main/java/datadog/communication/ddagent/NoopFeaturesDiscovery.java +++ b/communication/src/main/java/datadog/communication/ddagent/NoopFeaturesDiscovery.java @@ -11,15 +11,15 @@ * network I/O. */ public class NoopFeaturesDiscovery extends DDAgentFeaturesDiscovery { - public static final NoopFeaturesDiscovery INSTANCE = new NoopFeaturesDiscovery(); + public static final NoopFeaturesDiscovery INSTANCE = new NoopFeaturesDiscovery(); - private NoopFeaturesDiscovery() { - super(null, Monitoring.DISABLED, null, V0_4, false, false); - } + private NoopFeaturesDiscovery() { + super(null, Monitoring.DISABLED, null, V0_4, false, false); + } - @Override - public void discover() {} + @Override + public void discover() {} - @Override - public void discoverIfOutdated() {} + @Override + public void discoverIfOutdated() {} } diff --git a/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java b/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java index 73e9fbc037c..3b991887382 100644 --- a/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java +++ b/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java @@ -24,256 +24,248 @@ import org.slf4j.LoggerFactory; public class SharedCommunicationObjects { - private static final Logger log = LoggerFactory.getLogger(SharedCommunicationObjects.class); + private static final Logger log = LoggerFactory.getLogger(SharedCommunicationObjects.class); - private static final String X_DATADOG_TEST_SESSION_TOKEN = "X-Datadog-Test-Session-Token"; + private static final String X_DATADOG_TEST_SESSION_TOKEN = "X-Datadog-Test-Session-Token"; - private final List pausedComponents = new ArrayList<>(); - private volatile boolean paused; + private final List pausedComponents = new ArrayList<>(); + private volatile boolean paused; - /** - * HTTP client for making requests to Datadog agent. Depending on configuration, this client may - * use regular HTTP, UDS or named pipe. - */ - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public OkHttpClient agentHttpClient; + /** + * HTTP client for making requests to Datadog agent. Depending on configuration, this client may + * use regular HTTP, UDS or named pipe. + */ + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") + public OkHttpClient agentHttpClient; - /** - * HTTP client for making requests directly to Datadog backend. Unlike {@link #agentHttpClient}, - * this client is not configured to use UDS or named pipe. - */ - private volatile OkHttpClient intakeHttpClient; + /** + * HTTP client for making requests directly to Datadog backend. Unlike {@link #agentHttpClient}, + * this client is not configured to use UDS or named pipe. + */ + private volatile OkHttpClient intakeHttpClient; - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public long httpClientTimeout; + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") + public long httpClientTimeout; - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public boolean forceClearTextHttpForIntakeClient; + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") + public boolean forceClearTextHttpForIntakeClient; - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public HttpUrl agentUrl; + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") + public HttpUrl agentUrl; - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public Monitoring monitoring; + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") + public Monitoring monitoring; - private volatile DDAgentFeaturesDiscovery featuresDiscovery; - private ConfigurationPoller configurationPoller; + private volatile DDAgentFeaturesDiscovery featuresDiscovery; + private ConfigurationPoller configurationPoller; - public SharedCommunicationObjects() { - this(false); - } - - public SharedCommunicationObjects(boolean paused) { - this.paused = paused; - } + public SharedCommunicationObjects() { + this(false); + } - public void createRemaining(Config config) { - if (monitoring == null) { - monitoring = Monitoring.DISABLED; + public SharedCommunicationObjects(boolean paused) { + this.paused = paused; } - httpClientTimeout = - config.isCiVisibilityEnabled() - ? config.getCiVisibilityBackendApiTimeoutMillis() - : TimeUnit.SECONDS.toMillis(config.getAgentTimeout()); + public void createRemaining(Config config) { + if (monitoring == null) { + monitoring = Monitoring.DISABLED; + } - forceClearTextHttpForIntakeClient = config.isForceClearTextHttpForIntakeClient(); + httpClientTimeout = config.isCiVisibilityEnabled() + ? config.getCiVisibilityBackendApiTimeoutMillis() + : TimeUnit.SECONDS.toMillis(config.getAgentTimeout()); - if (agentUrl == null) { - agentUrl = parseAgentUrl(config); - if (agentUrl == null) { - throw new IllegalArgumentException("Bad agent URL: " + config.getAgentUrl()); - } - } + forceClearTextHttpForIntakeClient = config.isForceClearTextHttpForIntakeClient(); - if (agentHttpClient == null) { - String unixDomainSocket = SocketUtils.discoverApmSocket(config); - String namedPipe = config.getAgentNamedPipe(); - agentHttpClient = - OkHttpUtils.buildHttpClient( - OkHttpUtils.isPlainHttp(agentUrl), unixDomainSocket, namedPipe, httpClientTimeout); - String testSessionToken = config.getTestAgentSessionToken(); - if (testSessionToken != null) { - agentHttpClient = injectTestAgentSessionHeaderInterceptor(testSessionToken); - } + if (agentUrl == null) { + agentUrl = parseAgentUrl(config); + if (agentUrl == null) { + throw new IllegalArgumentException("Bad agent URL: " + config.getAgentUrl()); + } + } + + if (agentHttpClient == null) { + String unixDomainSocket = SocketUtils.discoverApmSocket(config); + String namedPipe = config.getAgentNamedPipe(); + agentHttpClient = OkHttpUtils.buildHttpClient( + OkHttpUtils.isPlainHttp(agentUrl), unixDomainSocket, namedPipe, httpClientTimeout); + String testSessionToken = config.getTestAgentSessionToken(); + if (testSessionToken != null) { + agentHttpClient = injectTestAgentSessionHeaderInterceptor(testSessionToken); + } + } } - } - - private OkHttpClient injectTestAgentSessionHeaderInterceptor(String testSessionToken) { - return agentHttpClient - .newBuilder() - .addInterceptor( - chain -> - chain.proceed( - chain - .request() + + private OkHttpClient injectTestAgentSessionHeaderInterceptor(String testSessionToken) { + return agentHttpClient + .newBuilder() + .addInterceptor(chain -> chain.proceed(chain.request() .newBuilder() .header(X_DATADOG_TEST_SESSION_TOKEN, testSessionToken) .build())) - .build(); - } + .build(); + } - /** Registers a callback to be called when remote communications resume. */ - public void whenReady(Runnable callback) { - if (paused) { - synchronized (pausedComponents) { + /** Registers a callback to be called when remote communications resume. */ + public void whenReady(Runnable callback) { if (paused) { - pausedComponents.add(callback); - return; + synchronized (pausedComponents) { + if (paused) { + pausedComponents.add(callback); + return; + } + } } - } + callback.run(); // not paused, run immediately } - callback.run(); // not paused, run immediately - } - - /** Resumes remote communications including any paused callbacks. */ - public void resume() { - paused = false; - // attempt discovery first to avoid potential race condition on IBM Java8 - if (null != featuresDiscovery) { - featuresDiscovery.discoverIfOutdated(); - } else { - Security.getProviders(); // fallback to preloading provider extensions + + /** Resumes remote communications including any paused callbacks. */ + public void resume() { + paused = false; + // attempt discovery first to avoid potential race condition on IBM Java8 + if (null != featuresDiscovery) { + featuresDiscovery.discoverIfOutdated(); + } else { + Security.getProviders(); // fallback to preloading provider extensions + } + synchronized (pausedComponents) { + for (Runnable callback : pausedComponents) { + try { + callback.run(); + } catch (Throwable e) { + log.warn("Problem resuming remote component {}", callback, e); + } + } + pausedComponents.clear(); + } } - synchronized (pausedComponents) { - for (Runnable callback : pausedComponents) { - try { - callback.run(); - } catch (Throwable e) { - log.warn("Problem resuming remote component {}", callback, e); + + private static HttpUrl parseAgentUrl(Config config) { + String agentUrl = config.getAgentUrl(); + if (agentUrl.startsWith("unix:")) { + // provide placeholder agent URL, in practice we'll be tunnelling over UDS + agentUrl = "http://" + config.getAgentHost() + ":" + config.getAgentPort(); } - } - pausedComponents.clear(); + return HttpUrl.parse(agentUrl); } - } - private static HttpUrl parseAgentUrl(Config config) { - String agentUrl = config.getAgentUrl(); - if (agentUrl.startsWith("unix:")) { - // provide placeholder agent URL, in practice we'll be tunnelling over UDS - agentUrl = "http://" + config.getAgentHost() + ":" + config.getAgentPort(); + public ConfigurationPoller configurationPoller(Config config) { + if (configurationPoller == null && config.isRemoteConfigEnabled()) { + configurationPoller = createPoller(config); + } + return configurationPoller; } - return HttpUrl.parse(agentUrl); - } - public ConfigurationPoller configurationPoller(Config config) { - if (configurationPoller == null && config.isRemoteConfigEnabled()) { - configurationPoller = createPoller(config); + private ConfigurationPoller createPoller(Config config) { + String containerId = ContainerInfo.get().getContainerId(); + String entityId = ContainerInfo.getEntityId(); + Supplier configUrlSupplier; + String remoteConfigUrl = config.getFinalRemoteConfigUrl(); + if (remoteConfigUrl != null) { + configUrlSupplier = new FixedConfigUrlSupplier(remoteConfigUrl); + } else { + createRemaining(config); + configUrlSupplier = new RetryConfigUrlSupplier(this, config); + } + return new DefaultConfigurationPoller( + config, TRACER_VERSION, containerId, entityId, configUrlSupplier, agentHttpClient); } - return configurationPoller; - } - - private ConfigurationPoller createPoller(Config config) { - String containerId = ContainerInfo.get().getContainerId(); - String entityId = ContainerInfo.getEntityId(); - Supplier configUrlSupplier; - String remoteConfigUrl = config.getFinalRemoteConfigUrl(); - if (remoteConfigUrl != null) { - configUrlSupplier = new FixedConfigUrlSupplier(remoteConfigUrl); - } else { - createRemaining(config); - configUrlSupplier = new RetryConfigUrlSupplier(this, config); + + // for testing + public void setFeaturesDiscovery(DDAgentFeaturesDiscovery featuresDiscovery) { + this.featuresDiscovery = featuresDiscovery; } - return new DefaultConfigurationPoller( - config, TRACER_VERSION, containerId, entityId, configUrlSupplier, agentHttpClient); - } - - // for testing - public void setFeaturesDiscovery(DDAgentFeaturesDiscovery featuresDiscovery) { - this.featuresDiscovery = featuresDiscovery; - } - - public DDAgentFeaturesDiscovery featuresDiscovery(Config config) { - DDAgentFeaturesDiscovery ret = featuresDiscovery; - if (ret == null) { - synchronized (this) { - if ((ret = featuresDiscovery) == null) { - if (config.isCiVisibilityEnabled() && BazelMode.get().isPayloadFilesEnabled()) { - // Hermetic Bazel runs write payloads to local files; don't probe the agent. - ret = NoopFeaturesDiscovery.INSTANCE; - } else { - createRemaining(config); - ret = - new DDAgentFeaturesDiscovery( - agentHttpClient, - monitoring, - agentUrl, - config.getProtocolVersion(), - config.isTracerMetricsEnabled(), - config.isTracerMetricsIgnoreAgentVersion()); - - if (paused) { - // defer remote discovery until remote I/O is allowed - } else { - if (AGENT_THREAD_GROUP.equals(Thread.currentThread().getThreadGroup())) { - ret.discover(); // safe to run on same thread - } else { - // avoid performing blocking I/O operation on application thread - AgentTaskScheduler.get().execute(ret::discoverIfOutdated); - } + + public DDAgentFeaturesDiscovery featuresDiscovery(Config config) { + DDAgentFeaturesDiscovery ret = featuresDiscovery; + if (ret == null) { + synchronized (this) { + if ((ret = featuresDiscovery) == null) { + if (config.isCiVisibilityEnabled() && BazelMode.get().isPayloadFilesEnabled()) { + // Hermetic Bazel runs write payloads to local files; don't probe the agent. + ret = NoopFeaturesDiscovery.INSTANCE; + } else { + createRemaining(config); + ret = new DDAgentFeaturesDiscovery( + agentHttpClient, + monitoring, + agentUrl, + config.getProtocolVersion(), + config.isTracerMetricsEnabled(), + config.isTracerMetricsIgnoreAgentVersion()); + + if (paused) { + // defer remote discovery until remote I/O is allowed + } else { + if (AGENT_THREAD_GROUP.equals(Thread.currentThread().getThreadGroup())) { + ret.discover(); // safe to run on same thread + } else { + // avoid performing blocking I/O operation on application thread + AgentTaskScheduler.get().execute(ret::discoverIfOutdated); + } + } + } + featuresDiscovery = ret; + } } - } - featuresDiscovery = ret; } - } + return ret; } - return ret; - } - private static final class FixedConfigUrlSupplier implements Supplier { - private final String configUrl; + private static final class FixedConfigUrlSupplier implements Supplier { + private final String configUrl; - private FixedConfigUrlSupplier(String configUrl) { - this.configUrl = configUrl; - } + private FixedConfigUrlSupplier(String configUrl) { + this.configUrl = configUrl; + } - @Override - public String get() { - return this.configUrl; + @Override + public String get() { + return this.configUrl; + } } - } - private static final class RetryConfigUrlSupplier implements Supplier { - private String configUrl; - private final SharedCommunicationObjects sco; - private final Config config; + private static final class RetryConfigUrlSupplier implements Supplier { + private String configUrl; + private final SharedCommunicationObjects sco; + private final Config config; - private RetryConfigUrlSupplier(final SharedCommunicationObjects sco, final Config config) { - this.sco = sco; - this.config = config; - } + private RetryConfigUrlSupplier(final SharedCommunicationObjects sco, final Config config) { + this.sco = sco; + this.config = config; + } - @Override - public String get() { - if (configUrl != null) { - return configUrl; - } - - final DDAgentFeaturesDiscovery discovery = sco.featuresDiscovery(config); - discovery.discoverIfOutdated(); - final String configEndpoint = discovery.getConfigEndpoint(); - if (configEndpoint == null) { - return null; - } - this.configUrl = discovery.buildUrl(configEndpoint).toString(); - log.debug("Found remote config endpoint: {}", this.configUrl); - return this.configUrl; - } - } + @Override + public String get() { + if (configUrl != null) { + return configUrl; + } - public OkHttpClient getIntakeHttpClient() { - OkHttpClient client = this.intakeHttpClient; - if (client != null) { - return client; + final DDAgentFeaturesDiscovery discovery = sco.featuresDiscovery(config); + discovery.discoverIfOutdated(); + final String configEndpoint = discovery.getConfigEndpoint(); + if (configEndpoint == null) { + return null; + } + this.configUrl = discovery.buildUrl(configEndpoint).toString(); + log.debug("Found remote config endpoint: {}", this.configUrl); + return this.configUrl; + } } - synchronized (this) { - if (this.intakeHttpClient == null) { - this.intakeHttpClient = - OkHttpUtils.buildHttpClient( - forceClearTextHttpForIntakeClient, null, null, httpClientTimeout); - } - return this.intakeHttpClient; + public OkHttpClient getIntakeHttpClient() { + OkHttpClient client = this.intakeHttpClient; + if (client != null) { + return client; + } + + synchronized (this) { + if (this.intakeHttpClient == null) { + this.intakeHttpClient = + OkHttpUtils.buildHttpClient(forceClearTextHttpForIntakeClient, null, null, httpClientTimeout); + } + return this.intakeHttpClient; + } } - } } diff --git a/communication/src/main/java/datadog/communication/ddagent/TracerVersion.java b/communication/src/main/java/datadog/communication/ddagent/TracerVersion.java index db45e70b548..eaa0d78287a 100644 --- a/communication/src/main/java/datadog/communication/ddagent/TracerVersion.java +++ b/communication/src/main/java/datadog/communication/ddagent/TracerVersion.java @@ -5,18 +5,16 @@ import java.nio.charset.StandardCharsets; public class TracerVersion { - public static final String TRACER_VERSION = getTracerVersion(); + public static final String TRACER_VERSION = getTracerVersion(); - private static String getTracerVersion() { - ClassLoader cl = ClassLoader.getSystemClassLoader(); - try (final BufferedReader reader = - new BufferedReader( - new InputStreamReader( - cl.getResourceAsStream("dd-java-agent.version"), StandardCharsets.ISO_8859_1))) { - String line = reader.readLine(); - return line != null ? line : "0.0.0"; - } catch (Exception e) { - return "0.0.0"; + private static String getTracerVersion() { + ClassLoader cl = ClassLoader.getSystemClassLoader(); + try (final BufferedReader reader = new BufferedReader( + new InputStreamReader(cl.getResourceAsStream("dd-java-agent.version"), StandardCharsets.ISO_8859_1))) { + String line = reader.readLine(); + return line != null ? line : "0.0.0"; + } catch (Exception e) { + return "0.0.0"; + } } - } } diff --git a/communication/src/main/java/datadog/communication/http/HttpRetryPolicy.java b/communication/src/main/java/datadog/communication/http/HttpRetryPolicy.java index a645f53e29a..fcd1b4f838c 100644 --- a/communication/src/main/java/datadog/communication/http/HttpRetryPolicy.java +++ b/communication/src/main/java/datadog/communication/http/HttpRetryPolicy.java @@ -39,152 +39,146 @@ @NotThreadSafe public class HttpRetryPolicy implements AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(HttpRetryPolicy.class); - - private static final int NO_RESPONSE_RECEIVED = -1; - private static final int TOO_MANY_REQUESTS_HTTP_CODE = 429; - private static final String X_RATELIMIT_RESET_HTTP_HEADER = "x-ratelimit-reset"; - private static final int RATE_LIMIT_RESET_TIME_UNDEFINED = -1; - private static final int MAX_ALLOWED_WAIT_TIME_SECONDS = 10; - private static final int RATE_LIMIT_DELAY_RANDOM_COMPONENT_MAX_MILLIS = 401; - - private int retriesLeft; - private long delay; - private boolean interrupted; - private final double delayFactor; - private final boolean suppressInterrupts; - - /** - * Creates a retry policy. - * - *

Protected so products with a stricter cross-SDK retry contract can reuse the shared HTTP - * retry loop while supplying their own response, exception, and backoff rules. - */ - protected HttpRetryPolicy( - int retriesLeft, long delay, double delayFactor, boolean suppressInterrupts) { - this.retriesLeft = retriesLeft; - this.delay = delay; - this.delayFactor = delayFactor; - this.suppressInterrupts = suppressInterrupts; - } - - public boolean shouldRetry(Exception e) { - if (e instanceof ConnectException) { - return shouldRetry((okhttp3.Response) null); - } - if (e instanceof InterruptedIOException) { - if (suppressInterrupts) { - return shouldRetry((okhttp3.Response) null); - } - } - if (e instanceof InterruptedException) { - if (suppressInterrupts) { - // remember interrupted status to restore the thread's interrupted flag later - interrupted = true; - return shouldRetry((okhttp3.Response) null); - } + private static final Logger log = LoggerFactory.getLogger(HttpRetryPolicy.class); + + private static final int NO_RESPONSE_RECEIVED = -1; + private static final int TOO_MANY_REQUESTS_HTTP_CODE = 429; + private static final String X_RATELIMIT_RESET_HTTP_HEADER = "x-ratelimit-reset"; + private static final int RATE_LIMIT_RESET_TIME_UNDEFINED = -1; + private static final int MAX_ALLOWED_WAIT_TIME_SECONDS = 10; + private static final int RATE_LIMIT_DELAY_RANDOM_COMPONENT_MAX_MILLIS = 401; + + private int retriesLeft; + private long delay; + private boolean interrupted; + private final double delayFactor; + private final boolean suppressInterrupts; + + /** + * Creates a retry policy. + * + *

Protected so products with a stricter cross-SDK retry contract can reuse the shared HTTP + * retry loop while supplying their own response, exception, and backoff rules. + */ + protected HttpRetryPolicy(int retriesLeft, long delay, double delayFactor, boolean suppressInterrupts) { + this.retriesLeft = retriesLeft; + this.delay = delay; + this.delayFactor = delayFactor; + this.suppressInterrupts = suppressInterrupts; } - return false; - } - public boolean shouldRetry(@Nullable okhttp3.Response response) { - if (retriesLeft == 0) { - return false; + public boolean shouldRetry(Exception e) { + if (e instanceof ConnectException) { + return shouldRetry((okhttp3.Response) null); + } + if (e instanceof InterruptedIOException) { + if (suppressInterrupts) { + return shouldRetry((okhttp3.Response) null); + } + } + if (e instanceof InterruptedException) { + if (suppressInterrupts) { + // remember interrupted status to restore the thread's interrupted flag later + interrupted = true; + return shouldRetry((okhttp3.Response) null); + } + } + return false; } - int responseCode = response != null ? response.code() : NO_RESPONSE_RECEIVED; - if (responseCode == TOO_MANY_REQUESTS_HTTP_CODE) { - long waitTimeSeconds = getRateLimitResetTime(response); - if (waitTimeSeconds == RATE_LIMIT_RESET_TIME_UNDEFINED) { - retriesLeft--; // doing a regular retry if proper reset time was not provided - return true; - } - - if (waitTimeSeconds > MAX_ALLOWED_WAIT_TIME_SECONDS) { - return false; // too long to wait, will not retry - } - - retriesLeft = 0; - delay = - TimeUnit.SECONDS.toMillis(waitTimeSeconds) - + ThreadLocalRandom.current().nextInt(RATE_LIMIT_DELAY_RANDOM_COMPONENT_MAX_MILLIS); - return true; - - } else if (responseCode >= 500 || responseCode == NO_RESPONSE_RECEIVED) { - retriesLeft--; - return true; - - } else { - return false; + public boolean shouldRetry(@Nullable okhttp3.Response response) { + if (retriesLeft == 0) { + return false; + } + + int responseCode = response != null ? response.code() : NO_RESPONSE_RECEIVED; + if (responseCode == TOO_MANY_REQUESTS_HTTP_CODE) { + long waitTimeSeconds = getRateLimitResetTime(response); + if (waitTimeSeconds == RATE_LIMIT_RESET_TIME_UNDEFINED) { + retriesLeft--; // doing a regular retry if proper reset time was not provided + return true; + } + + if (waitTimeSeconds > MAX_ALLOWED_WAIT_TIME_SECONDS) { + return false; // too long to wait, will not retry + } + + retriesLeft = 0; + delay = TimeUnit.SECONDS.toMillis(waitTimeSeconds) + + ThreadLocalRandom.current().nextInt(RATE_LIMIT_DELAY_RANDOM_COMPONENT_MAX_MILLIS); + return true; + + } else if (responseCode >= 500 || responseCode == NO_RESPONSE_RECEIVED) { + retriesLeft--; + return true; + + } else { + return false; + } } - } - private long getRateLimitResetTime(okhttp3.Response response) { - String rateLimitHeader = response.header(X_RATELIMIT_RESET_HTTP_HEADER); - if (rateLimitHeader == null) { - return RATE_LIMIT_RESET_TIME_UNDEFINED; + private long getRateLimitResetTime(okhttp3.Response response) { + String rateLimitHeader = response.header(X_RATELIMIT_RESET_HTTP_HEADER); + if (rateLimitHeader == null) { + return RATE_LIMIT_RESET_TIME_UNDEFINED; + } + + try { + return Long.parseLong(rateLimitHeader); + } catch (NumberFormatException e) { + log.error("Could not parse {} header contents: {}", X_RATELIMIT_RESET_HTTP_HEADER, rateLimitHeader, e); + return RATE_LIMIT_RESET_TIME_UNDEFINED; + } } - try { - return Long.parseLong(rateLimitHeader); - } catch (NumberFormatException e) { - log.error( - "Could not parse {} header contents: {}", - X_RATELIMIT_RESET_HTTP_HEADER, - rateLimitHeader, - e); - return RATE_LIMIT_RESET_TIME_UNDEFINED; + long getBackoffDelay() { + long currentDelay = delay; + delay = (long) (delay * delayFactor); + return currentDelay; } - } - - long getBackoffDelay() { - long currentDelay = delay; - delay = (long) (delay * delayFactor); - return currentDelay; - } - - public void backoff() throws IOException { - try { - Thread.sleep(getBackoffDelay()); - } catch (InterruptedException e) { - if (suppressInterrupts) { - // remember interrupted status to restore the thread's interrupted flag later - interrupted = true; - } else { - Thread.currentThread().interrupt(); - throw new InterruptedIOException("thread interrupted"); - } + + public void backoff() throws IOException { + try { + Thread.sleep(getBackoffDelay()); + } catch (InterruptedException e) { + if (suppressInterrupts) { + // remember interrupted status to restore the thread's interrupted flag later + interrupted = true; + } else { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("thread interrupted"); + } + } } - } - @Override - public void close() { - if (interrupted) { - Thread.currentThread().interrupt(); + @Override + public void close() { + if (interrupted) { + Thread.currentThread().interrupt(); + } } - } - public static class Factory { - public static final Factory NEVER_RETRY = new Factory(0, 0, 0); + public static class Factory { + public static final Factory NEVER_RETRY = new Factory(0, 0, 0); - private final int maxRetries; - private final long initialDelay; - private final double delayFactor; - private final boolean retryInterrupts; + private final int maxRetries; + private final long initialDelay; + private final double delayFactor; + private final boolean retryInterrupts; - public Factory(int maxRetries, int initialDelay, double delayFactor) { - this(maxRetries, initialDelay, delayFactor, false); - } + public Factory(int maxRetries, int initialDelay, double delayFactor) { + this(maxRetries, initialDelay, delayFactor, false); + } - public Factory(int maxRetries, int initialDelay, double delayFactor, boolean retryInterrupts) { - this.maxRetries = maxRetries; - this.initialDelay = initialDelay; - this.delayFactor = delayFactor; - this.retryInterrupts = retryInterrupts; - } + public Factory(int maxRetries, int initialDelay, double delayFactor, boolean retryInterrupts) { + this.maxRetries = maxRetries; + this.initialDelay = initialDelay; + this.delayFactor = delayFactor; + this.retryInterrupts = retryInterrupts; + } - public HttpRetryPolicy create() { - return new HttpRetryPolicy(maxRetries, initialDelay, delayFactor, retryInterrupts); + public HttpRetryPolicy create() { + return new HttpRetryPolicy(maxRetries, initialDelay, delayFactor, retryInterrupts); + } } - } } diff --git a/communication/src/main/java/datadog/communication/http/OkHttpUtils.java b/communication/src/main/java/datadog/communication/http/OkHttpUtils.java index 44105ae772d..e22ad803f6d 100644 --- a/communication/src/main/java/datadog/communication/http/OkHttpUtils.java +++ b/communication/src/main/java/datadog/communication/http/OkHttpUtils.java @@ -40,429 +40,413 @@ import org.slf4j.LoggerFactory; public final class OkHttpUtils { - private static final Logger log = LoggerFactory.getLogger(OkHttpUtils.class); - - private static final String DATADOG_META_LANG = "Datadog-Meta-Lang"; - private static final String DATADOG_META_LANG_VERSION = "Datadog-Meta-Lang-Version"; - private static final String DATADOG_META_LANG_INTERPRETER = "Datadog-Meta-Lang-Interpreter"; - private static final String DATADOG_META_LANG_INTERPRETER_VENDOR = - "Datadog-Meta-Lang-Interpreter-Vendor"; - public static final String DATADOG_CONTAINER_ID = "Datadog-Container-ID"; - private static final String DATADOG_ENTITY_ID = "Datadog-Entity-ID"; - public static final String DATADOG_CONTAINER_TAGS_HASH = "Datadog-Container-Tags-Hash"; - - private static final String DD_API_KEY = "DD-API-KEY"; - - private static final String JAVA_VERSION = - SystemProperties.getOrDefault("java.version", "unknown"); - private static final String JAVA_VM_NAME = - SystemProperties.getOrDefault("java.vm.name", "unknown"); - private static final String JAVA_VM_VENDOR = - SystemProperties.getOrDefault("java.vm.vendor", "unknown"); - - public static OkHttpClient buildHttpClient(final HttpUrl url, final long timeoutMillis) { - return buildHttpClient(isPlainHttp(url), null, null, timeoutMillis); - } - - public static OkHttpClient buildHttpClient( - final boolean isPlainHttp, - final String unixDomainSocketPath, - final String namedPipe, - final long timeoutMillis) { - return buildHttpClient( - unixDomainSocketPath, - Config.get().isJdkSocketEnabled(), - namedPipe, - null, - isPlainHttp, - false, - null, - null, - null, - null, - null, - null, - timeoutMillis, - Config.get().isAgentConfiguredUsingDefault()); - } - - public static OkHttpClient buildHttp2Client( - final boolean isPlainHttp, - final String unixDomainSocketPath, - final String namedPipe, - final long timeoutMillis) { - return buildHttpClient( - unixDomainSocketPath, - Config.get().isJdkSocketEnabled(), - namedPipe, - null, - isPlainHttp, - true, - null, - null, - null, - null, - null, - null, - timeoutMillis, - Config.get().isAgentConfiguredUsingDefault()); - } - - public static OkHttpClient buildHttpClient( - final Config config, - final Dispatcher dispatcher, - final HttpUrl url, - final Boolean retryOnConnectionFailure, - final Integer maxRunningRequests, - final String proxyHost, - final Integer proxyPort, - final String proxyUsername, - final String proxyPassword, - final long timeoutMillis) { - return buildHttpClient( - discoverApmSocket(config), - config.isJdkSocketEnabled(), - config.getAgentNamedPipe(), - dispatcher, - isPlainHttp(url), - false, - retryOnConnectionFailure, - maxRunningRequests, - proxyHost, - proxyPort, - proxyUsername, - proxyPassword, - timeoutMillis, - config.isAgentConfiguredUsingDefault()); - } - - public abstract static class CustomListener extends EventListener {} - - private static OkHttpClient buildHttpClient( - final String unixDomainSocketPath, - final boolean useJdkUnixDomainSocket, - final String namedPipe, - final Dispatcher dispatcher, - final boolean isPlainHttp, - final boolean isHttp2, - final Boolean retryOnConnectionFailure, - final Integer maxRunningRequests, - final String proxyHost, - final Integer proxyPort, - final String proxyUsername, - final String proxyPassword, - final long timeoutMillis, - final boolean agentConfiguredUsingDefault) { - final OkHttpClient.Builder builder = new OkHttpClient.Builder(); - - try { - builder.eventListenerFactory( - call -> { - Request request = call.request(); - CustomListener listener = request.tag(CustomListener.class); - return listener != null ? listener : EventListener.NONE; - }); - } catch (NoSuchMethodError e) { - // A workaround for OKHTTP instrumentation tests - // where the version of OKHTTP conflicts with the one used in this module. - // This should never happen in "real life" as OKHTTP classes - // used by the tracer core are relocated to a different package - } + private static final Logger log = LoggerFactory.getLogger(OkHttpUtils.class); - builder - .connectTimeout(timeoutMillis, MILLISECONDS) - .writeTimeout(timeoutMillis, MILLISECONDS) - .readTimeout(timeoutMillis, MILLISECONDS) - .proxySelector(AgentProxySelector.INSTANCE) - .dispatcher( - dispatcher != null ? dispatcher : new Dispatcher(RejectingExecutorService.INSTANCE)); - - if (unixDomainSocketPath != null) { - builder.socketFactory( - new UnixDomainSocketFactory( - new File(unixDomainSocketPath), useJdkUnixDomainSocket, agentConfiguredUsingDefault)); - log.debug("Using UnixDomainSocket as http transport"); - } else if (namedPipe != null) { - builder.socketFactory(new NamedPipeSocketFactory(namedPipe)); - log.debug("Using NamedPipe as http transport"); - } + private static final String DATADOG_META_LANG = "Datadog-Meta-Lang"; + private static final String DATADOG_META_LANG_VERSION = "Datadog-Meta-Lang-Version"; + private static final String DATADOG_META_LANG_INTERPRETER = "Datadog-Meta-Lang-Interpreter"; + private static final String DATADOG_META_LANG_INTERPRETER_VENDOR = "Datadog-Meta-Lang-Interpreter-Vendor"; + public static final String DATADOG_CONTAINER_ID = "Datadog-Container-ID"; + private static final String DATADOG_ENTITY_ID = "Datadog-Entity-ID"; + public static final String DATADOG_CONTAINER_TAGS_HASH = "Datadog-Container-Tags-Hash"; + + private static final String DD_API_KEY = "DD-API-KEY"; + + private static final String JAVA_VERSION = SystemProperties.getOrDefault("java.version", "unknown"); + private static final String JAVA_VM_NAME = SystemProperties.getOrDefault("java.vm.name", "unknown"); + private static final String JAVA_VM_VENDOR = SystemProperties.getOrDefault("java.vm.vendor", "unknown"); - if (isPlainHttp) { - // force clear text when using http to avoid failures for JVMs without TLS - builder.connectionSpecs(Collections.singletonList(ConnectionSpec.CLEARTEXT)); + public static OkHttpClient buildHttpClient(final HttpUrl url, final long timeoutMillis) { + return buildHttpClient(isPlainHttp(url), null, null, timeoutMillis); } - if (isHttp2) { - if (isPlainHttp) { - builder.protocols(Collections.singletonList(Protocol.H2_PRIOR_KNOWLEDGE)); - } else { - builder.protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1)); - } + public static OkHttpClient buildHttpClient( + final boolean isPlainHttp, + final String unixDomainSocketPath, + final String namedPipe, + final long timeoutMillis) { + return buildHttpClient( + unixDomainSocketPath, + Config.get().isJdkSocketEnabled(), + namedPipe, + null, + isPlainHttp, + false, + null, + null, + null, + null, + null, + null, + timeoutMillis, + Config.get().isAgentConfiguredUsingDefault()); } - if (retryOnConnectionFailure != null) { - builder.retryOnConnectionFailure(retryOnConnectionFailure); + public static OkHttpClient buildHttp2Client( + final boolean isPlainHttp, + final String unixDomainSocketPath, + final String namedPipe, + final long timeoutMillis) { + return buildHttpClient( + unixDomainSocketPath, + Config.get().isJdkSocketEnabled(), + namedPipe, + null, + isPlainHttp, + true, + null, + null, + null, + null, + null, + null, + timeoutMillis, + Config.get().isAgentConfiguredUsingDefault()); } - if (maxRunningRequests != null) { - // Reusing connections causes non daemon threads to be created which causes agent to prevent - // app from exiting. See https://github.com/square/okhttp/issues/4029 for some details. - builder.connectionPool(new ConnectionPool(maxRunningRequests, 1, SECONDS)); + public static OkHttpClient buildHttpClient( + final Config config, + final Dispatcher dispatcher, + final HttpUrl url, + final Boolean retryOnConnectionFailure, + final Integer maxRunningRequests, + final String proxyHost, + final Integer proxyPort, + final String proxyUsername, + final String proxyPassword, + final long timeoutMillis) { + return buildHttpClient( + discoverApmSocket(config), + config.isJdkSocketEnabled(), + config.getAgentNamedPipe(), + dispatcher, + isPlainHttp(url), + false, + retryOnConnectionFailure, + maxRunningRequests, + proxyHost, + proxyPort, + proxyUsername, + proxyPassword, + timeoutMillis, + config.isAgentConfiguredUsingDefault()); } - if (proxyHost != null) { - builder.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort))); - if (proxyUsername != null) { - builder.proxyAuthenticator( - (route, response) -> { - final String credential = - Credentials.basic(proxyUsername, proxyPassword == null ? "" : proxyPassword); - - return response - .request() - .newBuilder() - .header("Proxy-Authorization", credential) - .build(); + public abstract static class CustomListener extends EventListener {} + + private static OkHttpClient buildHttpClient( + final String unixDomainSocketPath, + final boolean useJdkUnixDomainSocket, + final String namedPipe, + final Dispatcher dispatcher, + final boolean isPlainHttp, + final boolean isHttp2, + final Boolean retryOnConnectionFailure, + final Integer maxRunningRequests, + final String proxyHost, + final Integer proxyPort, + final String proxyUsername, + final String proxyPassword, + final long timeoutMillis, + final boolean agentConfiguredUsingDefault) { + final OkHttpClient.Builder builder = new OkHttpClient.Builder(); + + try { + builder.eventListenerFactory(call -> { + Request request = call.request(); + CustomListener listener = request.tag(CustomListener.class); + return listener != null ? listener : EventListener.NONE; }); - } - } + } catch (NoSuchMethodError e) { + // A workaround for OKHTTP instrumentation tests + // where the version of OKHTTP conflicts with the one used in this module. + // This should never happen in "real life" as OKHTTP classes + // used by the tracer core are relocated to a different package + } - OkHttpClient client = builder.build(); + builder.connectTimeout(timeoutMillis, MILLISECONDS) + .writeTimeout(timeoutMillis, MILLISECONDS) + .readTimeout(timeoutMillis, MILLISECONDS) + .proxySelector(AgentProxySelector.INSTANCE) + .dispatcher(dispatcher != null ? dispatcher : new Dispatcher(RejectingExecutorService.INSTANCE)); + + if (unixDomainSocketPath != null) { + builder.socketFactory(new UnixDomainSocketFactory( + new File(unixDomainSocketPath), useJdkUnixDomainSocket, agentConfiguredUsingDefault)); + log.debug("Using UnixDomainSocket as http transport"); + } else if (namedPipe != null) { + builder.socketFactory(new NamedPipeSocketFactory(namedPipe)); + log.debug("Using NamedPipe as http transport"); + } - if (maxRunningRequests != null) { - client.dispatcher().setMaxRequests(maxRunningRequests); - // We are mainly talking to the same(ish) host so we need to raise this limit - client.dispatcher().setMaxRequestsPerHost(maxRunningRequests); - } + if (isPlainHttp) { + // force clear text when using http to avoid failures for JVMs without TLS + builder.connectionSpecs(Collections.singletonList(ConnectionSpec.CLEARTEXT)); + } - return client; - } + if (isHttp2) { + if (isPlainHttp) { + builder.protocols(Collections.singletonList(Protocol.H2_PRIOR_KNOWLEDGE)); + } else { + builder.protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1)); + } + } - public static Request.Builder prepareRequest(final HttpUrl url, Map headers) { + if (retryOnConnectionFailure != null) { + builder.retryOnConnectionFailure(retryOnConnectionFailure); + } - final Request.Builder builder = - new Request.Builder() - .url(url) - .addHeader(DATADOG_META_LANG, "java") - .addHeader(DATADOG_META_LANG_VERSION, JAVA_VERSION) - .addHeader(DATADOG_META_LANG_INTERPRETER, JAVA_VM_NAME) - .addHeader(DATADOG_META_LANG_INTERPRETER_VENDOR, JAVA_VM_VENDOR); + if (maxRunningRequests != null) { + // Reusing connections causes non daemon threads to be created which causes agent to prevent + // app from exiting. See https://github.com/square/okhttp/issues/4029 for some details. + builder.connectionPool(new ConnectionPool(maxRunningRequests, 1, SECONDS)); + } - final String containerId = ContainerInfo.get().getContainerId(); - final String entityId = ContainerInfo.getEntityId(); - if (containerId != null) { - builder.addHeader(DATADOG_CONTAINER_ID, containerId); - } - if (entityId != null) { - builder.addHeader(DATADOG_ENTITY_ID, entityId); - } + if (proxyHost != null) { + builder.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort))); + if (proxyUsername != null) { + builder.proxyAuthenticator((route, response) -> { + final String credential = + Credentials.basic(proxyUsername, proxyPassword == null ? "" : proxyPassword); + + return response.request() + .newBuilder() + .header("Proxy-Authorization", credential) + .build(); + }); + } + } - for (Map.Entry e : headers.entrySet()) { - builder.addHeader(e.getKey(), e.getValue()); - } + OkHttpClient client = builder.build(); + + if (maxRunningRequests != null) { + client.dispatcher().setMaxRequests(maxRunningRequests); + // We are mainly talking to the same(ish) host so we need to raise this limit + client.dispatcher().setMaxRequestsPerHost(maxRunningRequests); + } - return builder; - } - - public static Request.Builder prepareRequest( - final HttpUrl url, - final Map headers, - final Config config, - final boolean agentless) { - Request.Builder builder = prepareRequest(url, headers); - - final String apiKey = config.getApiKey(); - if (agentless && apiKey != null) { - // we only add the api key header if we know we're doing agentless. No point in adding it to - // other agent-based requests since we know the datadog-agent isn't going to make use of it. - builder = builder.addHeader(DD_API_KEY, apiKey); + return client; } - return builder; - } + public static Request.Builder prepareRequest(final HttpUrl url, Map headers) { - public static RequestBody msgpackRequestBodyOf(List buffers) { - return new ByteBufferRequestBody(buffers); - } + final Request.Builder builder = new Request.Builder() + .url(url) + .addHeader(DATADOG_META_LANG, "java") + .addHeader(DATADOG_META_LANG_VERSION, JAVA_VERSION) + .addHeader(DATADOG_META_LANG_INTERPRETER, JAVA_VM_NAME) + .addHeader(DATADOG_META_LANG_INTERPRETER_VENDOR, JAVA_VM_VENDOR); - public static RequestBody gzippedMsgpackRequestBodyOf(List buffers) { - return new GZipByteBufferRequestBody(buffers); - } + final String containerId = ContainerInfo.get().getContainerId(); + final String entityId = ContainerInfo.getEntityId(); + if (containerId != null) { + builder.addHeader(DATADOG_CONTAINER_ID, containerId); + } + if (entityId != null) { + builder.addHeader(DATADOG_ENTITY_ID, entityId); + } - public static RequestBody gzippedRequestBodyOf(RequestBody delegate) { - return new GZipRequestBodyDecorator(delegate); - } + for (Map.Entry e : headers.entrySet()) { + builder.addHeader(e.getKey(), e.getValue()); + } - public static RequestBody jsonRequestBodyOf(byte[] json) { - return new JsonRequestBody(json); - } + return builder; + } - private static class JsonRequestBody extends RequestBody { + public static Request.Builder prepareRequest( + final HttpUrl url, final Map headers, final Config config, final boolean agentless) { + Request.Builder builder = prepareRequest(url, headers); - private static final MediaType JSON = MediaType.get("application/json"); + final String apiKey = config.getApiKey(); + if (agentless && apiKey != null) { + // we only add the api key header if we know we're doing agentless. No point in adding it to + // other agent-based requests since we know the datadog-agent isn't going to make use of it. + builder = builder.addHeader(DD_API_KEY, apiKey); + } - private final byte[] json; + return builder; + } - private JsonRequestBody(byte[] json) { - this.json = json; + public static RequestBody msgpackRequestBodyOf(List buffers) { + return new ByteBufferRequestBody(buffers); } - @Override - public long contentLength() { - return json.length; + public static RequestBody gzippedMsgpackRequestBodyOf(List buffers) { + return new GZipByteBufferRequestBody(buffers); } - @Override - public MediaType contentType() { - return JSON; + public static RequestBody gzippedRequestBodyOf(RequestBody delegate) { + return new GZipRequestBodyDecorator(delegate); } - @Override - public void writeTo(BufferedSink sink) throws IOException { - sink.write(json); + public static RequestBody jsonRequestBodyOf(byte[] json) { + return new JsonRequestBody(json); } - } - private static class ByteBufferRequestBody extends RequestBody { + private static class JsonRequestBody extends RequestBody { - private static final MediaType MSGPACK = MediaType.get("application/msgpack"); + private static final MediaType JSON = MediaType.get("application/json"); - private final List buffers; + private final byte[] json; - private ByteBufferRequestBody(List buffers) { - this.buffers = buffers; - } + private JsonRequestBody(byte[] json) { + this.json = json; + } - @Override - public long contentLength() { - long length = 0; - for (ByteBuffer buffer : buffers) { - length += buffer.remaining(); - } - return length; - } + @Override + public long contentLength() { + return json.length; + } - @Override - public MediaType contentType() { - return MSGPACK; - } + @Override + public MediaType contentType() { + return JSON; + } - @Override - public void writeTo(BufferedSink sink) throws IOException { - for (ByteBuffer buffer : buffers) { - while (buffer.hasRemaining()) { - sink.write(buffer); + @Override + public void writeTo(BufferedSink sink) throws IOException { + sink.write(json); } - } } - } - private static final class GZipByteBufferRequestBody extends ByteBufferRequestBody { - private GZipByteBufferRequestBody(List buffers) { - super(buffers); - } + private static class ByteBufferRequestBody extends RequestBody { + + private static final MediaType MSGPACK = MediaType.get("application/msgpack"); - @Override - public long contentLength() { - return -1; + private final List buffers; + + private ByteBufferRequestBody(List buffers) { + this.buffers = buffers; + } + + @Override + public long contentLength() { + long length = 0; + for (ByteBuffer buffer : buffers) { + length += buffer.remaining(); + } + return length; + } + + @Override + public MediaType contentType() { + return MSGPACK; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + for (ByteBuffer buffer : buffers) { + while (buffer.hasRemaining()) { + sink.write(buffer); + } + } + } } - @Override - public void writeTo(BufferedSink sink) throws IOException { - BufferedSink gzipSink = Okio.buffer(new GzipSink(sink)); + private static final class GZipByteBufferRequestBody extends ByteBufferRequestBody { + private GZipByteBufferRequestBody(List buffers) { + super(buffers); + } + + @Override + public long contentLength() { + return -1; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + BufferedSink gzipSink = Okio.buffer(new GzipSink(sink)); - super.writeTo(gzipSink); + super.writeTo(gzipSink); - gzipSink.close(); + gzipSink.close(); + } } - } - private static final class GZipRequestBodyDecorator extends RequestBody { - private final RequestBody delegate; + private static final class GZipRequestBodyDecorator extends RequestBody { + private final RequestBody delegate; + + private GZipRequestBodyDecorator(RequestBody delegate) { + this.delegate = delegate; + } + + @Nullable + @Override + public MediaType contentType() { + return delegate.contentType(); + } + + @Override + public long contentLength() { + return -1; + } - private GZipRequestBodyDecorator(RequestBody delegate) { - this.delegate = delegate; + @Override + public void writeTo(BufferedSink sink) throws IOException { + BufferedSink gzipSink = Okio.buffer(new GzipSink(sink)); + delegate.writeTo(gzipSink); + gzipSink.close(); + } + } + + public static Response sendWithRetries( + OkHttpClient httpClient, HttpRetryPolicy.Factory retryPolicyFactory, Request request) throws IOException { + return sendWithRetries((Call.Factory) httpClient, retryPolicyFactory, request); } - @Nullable - @Override - public MediaType contentType() { - return delegate.contentType(); + public static Response sendWithRetries( + Call.Factory callFactory, HttpRetryPolicy.Factory retryPolicyFactory, Request request) throws IOException { + return sendWithRetries(callFactory, retryPolicyFactory, request, response -> response); } - @Override - public long contentLength() { - return -1; + public static T sendWithRetries( + Call.Factory callFactory, + HttpRetryPolicy.Factory retryPolicyFactory, + Request request, + ResponseMapper responseMapper) + throws IOException { + try (HttpRetryPolicy retryPolicy = retryPolicyFactory.create()) { + while (true) { + Response response = null; + try { + response = callFactory.newCall(request).execute(); + if (response.isSuccessful()) { + return responseMapper.map(response); + } + if (!retryPolicy.shouldRetry(response)) { + return responseMapper.map(response); + } else { + closeQuietly(response); + response = null; + } + } catch (Exception ex) { + if (response != null) { + closeQuietly(response); + } + if (!retryPolicy.shouldRetry(ex)) { + throw ex; + } + } + // If we get here, there has been an error, and we still have retries left + retryPolicy.backoff(); + } + } } - @Override - public void writeTo(BufferedSink sink) throws IOException { - BufferedSink gzipSink = Okio.buffer(new GzipSink(sink)); - delegate.writeTo(gzipSink); - gzipSink.close(); + @FunctionalInterface + public interface ResponseMapper { + T map(Response response) throws IOException; } - } - - public static Response sendWithRetries( - OkHttpClient httpClient, HttpRetryPolicy.Factory retryPolicyFactory, Request request) - throws IOException { - return sendWithRetries((Call.Factory) httpClient, retryPolicyFactory, request); - } - - public static Response sendWithRetries( - Call.Factory callFactory, HttpRetryPolicy.Factory retryPolicyFactory, Request request) - throws IOException { - return sendWithRetries(callFactory, retryPolicyFactory, request, response -> response); - } - - public static T sendWithRetries( - Call.Factory callFactory, - HttpRetryPolicy.Factory retryPolicyFactory, - Request request, - ResponseMapper responseMapper) - throws IOException { - try (HttpRetryPolicy retryPolicy = retryPolicyFactory.create()) { - while (true) { - Response response = null; + + private static void closeQuietly(Response response) { try { - response = callFactory.newCall(request).execute(); - if (response.isSuccessful()) { - return responseMapper.map(response); - } - if (!retryPolicy.shouldRetry(response)) { - return responseMapper.map(response); - } else { - closeQuietly(response); - response = null; - } - } catch (Exception ex) { - if (response != null) { - closeQuietly(response); - } - if (!retryPolicy.shouldRetry(ex)) { - throw ex; - } + response.close(); + } catch (Exception e) { + // ignore } - // If we get here, there has been an error, and we still have retries left - retryPolicy.backoff(); - } - } - } - - @FunctionalInterface - public interface ResponseMapper { - T map(Response response) throws IOException; - } - - private static void closeQuietly(Response response) { - try { - response.close(); - } catch (Exception e) { - // ignore } - } - public static boolean isPlainHttp(final HttpUrl url) { - return url != null && "http".equalsIgnoreCase(url.scheme()); - } + public static boolean isPlainHttp(final HttpUrl url) { + return url != null && "http".equalsIgnoreCase(url.scheme()); + } } diff --git a/communication/src/main/java/datadog/communication/http/RejectingExecutorService.java b/communication/src/main/java/datadog/communication/http/RejectingExecutorService.java index 1ec83a4fccc..7a5daf2a8bb 100644 --- a/communication/src/main/java/datadog/communication/http/RejectingExecutorService.java +++ b/communication/src/main/java/datadog/communication/http/RejectingExecutorService.java @@ -9,33 +9,33 @@ /** {@link ExecutorService} that always rejects requests. */ public final class RejectingExecutorService extends AbstractExecutorService { - public static final RejectingExecutorService INSTANCE = new RejectingExecutorService(); - - @Override - public void execute(final Runnable command) { - throw new RejectedExecutionException("Unexpected request to execute async task"); - } - - @Override - public void shutdown() {} - - @Override - public List shutdownNow() { - return Collections.emptyList(); - } - - @Override - public boolean isShutdown() { - return true; - } - - @Override - public boolean isTerminated() { - return true; - } - - @Override - public boolean awaitTermination(final long timeout, final TimeUnit unit) { - return true; - } + public static final RejectingExecutorService INSTANCE = new RejectingExecutorService(); + + @Override + public void execute(final Runnable command) { + throw new RejectedExecutionException("Unexpected request to execute async task"); + } + + @Override + public void shutdown() {} + + @Override + public List shutdownNow() { + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return true; + } + + @Override + public boolean isTerminated() { + return true; + } + + @Override + public boolean awaitTermination(final long timeout, final TimeUnit unit) { + return true; + } } diff --git a/communication/src/main/java/datadog/communication/http/SocketUtils.java b/communication/src/main/java/datadog/communication/http/SocketUtils.java index ed0dcaa118d..0011b3d276b 100644 --- a/communication/src/main/java/datadog/communication/http/SocketUtils.java +++ b/communication/src/main/java/datadog/communication/http/SocketUtils.java @@ -13,27 +13,27 @@ import org.slf4j.LoggerFactory; public final class SocketUtils { - private static final Logger log = LoggerFactory.getLogger(SocketUtils.class); + private static final Logger log = LoggerFactory.getLogger(SocketUtils.class); - @SuppressFBWarnings("DMI_HARDCODED_ABSOLUTE_FILENAME") - public static String discoverApmSocket(final Config config) { - String unixDomainSocket = config.getAgentUnixDomainSocket(); - if (!OperatingSystem.isWindows()) { - if (unixDomainSocket == null - && Config.get().isAgentConfiguredUsingDefault() - && Files.exists(new File(DEFAULT_TRACE_AGENT_SOCKET_PATH))) { - log.info("Detected {}. Using it to send trace data.", DEFAULT_TRACE_AGENT_SOCKET_PATH); - unixDomainSocket = DEFAULT_TRACE_AGENT_SOCKET_PATH; - } - } else /* windows */ { - if (unixDomainSocket != null) { - log.warn( - "{} setting not supported on {}. Reverting to the default.", - TracerConfig.AGENT_UNIX_DOMAIN_SOCKET, - SystemProperties.get("os.name")); - unixDomainSocket = null; - } + @SuppressFBWarnings("DMI_HARDCODED_ABSOLUTE_FILENAME") + public static String discoverApmSocket(final Config config) { + String unixDomainSocket = config.getAgentUnixDomainSocket(); + if (!OperatingSystem.isWindows()) { + if (unixDomainSocket == null + && Config.get().isAgentConfiguredUsingDefault() + && Files.exists(new File(DEFAULT_TRACE_AGENT_SOCKET_PATH))) { + log.info("Detected {}. Using it to send trace data.", DEFAULT_TRACE_AGENT_SOCKET_PATH); + unixDomainSocket = DEFAULT_TRACE_AGENT_SOCKET_PATH; + } + } else /* windows */ { + if (unixDomainSocket != null) { + log.warn( + "{} setting not supported on {}. Reverting to the default.", + TracerConfig.AGENT_UNIX_DOMAIN_SOCKET, + SystemProperties.get("os.name")); + unixDomainSocket = null; + } + } + return unixDomainSocket; } - return unixDomainSocket; - } } diff --git a/communication/src/main/java/datadog/communication/serialization/ByteBufferConsumer.java b/communication/src/main/java/datadog/communication/serialization/ByteBufferConsumer.java index 7c3e39f324e..96d146c3eec 100644 --- a/communication/src/main/java/datadog/communication/serialization/ByteBufferConsumer.java +++ b/communication/src/main/java/datadog/communication/serialization/ByteBufferConsumer.java @@ -4,5 +4,5 @@ public interface ByteBufferConsumer { - void accept(int messageCount, ByteBuffer buffer); + void accept(int messageCount, ByteBuffer buffer); } diff --git a/communication/src/main/java/datadog/communication/serialization/Caching.java b/communication/src/main/java/datadog/communication/serialization/Caching.java index 868170fcac3..f648ba9d437 100644 --- a/communication/src/main/java/datadog/communication/serialization/Caching.java +++ b/communication/src/main/java/datadog/communication/serialization/Caching.java @@ -4,79 +4,79 @@ /** Some common static functions used by simple & generational caches */ final class Caching { - private Caching() {} + private Caching() {} - /** - * Provides the cache size that holds the requestedCapacity - * - * @param requestedCapacity > 0 - * @return size >= requestedCapacity - */ - static final int cacheSizeFor(int requestedCapacity) { - int pow; - for (pow = 1; pow < requestedCapacity; pow *= 2) - ; - return pow; - } + /** + * Provides the cache size that holds the requestedCapacity + * + * @param requestedCapacity > 0 + * @return size >= requestedCapacity + */ + static final int cacheSizeFor(int requestedCapacity) { + int pow; + for (pow = 1; pow < requestedCapacity; pow *= 2) + ; + return pow; + } - /** Provides an "adjusted" (e.g. non-zero) hash for the given String */ - static final int adjHash(String value) { - int hash = value.hashCode(); - return (hash == 0) ? 0xDA7AD06 : hash; - } + /** Provides an "adjusted" (e.g. non-zero) hash for the given String */ + static final int adjHash(String value) { + int hash = value.hashCode(); + return (hash == 0) ? 0xDA7AD06 : hash; + } - /** Resets markers to zero */ - static final void reset(int[] marks) { - Arrays.fill(marks, 0); - } + /** Resets markers to zero */ + static final void reset(int[] marks) { + Arrays.fill(marks, 0); + } - /** - * Changes the mark status of the corresponding slot in the marking array. If there was previously - * a matching mark, resets the slot to zero and returns true If there was previously a mismatching - * mark, updates the slot and returns false - * - *

A return value of true indicates that the requested value has likely been seen previously - * and cache entry should be created. - */ - static final boolean mark(int[] marks, int newAdjHash) { - int index = bucketIndex(marks, newAdjHash); + /** + * Changes the mark status of the corresponding slot in the marking array. If there was previously + * a matching mark, resets the slot to zero and returns true If there was previously a mismatching + * mark, updates the slot and returns false + * + *

A return value of true indicates that the requested value has likely been seen previously + * and cache entry should be created. + */ + static final boolean mark(int[] marks, int newAdjHash) { + int index = bucketIndex(marks, newAdjHash); - // This is the 4th iteration of the marking strategy - // First version - used a mark entry, but that would prematurely - // burn a slot in the cache - // Second version - used a mark boolean, that worked well, but - // was a overly permissive in allowing the next request to the same slot - // to immediately create a CacheEntry - // Third version - used a mark hash that to match exactly, - // that could lead to access order fights over the cache slot - // So this version is a hybrid of 2nd & 3rd, using a bloom filter - // that effectively degenerates to a boolean + // This is the 4th iteration of the marking strategy + // First version - used a mark entry, but that would prematurely + // burn a slot in the cache + // Second version - used a mark boolean, that worked well, but + // was a overly permissive in allowing the next request to the same slot + // to immediately create a CacheEntry + // Third version - used a mark hash that to match exactly, + // that could lead to access order fights over the cache slot + // So this version is a hybrid of 2nd & 3rd, using a bloom filter + // that effectively degenerates to a boolean - // This approach provides a nice balance when there's an A-B-A access pattern - // The first A will mark the slot - // Then B will mark the slot with A | B - // Then either A or B can claim and reset the slot + // This approach provides a nice balance when there's an A-B-A access pattern + // The first A will mark the slot + // Then B will mark the slot with A | B + // Then either A or B can claim and reset the slot - int priorMarkHash = marks[index]; - boolean match = ((priorMarkHash & newAdjHash) == newAdjHash); - if (match) { - marks[index] = 0; - } else { - marks[index] = priorMarkHash | newAdjHash; + int priorMarkHash = marks[index]; + boolean match = ((priorMarkHash & newAdjHash) == newAdjHash); + if (match) { + marks[index] = 0; + } else { + marks[index] = priorMarkHash | newAdjHash; + } + return match; } - return match; - } - /** Provides the corresponding index into the marking array */ - static final int bucketIndex(int[] marks, int adjHash) { - return adjHash & (marks.length - 1); - } + /** Provides the corresponding index into the marking array */ + static final int bucketIndex(int[] marks, int adjHash) { + return adjHash & (marks.length - 1); + } - /** - * Provides the corresponding index into an entry array Assumes that array size was determined by - * using {@Caching#cacheSizeFor} - */ - static final int bucketIndex(E[] entries, int adjHash) { - return adjHash & (entries.length - 1); - } + /** + * Provides the corresponding index into an entry array Assumes that array size was determined by + * using {@Caching#cacheSizeFor} + */ + static final int bucketIndex(E[] entries, int adjHash) { + return adjHash & (entries.length - 1); + } } diff --git a/communication/src/main/java/datadog/communication/serialization/Codec.java b/communication/src/main/java/datadog/communication/serialization/Codec.java index b0eb5530827..4e385bfe89a 100644 --- a/communication/src/main/java/datadog/communication/serialization/Codec.java +++ b/communication/src/main/java/datadog/communication/serialization/Codec.java @@ -18,296 +18,295 @@ public final class Codec extends ClassValue> { - public static final Codec INSTANCE; - - static { - final Map, ValueWriter> writers = new HashMap<>(1 << 3); - writers.put(StackTraceEvent.class, new StackTraceEventWriter()); - writers.put(StackTraceFrame.class, new StackTraceEventFrameWriter()); - if (Config.get().isAiGuardEnabled()) { - writers.put(AIGuard.Message.class, new MessageWriter()); - writers.put(AIGuard.ToolCall.class, new ToolCallWriter()); - writers.put(AIGuard.ToolCall.Function.class, new FunctionWriter()); + public static final Codec INSTANCE; + + static { + final Map, ValueWriter> writers = new HashMap<>(1 << 3); + writers.put(StackTraceEvent.class, new StackTraceEventWriter()); + writers.put(StackTraceFrame.class, new StackTraceEventFrameWriter()); + if (Config.get().isAiGuardEnabled()) { + writers.put(AIGuard.Message.class, new MessageWriter()); + writers.put(AIGuard.ToolCall.class, new ToolCallWriter()); + writers.put(AIGuard.ToolCall.Function.class, new FunctionWriter()); + } + INSTANCE = new Codec(writers); } - INSTANCE = new Codec(writers); - } - private final Map, ValueWriter> config; + private final Map, ValueWriter> config; - public Codec(Map, ValueWriter> config) { - this.config = config; - } - - @SuppressWarnings("unchecked") - public Codec() { - this(Collections., ValueWriter>emptyMap()); - } - - @Override - protected ValueWriter computeValue(Class clazz) { - ValueWriter writer = config.get(clazz); - if (null != writer) { - return writer; - } - if (Number.class.isAssignableFrom(clazz)) { - if (Double.class == clazz) { - return new DoubleWriter(); - } - if (Float.class == clazz) { - return new FloatWriter(); - } - if (Integer.class == clazz) { - return new IntWriter(); - } - if (Long.class == clazz) { - return new LongWriter(); - } - if (Short.class == clazz) { - return new ShortWriter(); - } - // This is some other Number type, that will be treated as a metric by the - // serializer, so let's write out its double value to be protocol compatible - return new NumberDoubleWriter(); - } - if (clazz.isArray()) { - if (byte[].class == clazz) { - return new ByteArrayWriter(); - } - if (int[].class == clazz) { - return new IntArrayWriter(); - } - if (long[].class == clazz) { - return new LongArrayWriter(); - } - if (double[].class == clazz) { - return new DoubleArrayWriter(); - } - if (float[].class == clazz) { - return new FloatArrayWriter(); - } - if (short[].class == clazz) { - return new ShortArrayWriter(); - } - if (char[].class == clazz) { - return new CharArrayWriter(); - } - if (boolean[].class == clazz) { - return new BooleanArrayWriter(); - } - return new ObjectArrayWriter(); + public Codec(Map, ValueWriter> config) { + this.config = config; } - if (Boolean.class == clazz) { - return new BooleanWriter(); - } - if (CharSequence.class.isAssignableFrom(clazz)) { - return CharSequenceWriter.INSTANCE; - } - if (Map.class.isAssignableFrom(clazz)) { - return new MapWriter(); - } - if (Collection.class.isAssignableFrom(clazz)) { - return new CollectionWriter(); + + @SuppressWarnings("unchecked") + public Codec() { + this(Collections., ValueWriter>emptyMap()); } - if (ByteBuffer.class.isAssignableFrom(clazz)) { - return new ByteBufferWriter(); + + @Override + protected ValueWriter computeValue(Class clazz) { + ValueWriter writer = config.get(clazz); + if (null != writer) { + return writer; + } + if (Number.class.isAssignableFrom(clazz)) { + if (Double.class == clazz) { + return new DoubleWriter(); + } + if (Float.class == clazz) { + return new FloatWriter(); + } + if (Integer.class == clazz) { + return new IntWriter(); + } + if (Long.class == clazz) { + return new LongWriter(); + } + if (Short.class == clazz) { + return new ShortWriter(); + } + // This is some other Number type, that will be treated as a metric by the + // serializer, so let's write out its double value to be protocol compatible + return new NumberDoubleWriter(); + } + if (clazz.isArray()) { + if (byte[].class == clazz) { + return new ByteArrayWriter(); + } + if (int[].class == clazz) { + return new IntArrayWriter(); + } + if (long[].class == clazz) { + return new LongArrayWriter(); + } + if (double[].class == clazz) { + return new DoubleArrayWriter(); + } + if (float[].class == clazz) { + return new FloatArrayWriter(); + } + if (short[].class == clazz) { + return new ShortArrayWriter(); + } + if (char[].class == clazz) { + return new CharArrayWriter(); + } + if (boolean[].class == clazz) { + return new BooleanArrayWriter(); + } + return new ObjectArrayWriter(); + } + if (Boolean.class == clazz) { + return new BooleanWriter(); + } + if (CharSequence.class.isAssignableFrom(clazz)) { + return CharSequenceWriter.INSTANCE; + } + if (Map.class.isAssignableFrom(clazz)) { + return new MapWriter(); + } + if (Collection.class.isAssignableFrom(clazz)) { + return new CollectionWriter(); + } + if (ByteBuffer.class.isAssignableFrom(clazz)) { + return new ByteBufferWriter(); + } + return DefaultWriter.INSTANCE; } - return DefaultWriter.INSTANCE; - } - private static final class IntArrayWriter implements ValueWriter { + private static final class IntArrayWriter implements ValueWriter { - @Override - public void write(int[] value, Writable packer, EncodingCache encodingCache) { - packer.startArray(value.length); - for (int i : value) { - packer.writeInt(i); - } + @Override + public void write(int[] value, Writable packer, EncodingCache encodingCache) { + packer.startArray(value.length); + for (int i : value) { + packer.writeInt(i); + } + } } - } - private static final class ShortArrayWriter implements ValueWriter { + private static final class ShortArrayWriter implements ValueWriter { - @Override - public void write(short[] value, Writable packer, EncodingCache encodingCache) { - packer.startArray(value.length); - for (short i : value) { - packer.writeInt(i); - } + @Override + public void write(short[] value, Writable packer, EncodingCache encodingCache) { + packer.startArray(value.length); + for (short i : value) { + packer.writeInt(i); + } + } } - } - private static final class ByteArrayWriter implements ValueWriter { + private static final class ByteArrayWriter implements ValueWriter { - @Override - public void write(byte[] value, Writable packer, EncodingCache encodingCache) { - packer.writeBinary(value, 0, value.length); + @Override + public void write(byte[] value, Writable packer, EncodingCache encodingCache) { + packer.writeBinary(value, 0, value.length); + } } - } - private static final class ByteBufferWriter implements ValueWriter { + private static final class ByteBufferWriter implements ValueWriter { - @Override - public void write(ByteBuffer buffer, Writable packer, EncodingCache encodingCache) { - packer.writeBinary(buffer); + @Override + public void write(ByteBuffer buffer, Writable packer, EncodingCache encodingCache) { + packer.writeBinary(buffer); + } } - } - private static final class BooleanArrayWriter implements ValueWriter { + private static final class BooleanArrayWriter implements ValueWriter { - @Override - public void write(boolean[] value, Writable packer, EncodingCache encodingCache) { - packer.startArray(value.length); - for (boolean i : value) { - packer.writeBoolean(i); - } + @Override + public void write(boolean[] value, Writable packer, EncodingCache encodingCache) { + packer.startArray(value.length); + for (boolean i : value) { + packer.writeBoolean(i); + } + } } - } - private static final class DoubleArrayWriter implements ValueWriter { + private static final class DoubleArrayWriter implements ValueWriter { - @Override - public void write(double[] value, Writable packer, EncodingCache encodingCache) { - packer.startArray(value.length); - for (double i : value) { - packer.writeDouble(i); - } + @Override + public void write(double[] value, Writable packer, EncodingCache encodingCache) { + packer.startArray(value.length); + for (double i : value) { + packer.writeDouble(i); + } + } } - } - private static final class FloatArrayWriter implements ValueWriter { + private static final class FloatArrayWriter implements ValueWriter { - @Override - public void write(float[] value, Writable packer, EncodingCache encodingCache) { - packer.startArray(value.length); - for (float i : value) { - packer.writeFloat(i); - } + @Override + public void write(float[] value, Writable packer, EncodingCache encodingCache) { + packer.startArray(value.length); + for (float i : value) { + packer.writeFloat(i); + } + } } - } - private static final class LongArrayWriter implements ValueWriter { + private static final class LongArrayWriter implements ValueWriter { - @Override - public void write(long[] value, Writable packer, EncodingCache encodingCache) { - packer.startArray(value.length); - for (long i : value) { - packer.writeLong(i); - } + @Override + public void write(long[] value, Writable packer, EncodingCache encodingCache) { + packer.startArray(value.length); + for (long i : value) { + packer.writeLong(i); + } + } } - } - private static final class CollectionWriter implements ValueWriter> { + private static final class CollectionWriter implements ValueWriter> { - @Override - public void write(Collection collection, Writable packer, EncodingCache encodingCache) { - packer.startArray(collection.size()); - for (Object value : collection) { - packer.writeObject(value, encodingCache); - } + @Override + public void write(Collection collection, Writable packer, EncodingCache encodingCache) { + packer.startArray(collection.size()); + for (Object value : collection) { + packer.writeObject(value, encodingCache); + } + } } - } - private static final class ObjectArrayWriter implements ValueWriter { + private static final class ObjectArrayWriter implements ValueWriter { - @Override - public void write(Object[] array, Writable packer, EncodingCache encodingCache) { - packer.startArray(array.length); - for (Object value : array) { - packer.writeObject(value, encodingCache); - } + @Override + public void write(Object[] array, Writable packer, EncodingCache encodingCache) { + packer.startArray(array.length); + for (Object value : array) { + packer.writeObject(value, encodingCache); + } + } } - } - private static final class MapWriter implements ValueWriter> { + private static final class MapWriter implements ValueWriter> { - @Override - public void write( - Map value, Writable packer, EncodingCache encodingCache) { - packer.writeMap(value, encodingCache); + @Override + public void write(Map value, Writable packer, EncodingCache encodingCache) { + packer.writeMap(value, encodingCache); + } } - } - private static final class DoubleWriter implements ValueWriter { + private static final class DoubleWriter implements ValueWriter { - @Override - public void write(Double value, Writable packer, EncodingCache encodingCache) { - packer.writeDouble(value); + @Override + public void write(Double value, Writable packer, EncodingCache encodingCache) { + packer.writeDouble(value); + } } - } - private static final class BooleanWriter implements ValueWriter { + private static final class BooleanWriter implements ValueWriter { - @Override - public void write(Boolean value, Writable packer, EncodingCache encodingCache) { - packer.writeBoolean(value); + @Override + public void write(Boolean value, Writable packer, EncodingCache encodingCache) { + packer.writeBoolean(value); + } } - } - private static final class FloatWriter implements ValueWriter { + private static final class FloatWriter implements ValueWriter { - @Override - public void write(Float value, Writable packer, EncodingCache encodingCache) { - packer.writeFloat(value); + @Override + public void write(Float value, Writable packer, EncodingCache encodingCache) { + packer.writeFloat(value); + } } - } - private static final class IntWriter implements ValueWriter { + private static final class IntWriter implements ValueWriter { - @Override - public void write(Integer value, Writable packer, EncodingCache encodingCache) { - packer.writeInt(value); + @Override + public void write(Integer value, Writable packer, EncodingCache encodingCache) { + packer.writeInt(value); + } } - } - private static final class ShortWriter implements ValueWriter { + private static final class ShortWriter implements ValueWriter { - @Override - public void write(Short value, Writable packer, EncodingCache encodingCache) { - packer.writeInt(value); + @Override + public void write(Short value, Writable packer, EncodingCache encodingCache) { + packer.writeInt(value); + } } - } - private static final class LongWriter implements ValueWriter { + private static final class LongWriter implements ValueWriter { - @Override - public void write(Long value, Writable packer, EncodingCache encodingCache) { - packer.writeLong(value); + @Override + public void write(Long value, Writable packer, EncodingCache encodingCache) { + packer.writeLong(value); + } } - } - private static final class NumberDoubleWriter implements ValueWriter { + private static final class NumberDoubleWriter implements ValueWriter { - @Override - public void write(Number value, Writable packer, EncodingCache encodingCache) { - packer.writeDouble(value.doubleValue()); + @Override + public void write(Number value, Writable packer, EncodingCache encodingCache) { + packer.writeDouble(value.doubleValue()); + } } - } - private static final class CharSequenceWriter implements ValueWriter { + private static final class CharSequenceWriter implements ValueWriter { - public static final CharSequenceWriter INSTANCE = new CharSequenceWriter(); + public static final CharSequenceWriter INSTANCE = new CharSequenceWriter(); - @Override - public void write(CharSequence value, Writable packer, EncodingCache encodingCache) { - packer.writeString(value, encodingCache); + @Override + public void write(CharSequence value, Writable packer, EncodingCache encodingCache) { + packer.writeString(value, encodingCache); + } } - } - private static final class CharArrayWriter implements ValueWriter { + private static final class CharArrayWriter implements ValueWriter { - @Override - public void write(char[] value, Writable packer, EncodingCache encodingCache) { - packer.writeString(CharBuffer.wrap(value), null); + @Override + public void write(char[] value, Writable packer, EncodingCache encodingCache) { + packer.writeString(CharBuffer.wrap(value), null); + } } - } - private static final class DefaultWriter implements ValueWriter { + private static final class DefaultWriter implements ValueWriter { - public static final DefaultWriter INSTANCE = new DefaultWriter(); + public static final DefaultWriter INSTANCE = new DefaultWriter(); - @Override - public void write(Object value, Writable packer, EncodingCache encodingCache) { - CharSequenceWriter.INSTANCE.write(String.valueOf(value), packer, null); + @Override + public void write(Object value, Writable packer, EncodingCache encodingCache) { + CharSequenceWriter.INSTANCE.write(String.valueOf(value), packer, null); + } } - } } diff --git a/communication/src/main/java/datadog/communication/serialization/EncodingCache.java b/communication/src/main/java/datadog/communication/serialization/EncodingCache.java index 4833eb8695a..036c8535729 100644 --- a/communication/src/main/java/datadog/communication/serialization/EncodingCache.java +++ b/communication/src/main/java/datadog/communication/serialization/EncodingCache.java @@ -3,5 +3,5 @@ // TODO @FunctionalInterface public interface EncodingCache { - byte[] encode(CharSequence s); + byte[] encode(CharSequence s); } diff --git a/communication/src/main/java/datadog/communication/serialization/FlushingBuffer.java b/communication/src/main/java/datadog/communication/serialization/FlushingBuffer.java index e3087bd5f27..c0e5fc55bb4 100644 --- a/communication/src/main/java/datadog/communication/serialization/FlushingBuffer.java +++ b/communication/src/main/java/datadog/communication/serialization/FlushingBuffer.java @@ -5,110 +5,110 @@ public final class FlushingBuffer implements StreamingBuffer { - private final ByteBuffer buffer; - private final ByteBufferConsumer consumer; - - private int messageCount; - private int mark; - - public FlushingBuffer(int capacity, ByteBufferConsumer consumer) { - this.buffer = ByteBuffer.allocate(capacity); - this.consumer = consumer; - } - - @Override - public int capacity() { - return buffer.capacity(); - } - - @Override - public boolean isDirty() { - return mark > 0; - } - - @Override - public void mark() { - int current = buffer.position(); - if (current != mark) { - // count only non-empty messages - ++messageCount; - mark = current; - } - } - - @Override - public boolean flush() { - if (messageCount == 0) { - return false; - } - buffer.limit(mark); - buffer.flip(); - ByteBuffer toPublish = buffer.slice(); - consumer.accept(messageCount, toPublish); - reset(); - return true; - } - - @Override - public void put(byte b) { - buffer.put(b); - } - - @Override - public void putShort(short s) { - buffer.putShort(s); - } - - @Override - public void putChar(char c) { - buffer.putChar(c); - } - - @Override - public void putInt(int i) { - buffer.putInt(i); - } - - @Override - public void putLong(long l) { - buffer.putLong(l); - } - - @Override - public void putFloat(float f) { - buffer.putFloat(f); - } - - @Override - public void putDouble(double d) { - buffer.putDouble(d); - } - - @Override - public void put(byte[] bytes) { - buffer.put(bytes); - } - - @Override - public void put(byte[] bytes, int offset, int length) { - buffer.put(bytes, offset, length); - } - - @Override - public void put(ByteBuffer buffer) { - this.buffer.put(buffer); - } - - @Override - public void reset() { - messageCount = 0; - buffer.position(0); - buffer.limit(buffer.capacity()); - mark = 0; - } - - @VisibleForTesting - int getMessageCount() { - return messageCount; - } + private final ByteBuffer buffer; + private final ByteBufferConsumer consumer; + + private int messageCount; + private int mark; + + public FlushingBuffer(int capacity, ByteBufferConsumer consumer) { + this.buffer = ByteBuffer.allocate(capacity); + this.consumer = consumer; + } + + @Override + public int capacity() { + return buffer.capacity(); + } + + @Override + public boolean isDirty() { + return mark > 0; + } + + @Override + public void mark() { + int current = buffer.position(); + if (current != mark) { + // count only non-empty messages + ++messageCount; + mark = current; + } + } + + @Override + public boolean flush() { + if (messageCount == 0) { + return false; + } + buffer.limit(mark); + buffer.flip(); + ByteBuffer toPublish = buffer.slice(); + consumer.accept(messageCount, toPublish); + reset(); + return true; + } + + @Override + public void put(byte b) { + buffer.put(b); + } + + @Override + public void putShort(short s) { + buffer.putShort(s); + } + + @Override + public void putChar(char c) { + buffer.putChar(c); + } + + @Override + public void putInt(int i) { + buffer.putInt(i); + } + + @Override + public void putLong(long l) { + buffer.putLong(l); + } + + @Override + public void putFloat(float f) { + buffer.putFloat(f); + } + + @Override + public void putDouble(double d) { + buffer.putDouble(d); + } + + @Override + public void put(byte[] bytes) { + buffer.put(bytes); + } + + @Override + public void put(byte[] bytes, int offset, int length) { + buffer.put(bytes, offset, length); + } + + @Override + public void put(ByteBuffer buffer) { + this.buffer.put(buffer); + } + + @Override + public void reset() { + messageCount = 0; + buffer.position(0); + buffer.limit(buffer.capacity()); + mark = 0; + } + + @VisibleForTesting + int getMessageCount() { + return messageCount; + } } diff --git a/communication/src/main/java/datadog/communication/serialization/GenerationalUtf8Cache.java b/communication/src/main/java/datadog/communication/serialization/GenerationalUtf8Cache.java index 099ff56e0e0..d6c71095635 100644 --- a/communication/src/main/java/datadog/communication/serialization/GenerationalUtf8Cache.java +++ b/communication/src/main/java/datadog/communication/serialization/GenerationalUtf8Cache.java @@ -65,406 +65,402 @@ */ @ThreadSafe @SuppressFBWarnings( - value = "IS2_INCONSISTENT_SYNC", - justification = - "stat updates are deliberately racy - sync is only used to prevent simultaneous bulk updates") + value = "IS2_INCONSISTENT_SYNC", + justification = "stat updates are deliberately racy - sync is only used to prevent simultaneous bulk updates") public final class GenerationalUtf8Cache implements EncodingCache { - static final int MAX_EDEN_CAPACITY = 512; - static final int MAX_TENURED_CAPACITY = 1024; + static final int MAX_EDEN_CAPACITY = 512; + static final int MAX_TENURED_CAPACITY = 1024; - private static final int MAX_EDEN_PROBES = 4; - private static final int MAX_TENURED_PROBES = 8; - - private static final int MIN_PROMOTION_THRESHOLD = 2; - private static final int INITIAL_PROMOTION_THRESHOLD = 16; - - private static final double SCORE_DECAY = 0.5D; - private static final double PURGE_THRESHOLD = 0.25D; - private static final double PROMOTION_THRESHOLD_ADJ_FACTOR = 1.5; - - private static final double EDEN_PROPORTION = 1D / 3D; - private static final double TENURED_PROPORTION = 1 - EDEN_PROPORTION; - - static final int MAX_ENTRY_LEN = 256; - - final CacheEntry[] edenEntries; - private final int[] edenMarkers; - - final CacheEntry[] tenuredEntries; - - private long accessTimeMs; - private double promotionThreshold = INITIAL_PROMOTION_THRESHOLD; - - int edenHits = 0; - int tenuredHits = 0; - int earlyPromotions = 0; - int promotions = 0; - int edenEvictions = 0; - int tenuredEvictions = 0; - - public GenerationalUtf8Cache(int capacity) { - this.accessTimeMs = System.currentTimeMillis(); - - int edenCapacity = (int) (capacity * EDEN_PROPORTION); - int edenSize = Caching.cacheSizeFor(Math.min(edenCapacity, MAX_EDEN_CAPACITY)); - - // These sizes must be powers of 2 - this.edenEntries = new CacheEntry[edenSize]; - this.edenMarkers = new int[edenSize]; - - int tenuredCapacity = (int) (capacity * TENURED_PROPORTION); - int tenuredSize = Caching.cacheSizeFor(Math.min(tenuredCapacity, MAX_TENURED_CAPACITY)); - - // The size must be a power of 2 - this.tenuredEntries = new CacheEntry[tenuredSize]; - } - - public GenerationalUtf8Cache(int edenCapacity, int tenuredCapacity) { - this.accessTimeMs = System.currentTimeMillis(); - - int edenSize = Caching.cacheSizeFor(Math.min(edenCapacity, MAX_EDEN_CAPACITY)); - this.edenEntries = new CacheEntry[edenSize]; - this.edenMarkers = new int[edenSize]; - - int tenuredSize = Caching.cacheSizeFor(Math.min(tenuredCapacity, MAX_TENURED_CAPACITY)); - this.tenuredEntries = new CacheEntry[tenuredSize]; - } - - public int edenCapacity() { - return this.edenEntries.length; - } - - public int tenuredCapacity() { - return this.tenuredEntries.length; - } - - /** Updates the access time used by {@link #getUtf8(String)} to the provided value. */ - @SuppressFBWarnings("AT_NONATOMIC_64BIT_PRIMITIVE") - public void updateAccessTime(long accessTimeMs) { - this.accessTimeMs = accessTimeMs; - } - - /** Updates access time to the @link {@link System#currentTimeMillis()} */ - public void refreshAccessTime() { - this.updateAccessTime(System.currentTimeMillis()); - } - - public synchronized void recalibrate() { - this.recalibrate(System.currentTimeMillis()); - } - - /** - * Recalibrates the cache Applies a decay to existing entries - and purges entries below the - * PURGE_THRESHOLD - * - *

Adjusts the promotion threshold depending on ratio of promotions to evictions, since prior - * recalibration - * - *

While still racy this method is synchronized to avoid simultaneous recalibrations - */ - public synchronized void recalibrate(long accessTimeMs) { - this.accessTimeMs = accessTimeMs; - - recalibrate(this.edenEntries); - Caching.reset(this.edenMarkers); - recalibrate(this.tenuredEntries); - - int totalPromotions = this.promotions + this.earlyPromotions; - if (totalPromotions == 0 && this.promotionThreshold >= MIN_PROMOTION_THRESHOLD) { - this.promotionThreshold /= PROMOTION_THRESHOLD_ADJ_FACTOR; - } else if (totalPromotions > this.tenuredEvictions / 2) { - this.promotionThreshold *= PROMOTION_THRESHOLD_ADJ_FACTOR; - } + private static final int MAX_EDEN_PROBES = 4; + private static final int MAX_TENURED_PROBES = 8; - this.edenHits = 0; - this.tenuredHits = 0; - this.earlyPromotions = 0; - this.promotions = 0; - this.edenEvictions = 0; - this.tenuredEvictions = 0; - } - - static final void recalibrate(CacheEntry[] entries) { - for (int i = 0; i < entries.length; ++i) { - CacheEntry entry = entries[i]; - if (entry == null) continue; - - boolean purge = entry.decay(); - if (purge) entries[i] = null; - } - } - - @Override - public byte[] encode(CharSequence charSeq) { - if (charSeq instanceof String) { - String str = (String) charSeq; - return this.getUtf8(str); - } else { - return null; - } - } - - /** Returns the UTF-8 encoding of value -- using a cache value if available */ - public final byte[] getUtf8(String value) { - return this.getUtf8(value, this.accessTimeMs); - } - - /** - * Returns the UTF-8 encoding of value -- using a cache value if available If there is cache hit, - * the specified accessTimeMs is used to update the cache entry - */ - public final byte[] getUtf8(String value, long accessTimeMs) { - if (value.length() > MAX_ENTRY_LEN) return CacheEntry.utf8(value); - - int adjHash = Caching.adjHash(value); - - CacheEntry[] tenuredEntries = this.tenuredEntries; - int matchingTenuredIndex = lookupEntryIndex(tenuredEntries, MAX_TENURED_PROBES, adjHash, value); - if (matchingTenuredIndex != -1) { - // The slot can be mutated concurrently between the lookup and this read: nulled (recalibrate - // purge / eviction) or reassigned to a *different* value. CacheEntry identity is immutable - // (adjHash/value/valueUtf8 are final), so re-validate the loaded reference against the - // request; anything but a match means the slot moved out from under us, so fall through and - // treat it as a miss rather than NPE'ing (null) or returning another value's bytes - // (reassigned). - CacheEntry tenuredEntry = tenuredEntries[matchingTenuredIndex]; - if (tenuredEntry != null && tenuredEntry.matches(adjHash, value)) { - tenuredEntry.hit(accessTimeMs); - - this.tenuredHits += 1; - return tenuredEntry.utf8(); - } - } + private static final int MIN_PROMOTION_THRESHOLD = 2; + private static final int INITIAL_PROMOTION_THRESHOLD = 16; - CacheEntry[] edenEntries = this.edenEntries; - int matchingEdenIndex = lookupEntryIndex(edenEntries, MAX_EDEN_PROBES, adjHash, value); - if (matchingEdenIndex != -1) { - // Same lookup-then-read race as tenured, plus concurrent promotion nulls the slot (line - // below); re-validate the loaded reference and treat null-or-mismatch as a miss. - CacheEntry edenEntry = edenEntries[matchingEdenIndex]; - if (edenEntry != null && edenEntry.matches(adjHash, value)) { - double hits = edenEntry.hit(accessTimeMs); - if (hits > this.promotionThreshold) { - // mark promoted first - to avoid racy insertions - this.promotions += 1; - - boolean evicted = lruInsert(this.tenuredEntries, MAX_TENURED_PROBES, edenEntry); - if (evicted) this.tenuredEvictions += 1; - - edenEntries[matchingEdenIndex] = null; - } + private static final double SCORE_DECAY = 0.5D; + private static final double PURGE_THRESHOLD = 0.25D; + private static final double PROMOTION_THRESHOLD_ADJ_FACTOR = 1.5; - this.edenHits += 1; - return edenEntry.utf8(); - } - } + private static final double EDEN_PROPORTION = 1D / 3D; + private static final double TENURED_PROPORTION = 1 - EDEN_PROPORTION; - boolean wasMarked = Caching.mark(this.edenMarkers, adjHash); + static final int MAX_ENTRY_LEN = 256; - // If slot isn't marked, this is likely the first request - // Don't create an entry yet - if (!wasMarked) return CacheEntry.utf8(value); + final CacheEntry[] edenEntries; + private final int[] edenMarkers; - CacheEntry newEntry = new CacheEntry(adjHash, value); - // First request was swallowed by marking, so double hit - newEntry.hit(accessTimeMs); - newEntry.hit(accessTimeMs); + final CacheEntry[] tenuredEntries; - // search for empty slot or failing that the MFU entry - int edenMfuIndex = findFirstAvailableOrMfuIndex(edenEntries, MAX_EDEN_PROBES, adjHash); - CacheEntry edenMfuEntry = edenEntries[edenMfuIndex]; + private long accessTimeMs; + private double promotionThreshold = INITIAL_PROMOTION_THRESHOLD; - // Found an empty slot - fill it - if (edenMfuEntry == null) { - edenEntries[edenMfuIndex] = newEntry; - return newEntry.utf8(); - } + int edenHits = 0; + int tenuredHits = 0; + int earlyPromotions = 0; + int promotions = 0; + int edenEvictions = 0; + int tenuredEvictions = 0; + + public GenerationalUtf8Cache(int capacity) { + this.accessTimeMs = System.currentTimeMillis(); - // See if we can early promote the local MFU entry into the global cache - // Early promotion doesn't evict from the global cache + int edenCapacity = (int) (capacity * EDEN_PROPORTION); + int edenSize = Caching.cacheSizeFor(Math.min(edenCapacity, MAX_EDEN_CAPACITY)); - // NOTE: Need to make sure to use hash of the entry being promoted, - // since it may differ from the requested hash - int tenuredAvailableIndex = - findAvailableIndex(tenuredEntries, MAX_TENURED_PROBES, edenMfuEntry.adjHash()); - if (tenuredAvailableIndex != -1) { - tenuredEntries[tenuredAvailableIndex] = edenMfuEntry; - this.earlyPromotions += 1; + // These sizes must be powers of 2 + this.edenEntries = new CacheEntry[edenSize]; + this.edenMarkers = new int[edenSize]; - edenEntries[edenMfuIndex] = newEntry; - return newEntry.utf8(); + int tenuredCapacity = (int) (capacity * TENURED_PROPORTION); + int tenuredSize = Caching.cacheSizeFor(Math.min(tenuredCapacity, MAX_TENURED_CAPACITY)); + + // The size must be a power of 2 + this.tenuredEntries = new CacheEntry[tenuredSize]; } - // No empty slot - or space to promote into the global cache - // Insert into local cache while evicting the LFU - boolean evicted = lfuInsert(edenEntries, MAX_EDEN_PROBES, newEntry); - if (evicted) this.edenEvictions += 1; + public GenerationalUtf8Cache(int edenCapacity, int tenuredCapacity) { + this.accessTimeMs = System.currentTimeMillis(); - return newEntry.utf8(); - } + int edenSize = Caching.cacheSizeFor(Math.min(edenCapacity, MAX_EDEN_CAPACITY)); + this.edenEntries = new CacheEntry[edenSize]; + this.edenMarkers = new int[edenSize]; - static final int findAvailableIndex(CacheEntry[] entries, int numProbes, int newAdjHash) { - int initialBucketIndex = Caching.bucketIndex(entries, newAdjHash); - for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { - if (index >= entries.length) index = 0; + int tenuredSize = Caching.cacheSizeFor(Math.min(tenuredCapacity, MAX_TENURED_CAPACITY)); + this.tenuredEntries = new CacheEntry[tenuredSize]; + } - CacheEntry entry = entries[index]; - if (entry == null || entry.isPurgeable()) return index; + public int edenCapacity() { + return this.edenEntries.length; } - return -1; - } - - static final int findFirstAvailableOrMfuIndex( - CacheEntry[] entries, int numProbes, int newAdjHash) { - double mfuScore = Double.MIN_VALUE; - int mfuIndex = -1; - - int initialBucketIndex = Caching.bucketIndex(entries, newAdjHash); - for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { - if (index >= entries.length) index = 0; - - CacheEntry entry = entries[index]; - if (entry == null) return index; - - double score = entry.score(); - if (score > mfuScore) { - mfuScore = score; - mfuIndex = index; - } + + public int tenuredCapacity() { + return this.tenuredEntries.length; } - return mfuIndex; - } - - static final boolean lfuInsert(CacheEntry[] entries, int numProbes, CacheEntry newEntry) { - int initialBucketIndex = Caching.bucketIndex(entries, newEntry.adjHash()); - - // initial scan to see if there's an empty slot or marker entry is already present - double lowestScore = Double.MAX_VALUE; - int lfuIndex = -1; - for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { - if (index >= entries.length) index = 0; - - CacheEntry entry = entries[index]; - if (entry == null || entry.isPurgeable()) { - entries[index] = newEntry; - return false; - } else { - double score = entry.score(); - if (score < lowestScore) { - lowestScore = score; - lfuIndex = index; - } - } + + /** Updates the access time used by {@link #getUtf8(String)} to the provided value. */ + @SuppressFBWarnings("AT_NONATOMIC_64BIT_PRIMITIVE") + public void updateAccessTime(long accessTimeMs) { + this.accessTimeMs = accessTimeMs; } - // If we get here, then we're evicting the LFU - entries[lfuIndex] = newEntry; - return true; - } - - static final boolean lruInsert(CacheEntry[] entries, int numProbes, CacheEntry newEntry) { - int initialBucketIndex = Caching.bucketIndex(entries, newEntry.adjHash()); - - // initial scan to see if there's an empty slot or entry is already present - long lowestUsedMs = Long.MAX_VALUE; - int lruIndex = -1; - for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { - if (index >= entries.length) index = 0; - - CacheEntry entry = entries[index]; - if (entry == null || entry.matches(newEntry)) { - entries[index] = newEntry; - return false; - } - - long lastUsedMs = entry.lastUsedMs(); - if (lastUsedMs < lowestUsedMs) { - lowestUsedMs = lastUsedMs; - lruIndex = index; - } + /** Updates access time to the @link {@link System#currentTimeMillis()} */ + public void refreshAccessTime() { + this.updateAccessTime(System.currentTimeMillis()); } - entries[lruIndex] = newEntry; - return true; - } + public synchronized void recalibrate() { + this.recalibrate(System.currentTimeMillis()); + } - static final int lookupEntryIndex( - CacheEntry[] entries, int numProbes, int adjHash, String value) { - int initialBucketIndex = Caching.bucketIndex(entries, adjHash); - for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { - if (index >= entries.length) index = 0; + /** + * Recalibrates the cache Applies a decay to existing entries - and purges entries below the + * PURGE_THRESHOLD + * + *

Adjusts the promotion threshold depending on ratio of promotions to evictions, since prior + * recalibration + * + *

While still racy this method is synchronized to avoid simultaneous recalibrations + */ + public synchronized void recalibrate(long accessTimeMs) { + this.accessTimeMs = accessTimeMs; + + recalibrate(this.edenEntries); + Caching.reset(this.edenMarkers); + recalibrate(this.tenuredEntries); + + int totalPromotions = this.promotions + this.earlyPromotions; + if (totalPromotions == 0 && this.promotionThreshold >= MIN_PROMOTION_THRESHOLD) { + this.promotionThreshold /= PROMOTION_THRESHOLD_ADJ_FACTOR; + } else if (totalPromotions > this.tenuredEvictions / 2) { + this.promotionThreshold *= PROMOTION_THRESHOLD_ADJ_FACTOR; + } - CacheEntry entry = entries[index]; - if (entry != null && entry.matches(adjHash, value)) { - return index; - } - } - return -1; - } - - static final class CacheEntry { - final int adjHash; - final String value; - final byte[] valueUtf8; - - boolean promoted = false; - long lastUsedMs = 0; - double score = 0; - - public CacheEntry(int adjHash, String value) { - this.adjHash = adjHash; - this.value = value; - this.valueUtf8 = utf8(value); + this.edenHits = 0; + this.tenuredHits = 0; + this.earlyPromotions = 0; + this.promotions = 0; + this.edenEvictions = 0; + this.tenuredEvictions = 0; } - boolean matches(CacheEntry thatEntry) { - return (this == thatEntry) || this.matches(thatEntry.adjHash, thatEntry.value); - } + static final void recalibrate(CacheEntry[] entries) { + for (int i = 0; i < entries.length; ++i) { + CacheEntry entry = entries[i]; + if (entry == null) continue; - boolean matches(int adjHash, String value) { - return (this.adjHash == adjHash) && value.equals(this.value); + boolean purge = entry.decay(); + if (purge) entries[i] = null; + } } - int adjHash() { - return this.adjHash; + @Override + public byte[] encode(CharSequence charSeq) { + if (charSeq instanceof String) { + String str = (String) charSeq; + return this.getUtf8(str); + } else { + return null; + } } - double score() { - return this.score; + /** Returns the UTF-8 encoding of value -- using a cache value if available */ + public final byte[] getUtf8(String value) { + return this.getUtf8(value, this.accessTimeMs); } - long lastUsedMs() { - return this.lastUsedMs; + /** + * Returns the UTF-8 encoding of value -- using a cache value if available If there is cache hit, + * the specified accessTimeMs is used to update the cache entry + */ + public final byte[] getUtf8(String value, long accessTimeMs) { + if (value.length() > MAX_ENTRY_LEN) return CacheEntry.utf8(value); + + int adjHash = Caching.adjHash(value); + + CacheEntry[] tenuredEntries = this.tenuredEntries; + int matchingTenuredIndex = lookupEntryIndex(tenuredEntries, MAX_TENURED_PROBES, adjHash, value); + if (matchingTenuredIndex != -1) { + // The slot can be mutated concurrently between the lookup and this read: nulled (recalibrate + // purge / eviction) or reassigned to a *different* value. CacheEntry identity is immutable + // (adjHash/value/valueUtf8 are final), so re-validate the loaded reference against the + // request; anything but a match means the slot moved out from under us, so fall through and + // treat it as a miss rather than NPE'ing (null) or returning another value's bytes + // (reassigned). + CacheEntry tenuredEntry = tenuredEntries[matchingTenuredIndex]; + if (tenuredEntry != null && tenuredEntry.matches(adjHash, value)) { + tenuredEntry.hit(accessTimeMs); + + this.tenuredHits += 1; + return tenuredEntry.utf8(); + } + } + + CacheEntry[] edenEntries = this.edenEntries; + int matchingEdenIndex = lookupEntryIndex(edenEntries, MAX_EDEN_PROBES, adjHash, value); + if (matchingEdenIndex != -1) { + // Same lookup-then-read race as tenured, plus concurrent promotion nulls the slot (line + // below); re-validate the loaded reference and treat null-or-mismatch as a miss. + CacheEntry edenEntry = edenEntries[matchingEdenIndex]; + if (edenEntry != null && edenEntry.matches(adjHash, value)) { + double hits = edenEntry.hit(accessTimeMs); + if (hits > this.promotionThreshold) { + // mark promoted first - to avoid racy insertions + this.promotions += 1; + + boolean evicted = lruInsert(this.tenuredEntries, MAX_TENURED_PROBES, edenEntry); + if (evicted) this.tenuredEvictions += 1; + + edenEntries[matchingEdenIndex] = null; + } + + this.edenHits += 1; + return edenEntry.utf8(); + } + } + + boolean wasMarked = Caching.mark(this.edenMarkers, adjHash); + + // If slot isn't marked, this is likely the first request + // Don't create an entry yet + if (!wasMarked) return CacheEntry.utf8(value); + + CacheEntry newEntry = new CacheEntry(adjHash, value); + // First request was swallowed by marking, so double hit + newEntry.hit(accessTimeMs); + newEntry.hit(accessTimeMs); + + // search for empty slot or failing that the MFU entry + int edenMfuIndex = findFirstAvailableOrMfuIndex(edenEntries, MAX_EDEN_PROBES, adjHash); + CacheEntry edenMfuEntry = edenEntries[edenMfuIndex]; + + // Found an empty slot - fill it + if (edenMfuEntry == null) { + edenEntries[edenMfuIndex] = newEntry; + return newEntry.utf8(); + } + + // See if we can early promote the local MFU entry into the global cache + // Early promotion doesn't evict from the global cache + + // NOTE: Need to make sure to use hash of the entry being promoted, + // since it may differ from the requested hash + int tenuredAvailableIndex = findAvailableIndex(tenuredEntries, MAX_TENURED_PROBES, edenMfuEntry.adjHash()); + if (tenuredAvailableIndex != -1) { + tenuredEntries[tenuredAvailableIndex] = edenMfuEntry; + this.earlyPromotions += 1; + + edenEntries[edenMfuIndex] = newEntry; + return newEntry.utf8(); + } + + // No empty slot - or space to promote into the global cache + // Insert into local cache while evicting the LFU + boolean evicted = lfuInsert(edenEntries, MAX_EDEN_PROBES, newEntry); + if (evicted) this.edenEvictions += 1; + + return newEntry.utf8(); } - byte[] utf8() { - return this.valueUtf8; + static final int findAvailableIndex(CacheEntry[] entries, int numProbes, int newAdjHash) { + int initialBucketIndex = Caching.bucketIndex(entries, newAdjHash); + for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { + if (index >= entries.length) index = 0; + + CacheEntry entry = entries[index]; + if (entry == null || entry.isPurgeable()) return index; + } + return -1; } - double hit(long lastUsedMs) { - this.lastUsedMs = lastUsedMs; - this.score += 1; + static final int findFirstAvailableOrMfuIndex(CacheEntry[] entries, int numProbes, int newAdjHash) { + double mfuScore = Double.MIN_VALUE; + int mfuIndex = -1; - return this.score; + int initialBucketIndex = Caching.bucketIndex(entries, newAdjHash); + for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { + if (index >= entries.length) index = 0; + + CacheEntry entry = entries[index]; + if (entry == null) return index; + + double score = entry.score(); + if (score > mfuScore) { + mfuScore = score; + mfuIndex = index; + } + } + return mfuIndex; } - boolean decay() { - this.score *= SCORE_DECAY; + static final boolean lfuInsert(CacheEntry[] entries, int numProbes, CacheEntry newEntry) { + int initialBucketIndex = Caching.bucketIndex(entries, newEntry.adjHash()); + + // initial scan to see if there's an empty slot or marker entry is already present + double lowestScore = Double.MAX_VALUE; + int lfuIndex = -1; + for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { + if (index >= entries.length) index = 0; + + CacheEntry entry = entries[index]; + if (entry == null || entry.isPurgeable()) { + entries[index] = newEntry; + return false; + } else { + double score = entry.score(); + if (score < lowestScore) { + lowestScore = score; + lfuIndex = index; + } + } + } - return this.isPurgeable(); + // If we get here, then we're evicting the LFU + entries[lfuIndex] = newEntry; + return true; } - boolean isPurgeable() { - return (this.score < PURGE_THRESHOLD); + static final boolean lruInsert(CacheEntry[] entries, int numProbes, CacheEntry newEntry) { + int initialBucketIndex = Caching.bucketIndex(entries, newEntry.adjHash()); + + // initial scan to see if there's an empty slot or entry is already present + long lowestUsedMs = Long.MAX_VALUE; + int lruIndex = -1; + for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { + if (index >= entries.length) index = 0; + + CacheEntry entry = entries[index]; + if (entry == null || entry.matches(newEntry)) { + entries[index] = newEntry; + return false; + } + + long lastUsedMs = entry.lastUsedMs(); + if (lastUsedMs < lowestUsedMs) { + lowestUsedMs = lastUsedMs; + lruIndex = index; + } + } + + entries[lruIndex] = newEntry; + return true; } - static final byte[] utf8(String value) { - return value.getBytes(StandardCharsets.UTF_8); + static final int lookupEntryIndex(CacheEntry[] entries, int numProbes, int adjHash, String value) { + int initialBucketIndex = Caching.bucketIndex(entries, adjHash); + for (int probe = 0, index = initialBucketIndex; probe < numProbes; ++probe, ++index) { + if (index >= entries.length) index = 0; + + CacheEntry entry = entries[index]; + if (entry != null && entry.matches(adjHash, value)) { + return index; + } + } + return -1; } - @Override - public String toString() { - return this.value + " - score: " + this.score + " used (ms): " + this.lastUsedMs; + static final class CacheEntry { + final int adjHash; + final String value; + final byte[] valueUtf8; + + boolean promoted = false; + long lastUsedMs = 0; + double score = 0; + + public CacheEntry(int adjHash, String value) { + this.adjHash = adjHash; + this.value = value; + this.valueUtf8 = utf8(value); + } + + boolean matches(CacheEntry thatEntry) { + return (this == thatEntry) || this.matches(thatEntry.adjHash, thatEntry.value); + } + + boolean matches(int adjHash, String value) { + return (this.adjHash == adjHash) && value.equals(this.value); + } + + int adjHash() { + return this.adjHash; + } + + double score() { + return this.score; + } + + long lastUsedMs() { + return this.lastUsedMs; + } + + byte[] utf8() { + return this.valueUtf8; + } + + double hit(long lastUsedMs) { + this.lastUsedMs = lastUsedMs; + this.score += 1; + + return this.score; + } + + boolean decay() { + this.score *= SCORE_DECAY; + + return this.isPurgeable(); + } + + boolean isPurgeable() { + return (this.score < PURGE_THRESHOLD); + } + + static final byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + @Override + public String toString() { + return this.value + " - score: " + this.score + " used (ms): " + this.lastUsedMs; + } } - } } diff --git a/communication/src/main/java/datadog/communication/serialization/GrowableBuffer.java b/communication/src/main/java/datadog/communication/serialization/GrowableBuffer.java index 54894efec52..5cc5665c22c 100644 --- a/communication/src/main/java/datadog/communication/serialization/GrowableBuffer.java +++ b/communication/src/main/java/datadog/communication/serialization/GrowableBuffer.java @@ -10,126 +10,126 @@ */ public final class GrowableBuffer implements StreamingBuffer { - private final int initialCapacity; - private ByteBuffer buffer; - private int messageCount; - - public GrowableBuffer(int requiredCapacity) { - this.initialCapacity = nextPowerOfTwo(requiredCapacity); - this.buffer = ByteBuffer.allocate(initialCapacity); - } - - /** Flips the buffer and returns a new slice which shares the buffered content. */ - public ByteBuffer slice() { - buffer.flip(); - return buffer.slice(); - } - - /** Flips the buffer and returns the buffered content. */ - public ByteBuffer flip() { - buffer.flip(); - return buffer; - } - - public int messageCount() { - return messageCount; - } - - @Override - public void reset() { - messageCount = 0; - buffer.position(0); - buffer.limit(buffer.capacity()); - } - - @Override - public int capacity() { - return buffer.capacity(); - } - - @Override - public boolean isDirty() { - return messageCount > 0; - } - - @Override - public void mark() { - ++messageCount; - } - - @Override - public boolean flush() { - return false; - } - - @Override - public void put(byte b) { - checkCapacity(1); - buffer.put(b); - } - - @Override - public void putShort(short s) { - checkCapacity(2); - buffer.putShort(s); - } - - @Override - public void putChar(char c) { - checkCapacity(2); - buffer.putChar(c); - } - - @Override - public void putInt(int i) { - checkCapacity(4); - buffer.putInt(i); - } - - @Override - public void putLong(long l) { - checkCapacity(8); - buffer.putLong(l); - } - - @Override - public void putFloat(float f) { - checkCapacity(4); - buffer.putFloat(f); - } - - @Override - public void putDouble(double d) { - checkCapacity(8); - buffer.putDouble(d); - } - - @Override - public void put(byte[] bytes) { - checkCapacity(bytes.length); - buffer.put(bytes); - } - - @Override - public void put(byte[] bytes, int offset, int length) { - checkCapacity(length); - buffer.put(bytes, offset, length); - } - - @Override - public void put(ByteBuffer buffer) { - checkCapacity(buffer.remaining()); - this.buffer.put(buffer); - } - - private void checkCapacity(int required) { - if (buffer.remaining() < required) { - // round up to next multiple of initialCapacity that can accommodate required - int newSize = (buffer.capacity() + required + initialCapacity - 1) & -initialCapacity; - ByteBuffer newBuffer = ByteBuffer.allocate(newSize); - buffer.flip(); - newBuffer.put(buffer); - buffer = newBuffer; - } - } + private final int initialCapacity; + private ByteBuffer buffer; + private int messageCount; + + public GrowableBuffer(int requiredCapacity) { + this.initialCapacity = nextPowerOfTwo(requiredCapacity); + this.buffer = ByteBuffer.allocate(initialCapacity); + } + + /** Flips the buffer and returns a new slice which shares the buffered content. */ + public ByteBuffer slice() { + buffer.flip(); + return buffer.slice(); + } + + /** Flips the buffer and returns the buffered content. */ + public ByteBuffer flip() { + buffer.flip(); + return buffer; + } + + public int messageCount() { + return messageCount; + } + + @Override + public void reset() { + messageCount = 0; + buffer.position(0); + buffer.limit(buffer.capacity()); + } + + @Override + public int capacity() { + return buffer.capacity(); + } + + @Override + public boolean isDirty() { + return messageCount > 0; + } + + @Override + public void mark() { + ++messageCount; + } + + @Override + public boolean flush() { + return false; + } + + @Override + public void put(byte b) { + checkCapacity(1); + buffer.put(b); + } + + @Override + public void putShort(short s) { + checkCapacity(2); + buffer.putShort(s); + } + + @Override + public void putChar(char c) { + checkCapacity(2); + buffer.putChar(c); + } + + @Override + public void putInt(int i) { + checkCapacity(4); + buffer.putInt(i); + } + + @Override + public void putLong(long l) { + checkCapacity(8); + buffer.putLong(l); + } + + @Override + public void putFloat(float f) { + checkCapacity(4); + buffer.putFloat(f); + } + + @Override + public void putDouble(double d) { + checkCapacity(8); + buffer.putDouble(d); + } + + @Override + public void put(byte[] bytes) { + checkCapacity(bytes.length); + buffer.put(bytes); + } + + @Override + public void put(byte[] bytes, int offset, int length) { + checkCapacity(length); + buffer.put(bytes, offset, length); + } + + @Override + public void put(ByteBuffer buffer) { + checkCapacity(buffer.remaining()); + this.buffer.put(buffer); + } + + private void checkCapacity(int required) { + if (buffer.remaining() < required) { + // round up to next multiple of initialCapacity that can accommodate required + int newSize = (buffer.capacity() + required + initialCapacity - 1) & -initialCapacity; + ByteBuffer newBuffer = ByteBuffer.allocate(newSize); + buffer.flip(); + newBuffer.put(buffer); + buffer = newBuffer; + } + } } diff --git a/communication/src/main/java/datadog/communication/serialization/Mapper.java b/communication/src/main/java/datadog/communication/serialization/Mapper.java index 71c38641abc..cf49355c4ff 100644 --- a/communication/src/main/java/datadog/communication/serialization/Mapper.java +++ b/communication/src/main/java/datadog/communication/serialization/Mapper.java @@ -2,11 +2,11 @@ @FunctionalInterface public interface Mapper { - void map(T data, Writable packer); + void map(T data, Writable packer); - default void map(T data, Writable packer, boolean retry) { - map(data, packer); - } + default void map(T data, Writable packer, boolean retry) { + map(data, packer); + } - default void reset() {} + default void reset() {} } diff --git a/communication/src/main/java/datadog/communication/serialization/MessageFormatter.java b/communication/src/main/java/datadog/communication/serialization/MessageFormatter.java index 4ddb934483d..7863d54ff7a 100644 --- a/communication/src/main/java/datadog/communication/serialization/MessageFormatter.java +++ b/communication/src/main/java/datadog/communication/serialization/MessageFormatter.java @@ -1,7 +1,7 @@ package datadog.communication.serialization; public interface MessageFormatter { - boolean format(T message, Mapper mapper); + boolean format(T message, Mapper mapper); - void flush(); + void flush(); } diff --git a/communication/src/main/java/datadog/communication/serialization/SimpleUtf8Cache.java b/communication/src/main/java/datadog/communication/serialization/SimpleUtf8Cache.java index 8eb12d48465..0b9715767c8 100644 --- a/communication/src/main/java/datadog/communication/serialization/SimpleUtf8Cache.java +++ b/communication/src/main/java/datadog/communication/serialization/SimpleUtf8Cache.java @@ -44,182 +44,182 @@ */ @ThreadSafe public final class SimpleUtf8Cache implements EncodingCache { - static final int MAX_CAPACITY = 1024; + static final int MAX_CAPACITY = 1024; - private static final int MAX_PROBES = 4; + private static final int MAX_PROBES = 4; - private final int[] markers; - private final CacheEntry[] entries; + private final int[] markers; + private final CacheEntry[] entries; - private static final double HIT_DECAY = 0.5D; - private static final double PURGE_THRESHOLD = 0.25D; + private static final double HIT_DECAY = 0.5D; + private static final double PURGE_THRESHOLD = 0.25D; - static final int MAX_ENTRY_LEN = 128; + static final int MAX_ENTRY_LEN = 128; - protected int hits = 0; - protected int evictions = 0; + protected int hits = 0; + protected int evictions = 0; - public SimpleUtf8Cache(int capacity) { - int size = Caching.cacheSizeFor(Math.min(capacity, MAX_CAPACITY)); + public SimpleUtf8Cache(int capacity) { + int size = Caching.cacheSizeFor(Math.min(capacity, MAX_CAPACITY)); - this.markers = new int[size]; - this.entries = new CacheEntry[size]; - } - - public int capacity() { - return this.entries.length; - } - - /** - * Recalibrates the cache Applies a decay to existing entries - and purges entries below the - * PURGE_THRESHOLD - * - *

While still racy this method is synchronized to avoid simultaneous recalibrations - */ - public synchronized void recalibrate() { - CacheEntry[] thisEntries = this.entries; - for (int i = 0; i < thisEntries.length; ++i) { - CacheEntry entry = thisEntries[i]; - if (entry == null) continue; - - boolean purge = entry.decay(); - if (purge) thisEntries[i] = null; + this.markers = new int[size]; + this.entries = new CacheEntry[size]; } - Caching.reset(this.markers); - } - - @Override - public byte[] encode(CharSequence charSeq) { - if (charSeq instanceof String) { - String str = (String) charSeq; - return this.getUtf8(str); - } else { - return null; + public int capacity() { + return this.entries.length; } - } - /** Returns the UTF-8 encoding of value -- using a cache value if available */ - public final byte[] getUtf8(String value) { - if (value.length() > MAX_ENTRY_LEN) return CacheEntry.utf8(value); + /** + * Recalibrates the cache Applies a decay to existing entries - and purges entries below the + * PURGE_THRESHOLD + * + *

While still racy this method is synchronized to avoid simultaneous recalibrations + */ + public synchronized void recalibrate() { + CacheEntry[] thisEntries = this.entries; + for (int i = 0; i < thisEntries.length; ++i) { + CacheEntry entry = thisEntries[i]; + if (entry == null) continue; + + boolean purge = entry.decay(); + if (purge) thisEntries[i] = null; + } + + Caching.reset(this.markers); + } - CacheEntry[] thisEntries = this.entries; + @Override + public byte[] encode(CharSequence charSeq) { + if (charSeq instanceof String) { + String str = (String) charSeq; + return this.getUtf8(str); + } else { + return null; + } + } - int adjHash = Caching.adjHash(value); + /** Returns the UTF-8 encoding of value -- using a cache value if available */ + public final byte[] getUtf8(String value) { + if (value.length() > MAX_ENTRY_LEN) return CacheEntry.utf8(value); - CacheEntry matchingEntry = lookupEntry(thisEntries, adjHash, value); - if (matchingEntry != null) { - matchingEntry.hit(); + CacheEntry[] thisEntries = this.entries; - this.hits += 1; - return matchingEntry.utf8(); - } + int adjHash = Caching.adjHash(value); - boolean wasMarked = Caching.mark(this.markers, adjHash); - if (!wasMarked) return CacheEntry.utf8(value); + CacheEntry matchingEntry = lookupEntry(thisEntries, adjHash, value); + if (matchingEntry != null) { + matchingEntry.hit(); - CacheEntry newEntry = new CacheEntry(adjHash, value); - newEntry.hit(); + this.hits += 1; + return matchingEntry.utf8(); + } - boolean evicted = lfuInsert(thisEntries, newEntry); - if (evicted) this.evictions += 1; + boolean wasMarked = Caching.mark(this.markers, adjHash); + if (!wasMarked) return CacheEntry.utf8(value); - return newEntry.utf8(); - } + CacheEntry newEntry = new CacheEntry(adjHash, value); + newEntry.hit(); - static final CacheEntry lookupEntry(CacheEntry[] entries, int adjHash, String value) { - int initialBucketIndex = Caching.bucketIndex(entries, adjHash); - for (int probe = 0, index = initialBucketIndex; probe < MAX_PROBES; ++probe, ++index) { - if (index >= entries.length) index = 0; + boolean evicted = lfuInsert(thisEntries, newEntry); + if (evicted) this.evictions += 1; - CacheEntry entry = entries[index]; - if (entry != null && entry.matches(adjHash, value)) { - return entry; - } + return newEntry.utf8(); } - return null; - } - - static final boolean lfuInsert(CacheEntry[] entries, CacheEntry newEntry) { - int initialBucketIndex = Caching.bucketIndex(entries, newEntry.adjHash()); - - // initial scan to see if there's an empty slot or marker entry is already present - double lowestHits = Double.MAX_VALUE; - int lfuIndex = -1; - for (int probe = 0, index = initialBucketIndex; probe < MAX_PROBES; ++probe, ++index) { - if (index >= entries.length) index = 0; - - CacheEntry entry = entries[index]; - if (entry == null || entry.isPurgeable()) { - entries[index] = newEntry; - return false; - } - - double hits = entry.score(); - if (hits < lowestHits) { - lowestHits = hits; - lfuIndex = index; - } + + static final CacheEntry lookupEntry(CacheEntry[] entries, int adjHash, String value) { + int initialBucketIndex = Caching.bucketIndex(entries, adjHash); + for (int probe = 0, index = initialBucketIndex; probe < MAX_PROBES; ++probe, ++index) { + if (index >= entries.length) index = 0; + + CacheEntry entry = entries[index]; + if (entry != null && entry.matches(adjHash, value)) { + return entry; + } + } + return null; } - // If we get here, then we're evicting the LFU - entries[lfuIndex] = newEntry; - return true; - } + static final boolean lfuInsert(CacheEntry[] entries, CacheEntry newEntry) { + int initialBucketIndex = Caching.bucketIndex(entries, newEntry.adjHash()); + + // initial scan to see if there's an empty slot or marker entry is already present + double lowestHits = Double.MAX_VALUE; + int lfuIndex = -1; + for (int probe = 0, index = initialBucketIndex; probe < MAX_PROBES; ++probe, ++index) { + if (index >= entries.length) index = 0; + + CacheEntry entry = entries[index]; + if (entry == null || entry.isPurgeable()) { + entries[index] = newEntry; + return false; + } + + double hits = entry.score(); + if (hits < lowestHits) { + lowestHits = hits; + lfuIndex = index; + } + } + + // If we get here, then we're evicting the LFU + entries[lfuIndex] = newEntry; + return true; + } - static final class CacheEntry { - final int adjHash; - final String value; - final byte[] valueUtf8; + static final class CacheEntry { + final int adjHash; + final String value; + final byte[] valueUtf8; - boolean promoted = false; - double score = 0; + boolean promoted = false; + double score = 0; - public CacheEntry(int adjHash, String value) { - this.adjHash = adjHash; - this.value = value; - this.valueUtf8 = utf8(value); - } + public CacheEntry(int adjHash, String value) { + this.adjHash = adjHash; + this.value = value; + this.valueUtf8 = utf8(value); + } - boolean matches(int adjHash, String value) { - return (this.adjHash == adjHash) && value.equals(this.value); - } + boolean matches(int adjHash, String value) { + return (this.adjHash == adjHash) && value.equals(this.value); + } - int adjHash() { - return this.adjHash; - } + int adjHash() { + return this.adjHash; + } - double score() { - return this.score; - } + double score() { + return this.score; + } - byte[] utf8() { - return this.valueUtf8; - } + byte[] utf8() { + return this.valueUtf8; + } - double hit() { - this.score += 1; + double hit() { + this.score += 1; - return this.score; - } + return this.score; + } - boolean decay() { - this.score *= HIT_DECAY; + boolean decay() { + this.score *= HIT_DECAY; - return this.isPurgeable(); - } + return this.isPurgeable(); + } - boolean isPurgeable() { - return (this.score < PURGE_THRESHOLD); - } + boolean isPurgeable() { + return (this.score < PURGE_THRESHOLD); + } - static final byte[] utf8(String value) { - return value.getBytes(StandardCharsets.UTF_8); - } + static final byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } - @Override - public String toString() { - return this.value + " - score: " + this.score; + @Override + public String toString() { + return this.value + " - score: " + this.score; + } } - } } diff --git a/communication/src/main/java/datadog/communication/serialization/StreamingBuffer.java b/communication/src/main/java/datadog/communication/serialization/StreamingBuffer.java index a88bf137e9c..368c7bf2303 100644 --- a/communication/src/main/java/datadog/communication/serialization/StreamingBuffer.java +++ b/communication/src/main/java/datadog/communication/serialization/StreamingBuffer.java @@ -4,33 +4,33 @@ public interface StreamingBuffer { - int capacity(); + int capacity(); - boolean isDirty(); + boolean isDirty(); - void mark(); + void mark(); - boolean flush(); + boolean flush(); - void put(byte b); + void put(byte b); - void putShort(short s); + void putShort(short s); - void putChar(char c); + void putChar(char c); - void putInt(int i); + void putInt(int i); - void putLong(long l); + void putLong(long l); - void putFloat(float f); + void putFloat(float f); - void putDouble(double d); + void putDouble(double d); - void put(byte[] bytes); + void put(byte[] bytes); - void put(byte[] bytes, int offset, int length); + void put(byte[] bytes, int offset, int length); - void put(ByteBuffer buffer); + void put(ByteBuffer buffer); - void reset(); + void reset(); } diff --git a/communication/src/main/java/datadog/communication/serialization/ValueWriter.java b/communication/src/main/java/datadog/communication/serialization/ValueWriter.java index 60b33e092a3..478c29597d6 100644 --- a/communication/src/main/java/datadog/communication/serialization/ValueWriter.java +++ b/communication/src/main/java/datadog/communication/serialization/ValueWriter.java @@ -2,5 +2,5 @@ // TODO @FunctionalInterface public interface ValueWriter { - void write(T value, Writable writable, EncodingCache encodingCache); + void write(T value, Writable writable, EncodingCache encodingCache); } diff --git a/communication/src/main/java/datadog/communication/serialization/Writable.java b/communication/src/main/java/datadog/communication/serialization/Writable.java index 7450e66d512..7fe14aa189e 100644 --- a/communication/src/main/java/datadog/communication/serialization/Writable.java +++ b/communication/src/main/java/datadog/communication/serialization/Writable.java @@ -5,70 +5,70 @@ import java.util.Map; public interface Writable { - void writeNull(); + void writeNull(); - void writeBoolean(boolean value); + void writeBoolean(boolean value); - void writeObject(Object value, EncodingCache encodingCache); + void writeObject(Object value, EncodingCache encodingCache); - void writeObjectString(Object value, EncodingCache encodingCache); + void writeObjectString(Object value, EncodingCache encodingCache); - void writeMap(Map map, EncodingCache encodingCache); + void writeMap(Map map, EncodingCache encodingCache); - void writeString(CharSequence s, EncodingCache encodingCache); + void writeString(CharSequence s, EncodingCache encodingCache); - void writeUTF8(byte[] string, int offset, int length); + void writeUTF8(byte[] string, int offset, int length); - void writeUTF8(byte[] string); + void writeUTF8(byte[] string); - void writeUTF8(UTF8BytesString string); + void writeUTF8(UTF8BytesString string); - void writeBinary(byte[] binary); + void writeBinary(byte[] binary); - void writeBinary(byte[] binary, int offset, int length); + void writeBinary(byte[] binary, int offset, int length); - /** - * Encodes 128 bits as binary. - * - * @param hi the high-order 64 bits. - * @param lo The low-order 64 bits. - */ - void writeBinary(long hi, long lo); + /** + * Encodes 128 bits as binary. + * + * @param hi the high-order 64 bits. + * @param lo The low-order 64 bits. + */ + void writeBinary(long hi, long lo); - /** - * Start a part of the message containing key-value pairs - * - * @param elementCount how many key-value pairs in the section of the message - */ - void startMap(int elementCount); + /** + * Start a part of the message containing key-value pairs + * + * @param elementCount how many key-value pairs in the section of the message + */ + void startMap(int elementCount); - /** - * Start a part of the message containing fields or values at ordinal positions - * - * @param elementCount how many fields in the section of the message - */ - void startStruct(int elementCount); + /** + * Start a part of the message containing fields or values at ordinal positions + * + * @param elementCount how many fields in the section of the message + */ + void startStruct(int elementCount); - /** - * Start a part of the message containing positional values - * - * @param elementCount how many array elements in the section of the message - */ - void startArray(int elementCount); + /** + * Start a part of the message containing positional values + * + * @param elementCount how many array elements in the section of the message + */ + void startArray(int elementCount); - void writeBinary(ByteBuffer buffer); + void writeBinary(ByteBuffer buffer); - void writeInt(int value); + void writeInt(int value); - void writeSignedInt(int value); + void writeSignedInt(int value); - void writeLong(long value); + void writeLong(long value); - void writeUnsignedLong(long value); + void writeUnsignedLong(long value); - void writeSignedLong(long value); + void writeSignedLong(long value); - void writeFloat(float value); + void writeFloat(float value); - void writeDouble(double value); + void writeDouble(double value); } diff --git a/communication/src/main/java/datadog/communication/serialization/custom/aiguard/FunctionWriter.java b/communication/src/main/java/datadog/communication/serialization/custom/aiguard/FunctionWriter.java index 8db825f4d04..0e84f8d84b8 100644 --- a/communication/src/main/java/datadog/communication/serialization/custom/aiguard/FunctionWriter.java +++ b/communication/src/main/java/datadog/communication/serialization/custom/aiguard/FunctionWriter.java @@ -7,15 +7,13 @@ public class FunctionWriter implements ValueWriter { - @Override - public void write( - final AIGuard.ToolCall.Function function, - final Writable writable, - final EncodingCache encodingCache) { - writable.startMap(2); - writable.writeString("name", encodingCache); - writable.writeString(function.getName(), encodingCache); - writable.writeString("arguments", encodingCache); - writable.writeString(function.getArguments(), encodingCache); - } + @Override + public void write( + final AIGuard.ToolCall.Function function, final Writable writable, final EncodingCache encodingCache) { + writable.startMap(2); + writable.writeString("name", encodingCache); + writable.writeString(function.getName(), encodingCache); + writable.writeString("arguments", encodingCache); + writable.writeString(function.getArguments(), encodingCache); + } } diff --git a/communication/src/main/java/datadog/communication/serialization/custom/aiguard/MessageWriter.java b/communication/src/main/java/datadog/communication/serialization/custom/aiguard/MessageWriter.java index 55669be9afa..daddc8c225d 100644 --- a/communication/src/main/java/datadog/communication/serialization/custom/aiguard/MessageWriter.java +++ b/communication/src/main/java/datadog/communication/serialization/custom/aiguard/MessageWriter.java @@ -9,91 +9,90 @@ public class MessageWriter implements ValueWriter { - @Override - public void write( - final AIGuard.Message value, final Writable writable, final EncodingCache encodingCache) { - final int[] size = {0}; - final boolean hasRole = present(Strings.isNotBlank(value.getRole()), size); - final boolean hasToolCallId = present(Strings.isNotBlank(value.getToolCallId()), size); - final boolean hasToolCalls = present(isNotEmpty(value.getToolCalls()), size); + @Override + public void write(final AIGuard.Message value, final Writable writable, final EncodingCache encodingCache) { + final int[] size = {0}; + final boolean hasRole = present(Strings.isNotBlank(value.getRole()), size); + final boolean hasToolCallId = present(Strings.isNotBlank(value.getToolCallId()), size); + final boolean hasToolCalls = present(isNotEmpty(value.getToolCalls()), size); - final boolean hasContentParts = present(isNotEmpty(value.getContentParts()), size); - // An empty content string is still written: "" is what the redaction remove strategy leaves - // behind, and dropping it would be indistinguishable from a message that never had content. - final boolean hasContentString = present(!hasContentParts && value.getContent() != null, size); + final boolean hasContentParts = present(isNotEmpty(value.getContentParts()), size); + // An empty content string is still written: "" is what the redaction remove strategy leaves + // behind, and dropping it would be indistinguishable from a message that never had content. + final boolean hasContentString = present(!hasContentParts && value.getContent() != null, size); - writable.startMap(size[0]); - writeString(hasRole, "role", value.getRole(), writable, encodingCache); + writable.startMap(size[0]); + writeString(hasRole, "role", value.getRole(), writable, encodingCache); - if (hasContentParts) { - writeContentParts("content", value.getContentParts(), writable, encodingCache); - } else { - writeString(hasContentString, "content", value.getContent(), writable, encodingCache); - } + if (hasContentParts) { + writeContentParts("content", value.getContentParts(), writable, encodingCache); + } else { + writeString(hasContentString, "content", value.getContent(), writable, encodingCache); + } - writeString(hasToolCallId, "tool_call_id", value.getToolCallId(), writable, encodingCache); - writeToolCallArray(hasToolCalls, "tool_calls", value.getToolCalls(), writable, encodingCache); - } + writeString(hasToolCallId, "tool_call_id", value.getToolCallId(), writable, encodingCache); + writeToolCallArray(hasToolCalls, "tool_calls", value.getToolCalls(), writable, encodingCache); + } - private static void writeContentParts( - final String key, - final List contentParts, - final Writable writable, - final EncodingCache encodingCache) { - writable.writeString(key, encodingCache); - writable.startArray(contentParts.size()); + private static void writeContentParts( + final String key, + final List contentParts, + final Writable writable, + final EncodingCache encodingCache) { + writable.writeString(key, encodingCache); + writable.startArray(contentParts.size()); - for (final AIGuard.ContentPart part : contentParts) { - writable.startMap(2); + for (final AIGuard.ContentPart part : contentParts) { + writable.startMap(2); - writable.writeString("type", encodingCache); - writable.writeString(part.getType().toString(), encodingCache); + writable.writeString("type", encodingCache); + writable.writeString(part.getType().toString(), encodingCache); - if (part.getType() == AIGuard.ContentPart.Type.TEXT) { - writable.writeString("text", encodingCache); - writable.writeString(part.getText(), encodingCache); - } else if (part.getType() == AIGuard.ContentPart.Type.IMAGE_URL) { - writable.writeString("image_url", encodingCache); - writable.startMap(1); - writable.writeString("url", encodingCache); - writable.writeString(part.getImageUrl().getUrl(), encodingCache); - } + if (part.getType() == AIGuard.ContentPart.Type.TEXT) { + writable.writeString("text", encodingCache); + writable.writeString(part.getText(), encodingCache); + } else if (part.getType() == AIGuard.ContentPart.Type.IMAGE_URL) { + writable.writeString("image_url", encodingCache); + writable.startMap(1); + writable.writeString("url", encodingCache); + writable.writeString(part.getImageUrl().getUrl(), encodingCache); + } + } } - } - private static void writeString( - final boolean present, - final String key, - final String value, - final Writable writable, - final EncodingCache encodingCache) { - if (present) { - writable.writeString(key, encodingCache); - writable.writeString(value, encodingCache); + private static void writeString( + final boolean present, + final String key, + final String value, + final Writable writable, + final EncodingCache encodingCache) { + if (present) { + writable.writeString(key, encodingCache); + writable.writeString(value, encodingCache); + } } - } - private static void writeToolCallArray( - final boolean present, - final String key, - final List values, - final Writable writable, - final EncodingCache encodingCache) { - if (present) { - writable.writeString(key, encodingCache); - writable.writeObject(values, encodingCache); + private static void writeToolCallArray( + final boolean present, + final String key, + final List values, + final Writable writable, + final EncodingCache encodingCache) { + if (present) { + writable.writeString(key, encodingCache); + writable.writeObject(values, encodingCache); + } } - } - /** Counts a field towards the map size when it is present, and reports whether it is. */ - private static boolean present(final boolean present, final int[] fieldCount) { - if (present) { - fieldCount[0]++; + /** Counts a field towards the map size when it is present, and reports whether it is. */ + private static boolean present(final boolean present, final int[] fieldCount) { + if (present) { + fieldCount[0]++; + } + return present; } - return present; - } - private static boolean isNotEmpty(final List value) { - return value != null && !value.isEmpty(); - } + private static boolean isNotEmpty(final List value) { + return value != null && !value.isEmpty(); + } } diff --git a/communication/src/main/java/datadog/communication/serialization/custom/aiguard/ToolCallWriter.java b/communication/src/main/java/datadog/communication/serialization/custom/aiguard/ToolCallWriter.java index 2b5107e1eb7..bfa32d856f4 100644 --- a/communication/src/main/java/datadog/communication/serialization/custom/aiguard/ToolCallWriter.java +++ b/communication/src/main/java/datadog/communication/serialization/custom/aiguard/ToolCallWriter.java @@ -7,13 +7,12 @@ public class ToolCallWriter implements ValueWriter { - @Override - public void write( - final AIGuard.ToolCall value, final Writable writable, final EncodingCache encodingCache) { - writable.startMap(2); - writable.writeString("id", encodingCache); - writable.writeString(value.getId(), encodingCache); - writable.writeString("function", encodingCache); - writable.writeObject(value.getFunction(), encodingCache); - } + @Override + public void write(final AIGuard.ToolCall value, final Writable writable, final EncodingCache encodingCache) { + writable.startMap(2); + writable.writeString("id", encodingCache); + writable.writeString(value.getId(), encodingCache); + writable.writeString("function", encodingCache); + writable.writeObject(value.getFunction(), encodingCache); + } } diff --git a/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventFrameWriter.java b/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventFrameWriter.java index 0977448bfd7..ca611defd72 100644 --- a/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventFrameWriter.java +++ b/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventFrameWriter.java @@ -8,51 +8,51 @@ public class StackTraceEventFrameWriter implements ValueWriter { - @Override - public void write(StackTraceFrame value, Writable writable, EncodingCache encodingCache) { - int mapSize = 1; // id always present - boolean hasText = Strings.isNotBlank(value.getText()); - boolean hasFile = Strings.isNotBlank(value.getFile()); - boolean hasLine = value.getLine() != null; - boolean hasClass = Strings.isNotBlank(value.getClass_name()); - boolean hasFunction = Strings.isNotBlank(value.getFunction()); - if (hasText) { - mapSize++; + @Override + public void write(StackTraceFrame value, Writable writable, EncodingCache encodingCache) { + int mapSize = 1; // id always present + boolean hasText = Strings.isNotBlank(value.getText()); + boolean hasFile = Strings.isNotBlank(value.getFile()); + boolean hasLine = value.getLine() != null; + boolean hasClass = Strings.isNotBlank(value.getClass_name()); + boolean hasFunction = Strings.isNotBlank(value.getFunction()); + if (hasText) { + mapSize++; + } + if (hasFile) { + mapSize++; + } + if (hasLine) { + mapSize++; + } + if (hasClass) { + mapSize++; + } + if (hasFunction) { + mapSize++; + } + writable.startMap(mapSize); + writable.writeString("id", encodingCache); + writable.writeInt(value.getId()); + if (hasText) { + writable.writeString("text", encodingCache); + writable.writeString(value.getText(), encodingCache); + } + if (hasFile) { + writable.writeString("file", encodingCache); + writable.writeString(value.getFile(), encodingCache); + } + if (hasLine) { + writable.writeString("line", encodingCache); + writable.writeInt(value.getLine()); + } + if (hasClass) { + writable.writeString("class_name", encodingCache); + writable.writeString(value.getClass_name(), encodingCache); + } + if (hasFunction) { + writable.writeString("function", encodingCache); + writable.writeString(value.getFunction(), encodingCache); + } } - if (hasFile) { - mapSize++; - } - if (hasLine) { - mapSize++; - } - if (hasClass) { - mapSize++; - } - if (hasFunction) { - mapSize++; - } - writable.startMap(mapSize); - writable.writeString("id", encodingCache); - writable.writeInt(value.getId()); - if (hasText) { - writable.writeString("text", encodingCache); - writable.writeString(value.getText(), encodingCache); - } - if (hasFile) { - writable.writeString("file", encodingCache); - writable.writeString(value.getFile(), encodingCache); - } - if (hasLine) { - writable.writeString("line", encodingCache); - writable.writeInt(value.getLine()); - } - if (hasClass) { - writable.writeString("class_name", encodingCache); - writable.writeString(value.getClass_name(), encodingCache); - } - if (hasFunction) { - writable.writeString("function", encodingCache); - writable.writeString(value.getFunction(), encodingCache); - } - } } diff --git a/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventWriter.java b/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventWriter.java index f2b56b3bba6..294c7367c63 100644 --- a/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventWriter.java +++ b/communication/src/main/java/datadog/communication/serialization/custom/stacktrace/StackTraceEventWriter.java @@ -8,35 +8,35 @@ public class StackTraceEventWriter implements ValueWriter { - @Override - public void write(StackTraceEvent value, Writable writable, EncodingCache encodingCache) { - int mapSize = 1; // frames always present - boolean hasId = Strings.isNotBlank(value.getId()); - boolean hasLanguage = Strings.isNotBlank(value.getLanguage()); - boolean hasMessage = Strings.isNotBlank(value.getMessage()); - if (hasId) { - mapSize++; + @Override + public void write(StackTraceEvent value, Writable writable, EncodingCache encodingCache) { + int mapSize = 1; // frames always present + boolean hasId = Strings.isNotBlank(value.getId()); + boolean hasLanguage = Strings.isNotBlank(value.getLanguage()); + boolean hasMessage = Strings.isNotBlank(value.getMessage()); + if (hasId) { + mapSize++; + } + if (hasLanguage) { + mapSize++; + } + if (hasMessage) { + mapSize++; + } + writable.startMap(mapSize); + if (hasId) { + writable.writeString("id", encodingCache); + writable.writeString(value.getId(), encodingCache); + } + if (hasLanguage) { + writable.writeString("language", encodingCache); + writable.writeString(value.getLanguage(), encodingCache); + } + if (hasMessage) { + writable.writeString("message", encodingCache); + writable.writeString(value.getMessage(), encodingCache); + } + writable.writeString("frames", encodingCache); + writable.writeObject(value.getFrames(), encodingCache); } - if (hasLanguage) { - mapSize++; - } - if (hasMessage) { - mapSize++; - } - writable.startMap(mapSize); - if (hasId) { - writable.writeString("id", encodingCache); - writable.writeString(value.getId(), encodingCache); - } - if (hasLanguage) { - writable.writeString("language", encodingCache); - writable.writeString(value.getLanguage(), encodingCache); - } - if (hasMessage) { - writable.writeString("message", encodingCache); - writable.writeString(value.getMessage(), encodingCache); - } - writable.writeString("frames", encodingCache); - writable.writeObject(value.getFrames(), encodingCache); - } } diff --git a/communication/src/main/java/datadog/communication/serialization/msgpack/MsgPackWriter.java b/communication/src/main/java/datadog/communication/serialization/msgpack/MsgPackWriter.java index c86ce35bcac..f1b03ae9ad5 100644 --- a/communication/src/main/java/datadog/communication/serialization/msgpack/MsgPackWriter.java +++ b/communication/src/main/java/datadog/communication/serialization/msgpack/MsgPackWriter.java @@ -18,574 +18,574 @@ /** Not thread-safe (use one per thread). */ public class MsgPackWriter implements WritableFormatter { - private static final Logger log = LoggerFactory.getLogger(MsgPackWriter.class); + private static final Logger log = LoggerFactory.getLogger(MsgPackWriter.class); - // see https://github.com/msgpack/msgpack/blob/master/spec.md - public static final byte NULL = (byte) 0xC0; + // see https://github.com/msgpack/msgpack/blob/master/spec.md + public static final byte NULL = (byte) 0xC0; - public static final byte FALSE = (byte) 0xC2; - public static final byte TRUE = (byte) 0xC3; + public static final byte FALSE = (byte) 0xC2; + public static final byte TRUE = (byte) 0xC3; - public static final byte UINT8 = (byte) 0xCC; - public static final byte UINT16 = (byte) 0xCD; - public static final byte UINT32 = (byte) 0xCE; - public static final byte UINT64 = (byte) 0xCF; + public static final byte UINT8 = (byte) 0xCC; + public static final byte UINT16 = (byte) 0xCD; + public static final byte UINT32 = (byte) 0xCE; + public static final byte UINT64 = (byte) 0xCF; - public static final byte INT8 = (byte) 0xD0; - public static final byte INT16 = (byte) 0xD1; - public static final byte INT32 = (byte) 0xD2; - public static final byte INT64 = (byte) 0xD3; + public static final byte INT8 = (byte) 0xD0; + public static final byte INT16 = (byte) 0xD1; + public static final byte INT32 = (byte) 0xD2; + public static final byte INT64 = (byte) 0xD3; - public static final byte FLOAT32 = (byte) 0xCA; - public static final byte FLOAT64 = (byte) 0xCB; + public static final byte FLOAT32 = (byte) 0xCA; + public static final byte FLOAT64 = (byte) 0xCB; - public static final byte STR8 = (byte) 0xD9; - public static final byte STR16 = (byte) 0xDA; - public static final byte STR32 = (byte) 0xDB; + public static final byte STR8 = (byte) 0xD9; + public static final byte STR16 = (byte) 0xDA; + public static final byte STR32 = (byte) 0xDB; - public static final byte BIN8 = (byte) 0xC4; - public static final byte BIN16 = (byte) 0xC5; - public static final byte BIN32 = (byte) 0xC6; + public static final byte BIN8 = (byte) 0xC4; + public static final byte BIN16 = (byte) 0xC5; + public static final byte BIN32 = (byte) 0xC6; - public static final byte ARRAY16 = (byte) 0xDC; - public static final byte ARRAY32 = (byte) 0xDD; + public static final byte ARRAY16 = (byte) 0xDC; + public static final byte ARRAY32 = (byte) 0xDD; - public static final byte MAP16 = (byte) 0xDE; - public static final byte MAP32 = (byte) 0xDF; + public static final byte MAP16 = (byte) 0xDE; + public static final byte MAP32 = (byte) 0xDF; - public static final int NEGFIXNUM = 0xE0; - public static final int FIXSTR = 0xA0; - public static final int FIXARRAY = 0x90; - public static final int FIXMAP = 0x80; + public static final int NEGFIXNUM = 0xE0; + public static final int FIXSTR = 0xA0; + public static final int FIXARRAY = 0x90; + public static final int FIXMAP = 0x80; - private final Codec codec; + private final Codec codec; - private final StreamingBuffer buffer; + private final StreamingBuffer buffer; - public MsgPackWriter(StreamingBuffer buffer) { - this(Codec.INSTANCE, buffer); - } + public MsgPackWriter(StreamingBuffer buffer) { + this(Codec.INSTANCE, buffer); + } - public MsgPackWriter(Codec codec, StreamingBuffer buffer) { - this.codec = codec; - this.buffer = buffer; - } + public MsgPackWriter(Codec codec, StreamingBuffer buffer) { + this.codec = codec; + this.buffer = buffer; + } - @Override - public void flush() { - if (buffer.isDirty()) { - buffer.flush(); + @Override + public void flush() { + if (buffer.isDirty()) { + buffer.flush(); + } } - } - - @Override - public boolean format(T message, Mapper mapper) { - try { - mapper.map(message, this); - buffer.mark(); - return true; - } catch (BufferOverflowException overflow) { - // if the buffer has finite capacity, it will overflow - // if we tried to serialise a message larger than the - // max capacity, then reject the message - if (buffer.flush()) { + + @Override + public boolean format(T message, Mapper mapper) { try { - mapper.reset(); - mapper.map(message, this, true); - buffer.mark(); - return true; - } catch (BufferOverflowException fatal) { - log.debug( - "dropping message because its serialized size is too large (> {}MB)", - (buffer.capacity() >>> 20)); + mapper.map(message, this); + buffer.mark(); + return true; + } catch (BufferOverflowException overflow) { + // if the buffer has finite capacity, it will overflow + // if we tried to serialise a message larger than the + // max capacity, then reject the message + if (buffer.flush()) { + try { + mapper.reset(); + mapper.map(message, this, true); + buffer.mark(); + return true; + } catch (BufferOverflowException fatal) { + log.debug( + "dropping message because its serialized size is too large (> {}MB)", + (buffer.capacity() >>> 20)); + } + } + buffer.reset(); + // the buffer is now empty, so drop any mapper state from the rejected message + mapper.reset(); + return false; } - } - buffer.reset(); - // the buffer is now empty, so drop any mapper state from the rejected message - mapper.reset(); - return false; } - } - // NOTE - implementations pulled up to this level should - // not write directly to the buffer + // NOTE - implementations pulled up to this level should + // not write directly to the buffer - @Override - public void writeMap(Map map, EncodingCache encodingCache) { - startMap(map.size()); - for (Map.Entry entry : map.entrySet()) { - writeString(entry.getKey(), encodingCache); - writeObject(entry.getValue(), encodingCache); + @Override + public void writeMap(Map map, EncodingCache encodingCache) { + startMap(map.size()); + for (Map.Entry entry : map.entrySet()) { + writeString(entry.getKey(), encodingCache); + writeObject(entry.getValue(), encodingCache); + } } - } - - @Override - @SuppressWarnings({"rawtypes", "unchecked"}) - public void writeObject(Object value, EncodingCache encodingCache) { - // unpeel a very common case, but should try to move away from sending - // UTF8BytesString down this codepath at all - if (value instanceof UTF8BytesString) { - writeUTF8((UTF8BytesString) value); - } else if (null == value) { - writeNull(); - } else { - ValueWriter writer = codec.get(value.getClass()); - writer.write(value, this, encodingCache); + + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public void writeObject(Object value, EncodingCache encodingCache) { + // unpeel a very common case, but should try to move away from sending + // UTF8BytesString down this codepath at all + if (value instanceof UTF8BytesString) { + writeUTF8((UTF8BytesString) value); + } else if (null == value) { + writeNull(); + } else { + ValueWriter writer = codec.get(value.getClass()); + writer.write(value, this, encodingCache); + } } - } - - @Override - @SuppressWarnings({"rawtypes", "unchecked"}) - public void writeObjectString(Object value, EncodingCache encodingCache) { - // unpeel a very common case, but should try to move away from sending - // UTF8BytesString down this codepath at all - if (value instanceof UTF8BytesString) { - writeUTF8((UTF8BytesString) value); - } else if (null == value) { - writeNull(); - } else { - String s = String.valueOf(value); - if (null != encodingCache) { - byte[] utf8 = encodingCache.encode(s); - if (null != utf8) { - writeUTF8(utf8); - return; + + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public void writeObjectString(Object value, EncodingCache encodingCache) { + // unpeel a very common case, but should try to move away from sending + // UTF8BytesString down this codepath at all + if (value instanceof UTF8BytesString) { + writeUTF8((UTF8BytesString) value); + } else if (null == value) { + writeNull(); + } else { + String s = String.valueOf(value); + if (null != encodingCache) { + byte[] utf8 = encodingCache.encode(s); + if (null != utf8) { + writeUTF8(utf8); + return; + } + } + writeUTF8(s.getBytes(UTF_8)); } - } - writeUTF8(s.getBytes(UTF_8)); } - } - - @Override - public void writeNull() { - buffer.put(NULL); - } - - @Override - public void writeBoolean(boolean value) { - buffer.put(value ? TRUE : FALSE); - } - - @Override - public void writeString(CharSequence s, EncodingCache encodingCache) { - if (null == s) { - writeNull(); - } else { - if (null != encodingCache) { - byte[] utf8 = encodingCache.encode(s); - if (null != utf8) { - writeUTF8(utf8); - return; + + @Override + public void writeNull() { + buffer.put(NULL); + } + + @Override + public void writeBoolean(boolean value) { + buffer.put(value ? TRUE : FALSE); + } + + @Override + public void writeString(CharSequence s, EncodingCache encodingCache) { + if (null == s) { + writeNull(); + } else { + if (null != encodingCache) { + byte[] utf8 = encodingCache.encode(s); + if (null != utf8) { + writeUTF8(utf8); + return; + } + } + if (s instanceof UTF8BytesString) { + writeUTF8((UTF8BytesString) s); + } else { + writeUTF8(String.valueOf(s).getBytes(UTF_8)); + } } - } - if (s instanceof UTF8BytesString) { - writeUTF8((UTF8BytesString) s); - } else { - writeUTF8(String.valueOf(s).getBytes(UTF_8)); - } } - } - - @Override - public void writeUTF8(byte[] string, int offset, int length) { - writeStringHeader(length); - buffer.put(string, offset, length); - } - - @Override - public void writeUTF8(byte[] string) { - writeUTF8(string, 0, string.length); - } - - @Override - public void writeUTF8(UTF8BytesString string) { - writeStringHeader(string.encodedLength()); - buffer.put(string.getUtf8Bytes()); - } - - @Override - public void writeBinary(byte[] binary) { - writeBinaryHeader(binary.length); - buffer.put(binary); - } - - @Override - public void writeBinary(byte[] binary, int offset, int length) { - writeBinaryHeader(length); - buffer.put(binary, offset, length); - } - - @Override - public void writeBinary(long hi, long lo) { - writeBinaryHeader(16); - buffer.putLong(hi); - buffer.putLong(lo); - } - - @Override - public void writeBinary(ByteBuffer binary) { - ByteBuffer slice = binary.slice(); - writeBinaryHeader(slice.limit() - slice.position()); - buffer.put(slice); - } - - @Override - public void writeInt(int value) { - if (value < 0) { - switch (Integer.numberOfLeadingZeros(~value)) { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - case 8: - case 9: - case 10: - case 11: - case 12: - case 13: - case 14: - case 15: - case 16: - buffer.put(INT32); - buffer.putInt(value); - break; - case 17: - case 18: - case 19: - case 20: - case 21: - case 22: - case 23: - case 24: - buffer.put(INT16); - buffer.putChar((char) value); - break; - case 25: - case 26: - buffer.put(INT8); - buffer.put((byte) value); - break; - case 27: - case 28: - case 29: - case 30: - case 31: - case 32: - default: - buffer.put((byte) (NEGFIXNUM | value)); - } - } else { - switch (Integer.numberOfLeadingZeros(value)) { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - case 8: - case 9: - case 10: - case 11: - case 12: - case 13: - case 14: - case 15: - buffer.put(UINT32); - buffer.putInt(value); - break; - case 16: - case 17: - case 18: - case 19: - case 20: - case 21: - case 22: - case 23: - buffer.put(UINT16); - buffer.putChar((char) value); - break; - case 24: - buffer.put(UINT8); - buffer.put((byte) value); - break; - case 25: - case 26: - case 27: - case 28: - case 29: - case 30: - case 31: - case 32: - default: - buffer.put((byte) value); - } + + @Override + public void writeUTF8(byte[] string, int offset, int length) { + writeStringHeader(length); + buffer.put(string, offset, length); } - } - - @Override - public void writeSignedInt(int value) { - writeInt(value); - } - - @Override - public void writeLong(long value) { - writeLongInternal(value, false); - } - - @Override - public void writeUnsignedLong(long value) { - writeLongInternal(value, true); - } - - public void writeLongInternal(long value, boolean forceUnsigned) { - if (value < 0 && !forceUnsigned) { - switch (Long.numberOfLeadingZeros(~value)) { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - case 8: - case 9: - case 10: - case 11: - case 12: - case 13: - case 14: - case 15: - case 16: - case 17: - case 18: - case 19: - case 20: - case 21: - case 22: - case 23: - case 24: - case 25: - case 26: - case 27: - case 28: - case 29: - case 30: - case 31: - case 32: - buffer.put(INT64); - buffer.putLong(value); - break; - case 33: - case 34: - case 35: - case 36: - case 37: - case 38: - case 39: - case 40: - case 41: - case 42: - case 43: - case 44: - case 45: - case 46: - case 47: - case 48: - buffer.put(INT32); - buffer.putInt((int) value); - break; - case 49: - case 50: - case 51: - case 52: - case 53: - case 54: - case 55: - case 56: - buffer.put(INT16); - buffer.putChar((char) value); - break; - case 57: - case 58: - buffer.put(INT8); - buffer.put((byte) value); - break; - case 59: - case 60: - case 61: - case 62: - case 63: - case 64: - default: - buffer.put((byte) (NEGFIXNUM | value)); - } - } else { - switch (Long.numberOfLeadingZeros(value)) { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - case 8: - case 9: - case 10: - case 11: - case 12: - case 13: - case 14: - case 15: - case 16: - case 17: - case 18: - case 19: - case 20: - case 21: - case 22: - case 23: - case 24: - case 25: - case 26: - case 27: - case 28: - case 29: - case 30: - case 31: - buffer.put(UINT64); - buffer.putLong(value); - break; - case 32: - case 33: - case 34: - case 35: - case 36: - case 37: - case 38: - case 39: - case 40: - case 41: - case 42: - case 43: - case 44: - case 45: - case 46: - case 47: - buffer.put(UINT32); - buffer.putInt((int) value); - break; - case 48: - case 49: - case 50: - case 51: - case 52: - case 53: - case 54: - case 55: - buffer.put(UINT16); - buffer.putChar((char) value); - break; - case 56: - buffer.put(UINT8); - buffer.put((byte) value); - break; - case 57: - case 59: - case 60: - case 61: - case 62: - case 63: - case 64: - default: - buffer.put((byte) value); - } + + @Override + public void writeUTF8(byte[] string) { + writeUTF8(string, 0, string.length); + } + + @Override + public void writeUTF8(UTF8BytesString string) { + writeStringHeader(string.encodedLength()); + buffer.put(string.getUtf8Bytes()); } - } - - @Override - public void writeSignedLong(long value) { - writeLong(value); - } - - @Override - public void writeFloat(float value) { - // The datadog agent fails to decode FLOAT32 even though the code in read_bytes.go looks like - // it will try to decode FLOAT32 just fine. Even if there will be a fix in a future datadog - // agent release, we need to be backwards compatible here by sending it as a FLOAT64 instead. - buffer.put(FLOAT64); - buffer.putDouble(value); - } - - @Override - public void writeDouble(double value) { - buffer.put(FLOAT64); - buffer.putDouble(value); - } - - @Override - public void startMap(int elementCount) { - if (elementCount < 0x10) { - buffer.put((byte) (FIXMAP | elementCount)); - } else if (elementCount < 0x10000) { - buffer.put(MAP16); - buffer.putShort((short) elementCount); - } else { - buffer.put(MAP32); - buffer.putInt(elementCount); + + @Override + public void writeBinary(byte[] binary) { + writeBinaryHeader(binary.length); + buffer.put(binary); } - } - - @Override - public void startStruct(int elementCount) { - startArray(elementCount); - } - - @Override - public void startArray(int elementCount) { - if (elementCount < 0x10) { - buffer.put((byte) (FIXARRAY | elementCount)); - } else if (elementCount < 0x10000) { - buffer.put(ARRAY16); - buffer.putShort((short) elementCount); - } else { - buffer.put(ARRAY32); - buffer.putInt(elementCount); + + @Override + public void writeBinary(byte[] binary, int offset, int length) { + writeBinaryHeader(length); + buffer.put(binary, offset, length); + } + + @Override + public void writeBinary(long hi, long lo) { + writeBinaryHeader(16); + buffer.putLong(hi); + buffer.putLong(lo); } - } - - void writeStringHeader(int length) { - if (length < 0x10) { - buffer.put((byte) (FIXSTR | length)); - } else if (length < 0x100) { - buffer.put(STR8); - buffer.put((byte) length); - } else if (length < 0x10000) { - buffer.put(STR16); - buffer.putShort((short) length); - } else { - buffer.put(STR32); - buffer.putInt(length); + + @Override + public void writeBinary(ByteBuffer binary) { + ByteBuffer slice = binary.slice(); + writeBinaryHeader(slice.limit() - slice.position()); + buffer.put(slice); + } + + @Override + public void writeInt(int value) { + if (value < 0) { + switch (Integer.numberOfLeadingZeros(~value)) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + case 16: + buffer.put(INT32); + buffer.putInt(value); + break; + case 17: + case 18: + case 19: + case 20: + case 21: + case 22: + case 23: + case 24: + buffer.put(INT16); + buffer.putChar((char) value); + break; + case 25: + case 26: + buffer.put(INT8); + buffer.put((byte) value); + break; + case 27: + case 28: + case 29: + case 30: + case 31: + case 32: + default: + buffer.put((byte) (NEGFIXNUM | value)); + } + } else { + switch (Integer.numberOfLeadingZeros(value)) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + buffer.put(UINT32); + buffer.putInt(value); + break; + case 16: + case 17: + case 18: + case 19: + case 20: + case 21: + case 22: + case 23: + buffer.put(UINT16); + buffer.putChar((char) value); + break; + case 24: + buffer.put(UINT8); + buffer.put((byte) value); + break; + case 25: + case 26: + case 27: + case 28: + case 29: + case 30: + case 31: + case 32: + default: + buffer.put((byte) value); + } + } + } + + @Override + public void writeSignedInt(int value) { + writeInt(value); } - } - - void writeBinaryHeader(int length) { - if (length < 0x100) { - buffer.put(BIN8); - buffer.put((byte) length); - } else if (length < 0x10000) { - buffer.put(BIN16); - buffer.putShort((short) length); - } else { - buffer.put(BIN32); - buffer.putInt(length); + + @Override + public void writeLong(long value) { + writeLongInternal(value, false); + } + + @Override + public void writeUnsignedLong(long value) { + writeLongInternal(value, true); + } + + public void writeLongInternal(long value, boolean forceUnsigned) { + if (value < 0 && !forceUnsigned) { + switch (Long.numberOfLeadingZeros(~value)) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + case 16: + case 17: + case 18: + case 19: + case 20: + case 21: + case 22: + case 23: + case 24: + case 25: + case 26: + case 27: + case 28: + case 29: + case 30: + case 31: + case 32: + buffer.put(INT64); + buffer.putLong(value); + break; + case 33: + case 34: + case 35: + case 36: + case 37: + case 38: + case 39: + case 40: + case 41: + case 42: + case 43: + case 44: + case 45: + case 46: + case 47: + case 48: + buffer.put(INT32); + buffer.putInt((int) value); + break; + case 49: + case 50: + case 51: + case 52: + case 53: + case 54: + case 55: + case 56: + buffer.put(INT16); + buffer.putChar((char) value); + break; + case 57: + case 58: + buffer.put(INT8); + buffer.put((byte) value); + break; + case 59: + case 60: + case 61: + case 62: + case 63: + case 64: + default: + buffer.put((byte) (NEGFIXNUM | value)); + } + } else { + switch (Long.numberOfLeadingZeros(value)) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + case 16: + case 17: + case 18: + case 19: + case 20: + case 21: + case 22: + case 23: + case 24: + case 25: + case 26: + case 27: + case 28: + case 29: + case 30: + case 31: + buffer.put(UINT64); + buffer.putLong(value); + break; + case 32: + case 33: + case 34: + case 35: + case 36: + case 37: + case 38: + case 39: + case 40: + case 41: + case 42: + case 43: + case 44: + case 45: + case 46: + case 47: + buffer.put(UINT32); + buffer.putInt((int) value); + break; + case 48: + case 49: + case 50: + case 51: + case 52: + case 53: + case 54: + case 55: + buffer.put(UINT16); + buffer.putChar((char) value); + break; + case 56: + buffer.put(UINT8); + buffer.put((byte) value); + break; + case 57: + case 59: + case 60: + case 61: + case 62: + case 63: + case 64: + default: + buffer.put((byte) value); + } + } + } + + @Override + public void writeSignedLong(long value) { + writeLong(value); + } + + @Override + public void writeFloat(float value) { + // The datadog agent fails to decode FLOAT32 even though the code in read_bytes.go looks like + // it will try to decode FLOAT32 just fine. Even if there will be a fix in a future datadog + // agent release, we need to be backwards compatible here by sending it as a FLOAT64 instead. + buffer.put(FLOAT64); + buffer.putDouble(value); + } + + @Override + public void writeDouble(double value) { + buffer.put(FLOAT64); + buffer.putDouble(value); + } + + @Override + public void startMap(int elementCount) { + if (elementCount < 0x10) { + buffer.put((byte) (FIXMAP | elementCount)); + } else if (elementCount < 0x10000) { + buffer.put(MAP16); + buffer.putShort((short) elementCount); + } else { + buffer.put(MAP32); + buffer.putInt(elementCount); + } + } + + @Override + public void startStruct(int elementCount) { + startArray(elementCount); + } + + @Override + public void startArray(int elementCount) { + if (elementCount < 0x10) { + buffer.put((byte) (FIXARRAY | elementCount)); + } else if (elementCount < 0x10000) { + buffer.put(ARRAY16); + buffer.putShort((short) elementCount); + } else { + buffer.put(ARRAY32); + buffer.putInt(elementCount); + } + } + + void writeStringHeader(int length) { + if (length < 0x10) { + buffer.put((byte) (FIXSTR | length)); + } else if (length < 0x100) { + buffer.put(STR8); + buffer.put((byte) length); + } else if (length < 0x10000) { + buffer.put(STR16); + buffer.putShort((short) length); + } else { + buffer.put(STR32); + buffer.putInt(length); + } + } + + void writeBinaryHeader(int length) { + if (length < 0x100) { + buffer.put(BIN8); + buffer.put((byte) length); + } else if (length < 0x10000) { + buffer.put(BIN16); + buffer.putShort((short) length); + } else { + buffer.put(BIN32); + buffer.putInt(length); + } } - } } diff --git a/communication/src/main/java/datadog/communication/util/IOThrowingFunction.java b/communication/src/main/java/datadog/communication/util/IOThrowingFunction.java index e6cd18d96ed..a69ab9bd64d 100644 --- a/communication/src/main/java/datadog/communication/util/IOThrowingFunction.java +++ b/communication/src/main/java/datadog/communication/util/IOThrowingFunction.java @@ -5,5 +5,5 @@ @FunctionalInterface public interface IOThrowingFunction { - U apply(T t) throws IOException; + U apply(T t) throws IOException; } diff --git a/communication/src/main/java/datadog/communication/util/IOUtils.java b/communication/src/main/java/datadog/communication/util/IOUtils.java index 819e4bb3e18..c7f4529e063 100644 --- a/communication/src/main/java/datadog/communication/util/IOUtils.java +++ b/communication/src/main/java/datadog/communication/util/IOUtils.java @@ -19,66 +19,61 @@ public abstract class IOUtils { - private static final int DEFAULT_BUFFER_SIZE = 4096; + private static final int DEFAULT_BUFFER_SIZE = 4096; - private IOUtils() {} + private IOUtils() {} - public static @Nonnull String readFully(InputStream input) throws IOException { - return readFully(input, Charset.defaultCharset()); - } + public static @Nonnull String readFully(InputStream input) throws IOException { + return readFully(input, Charset.defaultCharset()); + } - public static @Nonnull String readFully(InputStream input, Charset charset) throws IOException { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - readFully(input, output); - return new String(output.toByteArray(), charset); - } + public static @Nonnull String readFully(InputStream input, Charset charset) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + readFully(input, output); + return new String(output.toByteArray(), charset); + } - public static void readFully(InputStream input, OutputStream output) throws IOException { - byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; - int count; - while ((count = input.read(buffer)) != -1) { - output.write(buffer, 0, count); + public static void readFully(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } } - } - public static @Nonnull List readLines(final InputStream input) throws IOException { - return readLines(input, Charset.defaultCharset()); - } + public static @Nonnull List readLines(final InputStream input) throws IOException { + return readLines(input, Charset.defaultCharset()); + } - public static @Nonnull List readLines(final InputStream input, final Charset charset) - throws IOException { - final InputStreamReader reader = new InputStreamReader(input, charset); - return readLines(reader); - } + public static @Nonnull List readLines(final InputStream input, final Charset charset) throws IOException { + final InputStreamReader reader = new InputStreamReader(input, charset); + return readLines(reader); + } - public static @Nonnull List readLines(final Reader input) throws IOException { - final BufferedReader reader = new BufferedReader(input, DEFAULT_BUFFER_SIZE); - final List list = new ArrayList<>(); - String line = reader.readLine(); - while (line != null) { - list.add(line); - line = reader.readLine(); + public static @Nonnull List readLines(final Reader input) throws IOException { + final BufferedReader reader = new BufferedReader(input, DEFAULT_BUFFER_SIZE); + final List list = new ArrayList<>(); + String line = reader.readLine(); + while (line != null) { + list.add(line); + line = reader.readLine(); + } + return list; } - return list; - } - public static void copyFolder(Path src, Path dest) throws IOException { - Files.walkFileTree( - src, - new SimpleFileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) - throws IOException { - Files.createDirectories(dest.resolve(src.relativize(dir))); - return FileVisitResult.CONTINUE; - } + public static void copyFolder(Path src, Path dest) throws IOException { + Files.walkFileTree(src, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Files.createDirectories(dest.resolve(src.relativize(dir))); + return FileVisitResult.CONTINUE; + } - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) - throws IOException { - Files.copy(file, dest.resolve(src.relativize(file))); - return FileVisitResult.CONTINUE; - } + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.copy(file, dest.resolve(src.relativize(file))); + return FileVisitResult.CONTINUE; + } }); - } + } } diff --git a/communication/src/main/java/okhttp3/internal/PatchUtil.java b/communication/src/main/java/okhttp3/internal/PatchUtil.java index dde57821c31..db3df222403 100644 --- a/communication/src/main/java/okhttp3/internal/PatchUtil.java +++ b/communication/src/main/java/okhttp3/internal/PatchUtil.java @@ -56,635 +56,622 @@ /** Junk drawer of utility methods. */ public final class PatchUtil { - public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; - public static final String[] EMPTY_STRING_ARRAY = new String[0]; - - public static final ResponseBody EMPTY_RESPONSE = ResponseBody.create(null, EMPTY_BYTE_ARRAY); - public static final RequestBody EMPTY_REQUEST = RequestBody.create(null, EMPTY_BYTE_ARRAY); - - private static final ByteString UTF_8_BOM = ByteString.decodeHex("efbbbf"); - private static final ByteString UTF_16_BE_BOM = ByteString.decodeHex("feff"); - private static final ByteString UTF_16_LE_BOM = ByteString.decodeHex("fffe"); - private static final ByteString UTF_32_BE_BOM = ByteString.decodeHex("0000ffff"); - private static final ByteString UTF_32_LE_BOM = ByteString.decodeHex("ffff0000"); - - public static final Charset UTF_8 = Charset.forName("UTF-8"); - public static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); - private static final Charset UTF_16_BE = Charset.forName("UTF-16BE"); - private static final Charset UTF_16_LE = Charset.forName("UTF-16LE"); - private static final Charset UTF_32_BE = Charset.forName("UTF-32BE"); - private static final Charset UTF_32_LE = Charset.forName("UTF-32LE"); - - /** GMT and UTC are equivalent for our purposes. */ - public static final TimeZone UTC = TimeZone.getTimeZone("GMT"); - - public static final Comparator NATURAL_ORDER = - new Comparator() { + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + public static final String[] EMPTY_STRING_ARRAY = new String[0]; + + public static final ResponseBody EMPTY_RESPONSE = ResponseBody.create(null, EMPTY_BYTE_ARRAY); + public static final RequestBody EMPTY_REQUEST = RequestBody.create(null, EMPTY_BYTE_ARRAY); + + private static final ByteString UTF_8_BOM = ByteString.decodeHex("efbbbf"); + private static final ByteString UTF_16_BE_BOM = ByteString.decodeHex("feff"); + private static final ByteString UTF_16_LE_BOM = ByteString.decodeHex("fffe"); + private static final ByteString UTF_32_BE_BOM = ByteString.decodeHex("0000ffff"); + private static final ByteString UTF_32_LE_BOM = ByteString.decodeHex("ffff0000"); + + public static final Charset UTF_8 = Charset.forName("UTF-8"); + public static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); + private static final Charset UTF_16_BE = Charset.forName("UTF-16BE"); + private static final Charset UTF_16_LE = Charset.forName("UTF-16LE"); + private static final Charset UTF_32_BE = Charset.forName("UTF-32BE"); + private static final Charset UTF_32_LE = Charset.forName("UTF-32LE"); + + /** GMT and UTC are equivalent for our purposes. */ + public static final TimeZone UTC = TimeZone.getTimeZone("GMT"); + + public static final Comparator NATURAL_ORDER = new Comparator() { @Override public int compare(String a, String b) { - return a.compareTo(b); - } - }; - - private static final Method addSuppressedExceptionMethod; - - static { - Method m; - try { - m = Throwable.class.getDeclaredMethod("addSuppressed", Throwable.class); - } catch (Exception e) { - m = null; - } - addSuppressedExceptionMethod = m; - } - - public static void addSuppressedIfPossible(Throwable e, Throwable suppressed) { - if (addSuppressedExceptionMethod != null) { - try { - addSuppressedExceptionMethod.invoke(e, suppressed); - } catch (InvocationTargetException | IllegalAccessException ignored) { - } - } - } - - /** - * Quick and dirty pattern to differentiate IP addresses from hostnames. This is an approximation - * of Android's private InetAddress#isNumeric API. - * - *

This matches IPv6 addresses as a hex string containing at least one colon, and possibly - * including dots after the first colon. It matches IPv4 addresses as strings containing only - * decimal digits and dots. This pattern matches strings like "a:.23" and "54" that are neither IP - * addresses nor hostnames; they will be verified as IP addresses (which is a more strict - * verification). - */ - private static final Pattern VERIFY_AS_IP_ADDRESS = - Pattern.compile("([0-9a-fA-F]*:[0-9a-fA-F:.]*)|([\\d.]+)"); - - private PatchUtil() {} - - public static void checkOffsetAndCount(long arrayLength, long offset, long count) { - if ((offset | count) < 0 || offset > arrayLength || arrayLength - offset < count) { - throw new ArrayIndexOutOfBoundsException(); - } - } - - /** Returns true if two possibly-null objects are equal. */ - public static boolean equal(Object a, Object b) { - return a == b || (a != null && a.equals(b)); - } - - /** - * Closes {@code closeable}, ignoring any checked exceptions. Does nothing if {@code closeable} is - * null. - */ - public static void closeQuietly(Closeable closeable) { - if (closeable != null) { - try { - closeable.close(); - } catch (RuntimeException rethrown) { - throw rethrown; - } catch (Exception ignored) { - } - } - } - - /** - * Closes {@code socket}, ignoring any checked exceptions. Does nothing if {@code socket} is null. - */ - public static void closeQuietly(Socket socket) { - if (socket != null) { - try { - socket.close(); - } catch (AssertionError e) { - if (!isAndroidGetsocknameError(e)) throw e; - } catch (RuntimeException rethrown) { - if ("bio == null".equals(rethrown.getMessage())) { - // Conscrypt in Android 10 and 11 may throw closing an SSLSocket. This is safe to ignore. - // https://issuetracker.google.com/issues/177450597 - return; - } - throw rethrown; - } catch (Exception ignored) { - } - } - } - - /** - * Closes {@code serverSocket}, ignoring any checked exceptions. Does nothing if {@code - * serverSocket} is null. - */ - public static void closeQuietly(ServerSocket serverSocket) { - if (serverSocket != null) { - try { - serverSocket.close(); - } catch (RuntimeException rethrown) { - throw rethrown; - } catch (Exception ignored) { - } - } - } - - /** - * Attempts to exhaust {@code source}, returning true if successful. This is useful when reading a - * complete source is helpful, such as when doing so completes a cache body or frees a socket - * connection for reuse. - */ - public static boolean discard(Source source, int timeout, TimeUnit timeUnit) { - try { - return skipAll(source, timeout, timeUnit); - } catch (IOException e) { - return false; - } - } - - /** - * Reads until {@code in} is exhausted or the deadline has been reached. This is careful to not - * extend the deadline if one exists already. - */ - public static boolean skipAll(Source source, int duration, TimeUnit timeUnit) throws IOException { - long now = System.nanoTime(); - long originalDuration = - source.timeout().hasDeadline() ? source.timeout().deadlineNanoTime() - now : Long.MAX_VALUE; - source.timeout().deadlineNanoTime(now + Math.min(originalDuration, timeUnit.toNanos(duration))); - try { - Buffer skipBuffer = new Buffer(); - while (source.read(skipBuffer, 8192) != -1) { - skipBuffer.clear(); - } - return true; // Success! The source has been exhausted. - } catch (InterruptedIOException e) { - return false; // We ran out of time before exhausting the source. - } finally { - if (originalDuration == Long.MAX_VALUE) { - source.timeout().clearDeadline(); - } else { - source.timeout().deadlineNanoTime(now + originalDuration); - } - } - } - - /** Returns an immutable copy of {@code list}. */ - public static List immutableList(List list) { - return Collections.unmodifiableList(new ArrayList<>(list)); - } - - /** Returns an immutable copy of {@code map}. */ - public static Map immutableMap(Map map) { - return map.isEmpty() - ? Collections.emptyMap() - : Collections.unmodifiableMap(new LinkedHashMap<>(map)); - } - - /** Returns an immutable list containing {@code elements}. */ - public static List immutableList(T... elements) { - return Collections.unmodifiableList(Arrays.asList(elements.clone())); - } - - public static ThreadFactory threadFactory(final String name, final boolean daemon) { - return new ThreadFactory() { - @Override - public Thread newThread(Runnable runnable) { - Thread result = new Thread(runnable, name); - result.setDaemon(true); // always create daemon threads - return result; - } + return a.compareTo(b); + } }; - } - - /** - * Returns an array containing only elements found in {@code first} and also in {@code second}. - * The returned elements are in the same order as in {@code first}. - */ - @SuppressWarnings("unchecked") - public static String[] intersect( - Comparator comparator, String[] first, String[] second) { - List result = new ArrayList<>(); - for (String a : first) { - for (String b : second) { - if (comparator.compare(a, b) == 0) { - result.add(a); - break; - } - } - } - return result.toArray(new String[result.size()]); - } - - /** - * Returns true if there is an element in {@code first} that is also in {@code second}. This - * method terminates if any intersection is found. The sizes of both arguments are assumed to be - * so small, and the likelihood of an intersection so great, that it is not worth the CPU cost of - * sorting or the memory cost of hashing. - */ - public static boolean nonEmptyIntersection( - Comparator comparator, String[] first, String[] second) { - if (first == null || second == null || first.length == 0 || second.length == 0) { - return false; - } - for (String a : first) { - for (String b : second) { - if (comparator.compare(a, b) == 0) { - return true; - } - } - } - return false; - } - - public static String hostHeader(HttpUrl url, boolean includeDefaultPort) { - String host = url.host().contains(":") ? "[" + url.host() + "]" : url.host(); - return includeDefaultPort || url.port() != HttpUrl.defaultPort(url.scheme()) - ? host + ":" + url.port() - : host; - } - - /** - * Returns true if {@code e} is due to a firmware bug fixed after Android 4.2.2. - * https://code.google.com/p/android/issues/detail?id=54072 - */ - public static boolean isAndroidGetsocknameError(AssertionError e) { - return e.getCause() != null - && e.getMessage() != null - && e.getMessage().contains("getsockname failed"); - } - - public static int indexOf(Comparator comparator, String[] array, String value) { - for (int i = 0, size = array.length; i < size; i++) { - if (comparator.compare(array[i], value) == 0) return i; - } - return -1; - } - - public static String[] concat(String[] array, String value) { - String[] result = new String[array.length + 1]; - System.arraycopy(array, 0, result, 0, array.length); - result[result.length - 1] = value; - return result; - } - - /** - * Increments {@code pos} until {@code input[pos]} is not ASCII whitespace. Stops at {@code - * limit}. - */ - public static int skipLeadingAsciiWhitespace(String input, int pos, int limit) { - for (int i = pos; i < limit; i++) { - switch (input.charAt(i)) { - case '\t': - case '\n': - case '\f': - case '\r': - case ' ': - continue; - default: - return i; - } - } - return limit; - } - - /** - * Decrements {@code limit} until {@code input[limit - 1]} is not ASCII whitespace. Stops at - * {@code pos}. - */ - public static int skipTrailingAsciiWhitespace(String input, int pos, int limit) { - for (int i = limit - 1; i >= pos; i--) { - switch (input.charAt(i)) { - case '\t': - case '\n': - case '\f': - case '\r': - case ' ': - continue; - default: - return i + 1; - } - } - return pos; - } - - /** Equivalent to {@code string.substring(pos, limit).trim()}. */ - public static String trimSubstring(String string, int pos, int limit) { - int start = skipLeadingAsciiWhitespace(string, pos, limit); - int end = skipTrailingAsciiWhitespace(string, start, limit); - return string.substring(start, end); - } - - /** - * Returns the index of the first character in {@code input} that contains a character in {@code - * delimiters}. Returns limit if there is no such character. - */ - public static int delimiterOffset(String input, int pos, int limit, String delimiters) { - for (int i = pos; i < limit; i++) { - if (delimiters.indexOf(input.charAt(i)) != -1) return i; - } - return limit; - } - - /** - * Returns the index of the first character in {@code input} that is {@code delimiter}. Returns - * limit if there is no such character. - */ - public static int delimiterOffset(String input, int pos, int limit, char delimiter) { - for (int i = pos; i < limit; i++) { - if (input.charAt(i) == delimiter) return i; - } - return limit; - } - - /** - * If {@code host} is an IP address, this returns the IP address in canonical form. - * - *

Otherwise this performs IDN ToASCII encoding and canonicalize the result to lowercase. For - * example this converts {@code ☃.net} to {@code xn--n3h.net}, and {@code WwW.GoOgLe.cOm} to - * {@code www.google.com}. {@code null} will be returned if the host cannot be ToASCII encoded or - * if the result contains unsupported ASCII characters. - */ - public static String canonicalizeHost(String host) { - // If the input contains a :, it’s an IPv6 address. - if (host.contains(":")) { - // If the input is encased in square braces "[...]", drop 'em. - InetAddress inetAddress = - host.startsWith("[") && host.endsWith("]") - ? decodeIpv6(host, 1, host.length() - 1) - : decodeIpv6(host, 0, host.length()); - if (inetAddress == null) return null; - byte[] address = inetAddress.getAddress(); - if (address.length == 16) return inet6AddressToAscii(address); - throw new AssertionError("Invalid IPv6 address: '" + host + "'"); - } - - try { - String result = IDN.toASCII(host).toLowerCase(Locale.US); - if (result.isEmpty()) return null; - - // Confirm that the IDN ToASCII result doesn't contain any illegal characters. - if (containsInvalidHostnameAsciiCodes(result)) { - return null; - } - // TODO: implement all label limits. - return result; - } catch (IllegalArgumentException e) { - return null; - } - } - - private static boolean containsInvalidHostnameAsciiCodes(String hostnameAscii) { - for (int i = 0; i < hostnameAscii.length(); i++) { - char c = hostnameAscii.charAt(i); - // The WHATWG Host parsing rules accepts some character codes which are invalid by - // definition for OkHttp's host header checks (and the WHATWG Host syntax definition). Here - // we rule out characters that would cause problems in host headers. - if (c <= '\u001f' || c >= '\u007f') { - return true; - } - // Check for the characters mentioned in the WHATWG Host parsing spec: - // U+0000, U+0009, U+000A, U+000D, U+0020, "#", "%", "/", ":", "?", "@", "[", "\", and "]" - // (excluding the characters covered above). - if (" #%/:?@[\\]".indexOf(c) != -1) { - return true; - } - } - return false; - } - - /** - * Returns the index of the first character in {@code input} that is either a control character - * (like {@code \u0000 or \n}) or a non-ASCII character. Returns -1 if {@code input} has no such - * characters. - */ - public static int indexOfControlOrNonAscii(String input) { - for (int i = 0, length = input.length(); i < length; i++) { - char c = input.charAt(i); - if (c <= '\u001f' || c >= '\u007f') { - return i; - } - } - return -1; - } - - /** Returns true if {@code host} is not a host name and might be an IP address. */ - public static boolean verifyAsIpAddress(String host) { - return VERIFY_AS_IP_ADDRESS.matcher(host).matches(); - } - - /** Returns a {@link Locale#US} formatted {@link String}. */ - public static String format(String format, Object... args) { - return String.format(Locale.US, format, args); - } - - public static Charset bomAwareCharset(BufferedSource source, Charset charset) throws IOException { - if (source.rangeEquals(0, UTF_8_BOM)) { - source.skip(UTF_8_BOM.size()); - return UTF_8; - } - if (source.rangeEquals(0, UTF_16_BE_BOM)) { - source.skip(UTF_16_BE_BOM.size()); - return UTF_16_BE; - } - if (source.rangeEquals(0, UTF_16_LE_BOM)) { - source.skip(UTF_16_LE_BOM.size()); - return UTF_16_LE; - } - if (source.rangeEquals(0, UTF_32_BE_BOM)) { - source.skip(UTF_32_BE_BOM.size()); - return UTF_32_BE; - } - if (source.rangeEquals(0, UTF_32_LE_BOM)) { - source.skip(UTF_32_LE_BOM.size()); - return UTF_32_LE; - } - return charset; - } - - public static int checkDuration(String name, long duration, TimeUnit unit) { - if (duration < 0) throw new IllegalArgumentException(name + " < 0"); - if (unit == null) throw new NullPointerException("unit == null"); - long millis = unit.toMillis(duration); - if (millis > Integer.MAX_VALUE) throw new IllegalArgumentException(name + " too large."); - if (millis == 0 && duration > 0) throw new IllegalArgumentException(name + " too small."); - return (int) millis; - } - - public static AssertionError assertionError(String message, Exception e) { - AssertionError assertionError = new AssertionError(message); - try { - assertionError.initCause(e); - } catch (IllegalStateException ise) { - // ignored, shouldn't happen - } - return assertionError; - } - - public static int decodeHexDigit(char c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; - } - - /** Decodes an IPv6 address like 1111:2222:3333:4444:5555:6666:7777:8888 or ::1. */ - private static @Nullable InetAddress decodeIpv6(String input, int pos, int limit) { - byte[] address = new byte[16]; - int b = 0; - int compress = -1; - int groupOffset = -1; - - for (int i = pos; i < limit; ) { - if (b == address.length) return null; // Too many groups. - - // Read a delimiter. - if (i + 2 <= limit && input.regionMatches(i, "::", 0, 2)) { - // Compression "::" delimiter, which is anywhere in the input, including its prefix. - if (compress != -1) return null; // Multiple "::" delimiters. - i += 2; - b += 2; - compress = b; - if (i == limit) break; - } else if (b != 0) { - // Group separator ":" delimiter. - if (input.regionMatches(i, ":", 0, 1)) { - i++; - } else if (input.regionMatches(i, ".", 0, 1)) { - // If we see a '.', rewind to the beginning of the previous group and parse as IPv4. - if (!decodeIpv4Suffix(input, groupOffset, limit, address, b - 2)) return null; - b += 2; // We rewound two bytes and then added four. - break; - } else { - return null; // Wrong delimiter. - } - } - - // Read a group, one to four hex digits. - int value = 0; - groupOffset = i; - for (; i < limit; i++) { - char c = input.charAt(i); - int hexDigit = decodeHexDigit(c); - if (hexDigit == -1) break; - value = (value << 4) + hexDigit; - } - int groupLength = i - groupOffset; - if (groupLength == 0 || groupLength > 4) return null; // Group is the wrong size. - - // We've successfully read a group. Assign its value to our byte array. - address[b++] = (byte) ((value >>> 8) & 0xff); - address[b++] = (byte) (value & 0xff); - } - - // All done. If compression happened, we need to move bytes to the right place in the - // address. Here's a sample: - // - // input: "1111:2222:3333::7777:8888" - // before: { 11, 11, 22, 22, 33, 33, 00, 00, 77, 77, 88, 88, 00, 00, 00, 00 } - // compress: 6 - // b: 10 - // after: { 11, 11, 22, 22, 33, 33, 00, 00, 00, 00, 00, 00, 77, 77, 88, 88 } - // - if (b != address.length) { - if (compress == -1) return null; // Address didn't have compression or enough groups. - System.arraycopy(address, compress, address, address.length - (b - compress), b - compress); - Arrays.fill(address, compress, compress + (address.length - b), (byte) 0); - } - - try { - return InetAddress.getByAddress(address); - } catch (UnknownHostException e) { - throw new AssertionError(); - } - } - - /** Decodes an IPv4 address suffix of an IPv6 address, like 1111::5555:6666:192.168.0.1. */ - private static boolean decodeIpv4Suffix( - String input, int pos, int limit, byte[] address, int addressOffset) { - int b = addressOffset; - - for (int i = pos; i < limit; ) { - if (b == address.length) return false; // Too many groups. - - // Read a delimiter. - if (b != addressOffset) { - if (input.charAt(i) != '.') return false; // Wrong delimiter. - i++; - } - - // Read 1 or more decimal digits for a value in 0..255. - int value = 0; - int groupOffset = i; - for (; i < limit; i++) { - char c = input.charAt(i); - if (c < '0' || c > '9') break; - if (value == 0 && groupOffset != i) return false; // Reject unnecessary leading '0's. - value = (value * 10) + c - '0'; - if (value > 255) return false; // Value out of range. - } - int groupLength = i - groupOffset; - if (groupLength == 0) return false; // No digits. - - // We've successfully read a byte. - address[b++] = (byte) value; - } - - if (b != addressOffset + 4) return false; // Too few groups. We wanted exactly four. - return true; // Success. - } - - /** Encodes an IPv6 address in canonical form according to RFC 5952. */ - private static String inet6AddressToAscii(byte[] address) { - // Go through the address looking for the longest run of 0s. Each group is 2-bytes. - // A run must be longer than one group (section 4.2.2). - // If there are multiple equal runs, the first one must be used (section 4.2.3). - int longestRunOffset = -1; - int longestRunLength = 0; - for (int i = 0; i < address.length; i += 2) { - int currentRunOffset = i; - while (i < 16 && address[i] == 0 && address[i + 1] == 0) { - i += 2; - } - int currentRunLength = i - currentRunOffset; - if (currentRunLength > longestRunLength && currentRunLength >= 4) { - longestRunOffset = currentRunOffset; - longestRunLength = currentRunLength; - } - } - - // Emit each 2-byte group in hex, separated by ':'. The longest run of zeroes is "::". - Buffer result = new Buffer(); - for (int i = 0; i < address.length; ) { - if (i == longestRunOffset) { - result.writeByte(':'); - i += longestRunLength; - if (i == 16) result.writeByte(':'); - } else { - if (i > 0) result.writeByte(':'); - int group = (address[i] & 0xff) << 8 | address[i + 1] & 0xff; - result.writeHexadecimalUnsignedLong(group); - i += 2; - } - } - return result.readUtf8(); - } - - public static X509TrustManager platformTrustManager() { - try { - TrustManagerFactory trustManagerFactory = - TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init((KeyStore) null); - TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); - if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { - throw new IllegalStateException( - "Unexpected default trust managers:" + Arrays.toString(trustManagers)); - } - return (X509TrustManager) trustManagers[0]; - } catch (GeneralSecurityException e) { - throw assertionError("No System TLS", e); // The system has no TLS. Just give up. - } - } - - public static Headers toHeaders(List

headerBlock) { - Headers.Builder builder = new Headers.Builder(); - for (Header header : headerBlock) { - Internal.instance.addLenient(builder, header.name.utf8(), header.value.utf8()); - } - return builder.build(); - } + + private static final Method addSuppressedExceptionMethod; + + static { + Method m; + try { + m = Throwable.class.getDeclaredMethod("addSuppressed", Throwable.class); + } catch (Exception e) { + m = null; + } + addSuppressedExceptionMethod = m; + } + + public static void addSuppressedIfPossible(Throwable e, Throwable suppressed) { + if (addSuppressedExceptionMethod != null) { + try { + addSuppressedExceptionMethod.invoke(e, suppressed); + } catch (InvocationTargetException | IllegalAccessException ignored) { + } + } + } + + /** + * Quick and dirty pattern to differentiate IP addresses from hostnames. This is an approximation + * of Android's private InetAddress#isNumeric API. + * + *

This matches IPv6 addresses as a hex string containing at least one colon, and possibly + * including dots after the first colon. It matches IPv4 addresses as strings containing only + * decimal digits and dots. This pattern matches strings like "a:.23" and "54" that are neither IP + * addresses nor hostnames; they will be verified as IP addresses (which is a more strict + * verification). + */ + private static final Pattern VERIFY_AS_IP_ADDRESS = Pattern.compile("([0-9a-fA-F]*:[0-9a-fA-F:.]*)|([\\d.]+)"); + + private PatchUtil() {} + + public static void checkOffsetAndCount(long arrayLength, long offset, long count) { + if ((offset | count) < 0 || offset > arrayLength || arrayLength - offset < count) { + throw new ArrayIndexOutOfBoundsException(); + } + } + + /** Returns true if two possibly-null objects are equal. */ + public static boolean equal(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + /** + * Closes {@code closeable}, ignoring any checked exceptions. Does nothing if {@code closeable} is + * null. + */ + public static void closeQuietly(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } + + /** + * Closes {@code socket}, ignoring any checked exceptions. Does nothing if {@code socket} is null. + */ + public static void closeQuietly(Socket socket) { + if (socket != null) { + try { + socket.close(); + } catch (AssertionError e) { + if (!isAndroidGetsocknameError(e)) throw e; + } catch (RuntimeException rethrown) { + if ("bio == null".equals(rethrown.getMessage())) { + // Conscrypt in Android 10 and 11 may throw closing an SSLSocket. This is safe to ignore. + // https://issuetracker.google.com/issues/177450597 + return; + } + throw rethrown; + } catch (Exception ignored) { + } + } + } + + /** + * Closes {@code serverSocket}, ignoring any checked exceptions. Does nothing if {@code + * serverSocket} is null. + */ + public static void closeQuietly(ServerSocket serverSocket) { + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } + + /** + * Attempts to exhaust {@code source}, returning true if successful. This is useful when reading a + * complete source is helpful, such as when doing so completes a cache body or frees a socket + * connection for reuse. + */ + public static boolean discard(Source source, int timeout, TimeUnit timeUnit) { + try { + return skipAll(source, timeout, timeUnit); + } catch (IOException e) { + return false; + } + } + + /** + * Reads until {@code in} is exhausted or the deadline has been reached. This is careful to not + * extend the deadline if one exists already. + */ + public static boolean skipAll(Source source, int duration, TimeUnit timeUnit) throws IOException { + long now = System.nanoTime(); + long originalDuration = + source.timeout().hasDeadline() ? source.timeout().deadlineNanoTime() - now : Long.MAX_VALUE; + source.timeout().deadlineNanoTime(now + Math.min(originalDuration, timeUnit.toNanos(duration))); + try { + Buffer skipBuffer = new Buffer(); + while (source.read(skipBuffer, 8192) != -1) { + skipBuffer.clear(); + } + return true; // Success! The source has been exhausted. + } catch (InterruptedIOException e) { + return false; // We ran out of time before exhausting the source. + } finally { + if (originalDuration == Long.MAX_VALUE) { + source.timeout().clearDeadline(); + } else { + source.timeout().deadlineNanoTime(now + originalDuration); + } + } + } + + /** Returns an immutable copy of {@code list}. */ + public static List immutableList(List list) { + return Collections.unmodifiableList(new ArrayList<>(list)); + } + + /** Returns an immutable copy of {@code map}. */ + public static Map immutableMap(Map map) { + return map.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(map)); + } + + /** Returns an immutable list containing {@code elements}. */ + public static List immutableList(T... elements) { + return Collections.unmodifiableList(Arrays.asList(elements.clone())); + } + + public static ThreadFactory threadFactory(final String name, final boolean daemon) { + return new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread result = new Thread(runnable, name); + result.setDaemon(true); // always create daemon threads + return result; + } + }; + } + + /** + * Returns an array containing only elements found in {@code first} and also in {@code second}. + * The returned elements are in the same order as in {@code first}. + */ + @SuppressWarnings("unchecked") + public static String[] intersect(Comparator comparator, String[] first, String[] second) { + List result = new ArrayList<>(); + for (String a : first) { + for (String b : second) { + if (comparator.compare(a, b) == 0) { + result.add(a); + break; + } + } + } + return result.toArray(new String[result.size()]); + } + + /** + * Returns true if there is an element in {@code first} that is also in {@code second}. This + * method terminates if any intersection is found. The sizes of both arguments are assumed to be + * so small, and the likelihood of an intersection so great, that it is not worth the CPU cost of + * sorting or the memory cost of hashing. + */ + public static boolean nonEmptyIntersection(Comparator comparator, String[] first, String[] second) { + if (first == null || second == null || first.length == 0 || second.length == 0) { + return false; + } + for (String a : first) { + for (String b : second) { + if (comparator.compare(a, b) == 0) { + return true; + } + } + } + return false; + } + + public static String hostHeader(HttpUrl url, boolean includeDefaultPort) { + String host = url.host().contains(":") ? "[" + url.host() + "]" : url.host(); + return includeDefaultPort || url.port() != HttpUrl.defaultPort(url.scheme()) ? host + ":" + url.port() : host; + } + + /** + * Returns true if {@code e} is due to a firmware bug fixed after Android 4.2.2. + * https://code.google.com/p/android/issues/detail?id=54072 + */ + public static boolean isAndroidGetsocknameError(AssertionError e) { + return e.getCause() != null && e.getMessage() != null && e.getMessage().contains("getsockname failed"); + } + + public static int indexOf(Comparator comparator, String[] array, String value) { + for (int i = 0, size = array.length; i < size; i++) { + if (comparator.compare(array[i], value) == 0) return i; + } + return -1; + } + + public static String[] concat(String[] array, String value) { + String[] result = new String[array.length + 1]; + System.arraycopy(array, 0, result, 0, array.length); + result[result.length - 1] = value; + return result; + } + + /** + * Increments {@code pos} until {@code input[pos]} is not ASCII whitespace. Stops at {@code + * limit}. + */ + public static int skipLeadingAsciiWhitespace(String input, int pos, int limit) { + for (int i = pos; i < limit; i++) { + switch (input.charAt(i)) { + case '\t': + case '\n': + case '\f': + case '\r': + case ' ': + continue; + default: + return i; + } + } + return limit; + } + + /** + * Decrements {@code limit} until {@code input[limit - 1]} is not ASCII whitespace. Stops at + * {@code pos}. + */ + public static int skipTrailingAsciiWhitespace(String input, int pos, int limit) { + for (int i = limit - 1; i >= pos; i--) { + switch (input.charAt(i)) { + case '\t': + case '\n': + case '\f': + case '\r': + case ' ': + continue; + default: + return i + 1; + } + } + return pos; + } + + /** Equivalent to {@code string.substring(pos, limit).trim()}. */ + public static String trimSubstring(String string, int pos, int limit) { + int start = skipLeadingAsciiWhitespace(string, pos, limit); + int end = skipTrailingAsciiWhitespace(string, start, limit); + return string.substring(start, end); + } + + /** + * Returns the index of the first character in {@code input} that contains a character in {@code + * delimiters}. Returns limit if there is no such character. + */ + public static int delimiterOffset(String input, int pos, int limit, String delimiters) { + for (int i = pos; i < limit; i++) { + if (delimiters.indexOf(input.charAt(i)) != -1) return i; + } + return limit; + } + + /** + * Returns the index of the first character in {@code input} that is {@code delimiter}. Returns + * limit if there is no such character. + */ + public static int delimiterOffset(String input, int pos, int limit, char delimiter) { + for (int i = pos; i < limit; i++) { + if (input.charAt(i) == delimiter) return i; + } + return limit; + } + + /** + * If {@code host} is an IP address, this returns the IP address in canonical form. + * + *

Otherwise this performs IDN ToASCII encoding and canonicalize the result to lowercase. For + * example this converts {@code ☃.net} to {@code xn--n3h.net}, and {@code WwW.GoOgLe.cOm} to + * {@code www.google.com}. {@code null} will be returned if the host cannot be ToASCII encoded or + * if the result contains unsupported ASCII characters. + */ + public static String canonicalizeHost(String host) { + // If the input contains a :, it’s an IPv6 address. + if (host.contains(":")) { + // If the input is encased in square braces "[...]", drop 'em. + InetAddress inetAddress = host.startsWith("[") && host.endsWith("]") + ? decodeIpv6(host, 1, host.length() - 1) + : decodeIpv6(host, 0, host.length()); + if (inetAddress == null) return null; + byte[] address = inetAddress.getAddress(); + if (address.length == 16) return inet6AddressToAscii(address); + throw new AssertionError("Invalid IPv6 address: '" + host + "'"); + } + + try { + String result = IDN.toASCII(host).toLowerCase(Locale.US); + if (result.isEmpty()) return null; + + // Confirm that the IDN ToASCII result doesn't contain any illegal characters. + if (containsInvalidHostnameAsciiCodes(result)) { + return null; + } + // TODO: implement all label limits. + return result; + } catch (IllegalArgumentException e) { + return null; + } + } + + private static boolean containsInvalidHostnameAsciiCodes(String hostnameAscii) { + for (int i = 0; i < hostnameAscii.length(); i++) { + char c = hostnameAscii.charAt(i); + // The WHATWG Host parsing rules accepts some character codes which are invalid by + // definition for OkHttp's host header checks (and the WHATWG Host syntax definition). Here + // we rule out characters that would cause problems in host headers. + if (c <= '\u001f' || c >= '\u007f') { + return true; + } + // Check for the characters mentioned in the WHATWG Host parsing spec: + // U+0000, U+0009, U+000A, U+000D, U+0020, "#", "%", "/", ":", "?", "@", "[", "\", and "]" + // (excluding the characters covered above). + if (" #%/:?@[\\]".indexOf(c) != -1) { + return true; + } + } + return false; + } + + /** + * Returns the index of the first character in {@code input} that is either a control character + * (like {@code \u0000 or \n}) or a non-ASCII character. Returns -1 if {@code input} has no such + * characters. + */ + public static int indexOfControlOrNonAscii(String input) { + for (int i = 0, length = input.length(); i < length; i++) { + char c = input.charAt(i); + if (c <= '\u001f' || c >= '\u007f') { + return i; + } + } + return -1; + } + + /** Returns true if {@code host} is not a host name and might be an IP address. */ + public static boolean verifyAsIpAddress(String host) { + return VERIFY_AS_IP_ADDRESS.matcher(host).matches(); + } + + /** Returns a {@link Locale#US} formatted {@link String}. */ + public static String format(String format, Object... args) { + return String.format(Locale.US, format, args); + } + + public static Charset bomAwareCharset(BufferedSource source, Charset charset) throws IOException { + if (source.rangeEquals(0, UTF_8_BOM)) { + source.skip(UTF_8_BOM.size()); + return UTF_8; + } + if (source.rangeEquals(0, UTF_16_BE_BOM)) { + source.skip(UTF_16_BE_BOM.size()); + return UTF_16_BE; + } + if (source.rangeEquals(0, UTF_16_LE_BOM)) { + source.skip(UTF_16_LE_BOM.size()); + return UTF_16_LE; + } + if (source.rangeEquals(0, UTF_32_BE_BOM)) { + source.skip(UTF_32_BE_BOM.size()); + return UTF_32_BE; + } + if (source.rangeEquals(0, UTF_32_LE_BOM)) { + source.skip(UTF_32_LE_BOM.size()); + return UTF_32_LE; + } + return charset; + } + + public static int checkDuration(String name, long duration, TimeUnit unit) { + if (duration < 0) throw new IllegalArgumentException(name + " < 0"); + if (unit == null) throw new NullPointerException("unit == null"); + long millis = unit.toMillis(duration); + if (millis > Integer.MAX_VALUE) throw new IllegalArgumentException(name + " too large."); + if (millis == 0 && duration > 0) throw new IllegalArgumentException(name + " too small."); + return (int) millis; + } + + public static AssertionError assertionError(String message, Exception e) { + AssertionError assertionError = new AssertionError(message); + try { + assertionError.initCause(e); + } catch (IllegalStateException ise) { + // ignored, shouldn't happen + } + return assertionError; + } + + public static int decodeHexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + + /** Decodes an IPv6 address like 1111:2222:3333:4444:5555:6666:7777:8888 or ::1. */ + private static @Nullable InetAddress decodeIpv6(String input, int pos, int limit) { + byte[] address = new byte[16]; + int b = 0; + int compress = -1; + int groupOffset = -1; + + for (int i = pos; i < limit; ) { + if (b == address.length) return null; // Too many groups. + + // Read a delimiter. + if (i + 2 <= limit && input.regionMatches(i, "::", 0, 2)) { + // Compression "::" delimiter, which is anywhere in the input, including its prefix. + if (compress != -1) return null; // Multiple "::" delimiters. + i += 2; + b += 2; + compress = b; + if (i == limit) break; + } else if (b != 0) { + // Group separator ":" delimiter. + if (input.regionMatches(i, ":", 0, 1)) { + i++; + } else if (input.regionMatches(i, ".", 0, 1)) { + // If we see a '.', rewind to the beginning of the previous group and parse as IPv4. + if (!decodeIpv4Suffix(input, groupOffset, limit, address, b - 2)) return null; + b += 2; // We rewound two bytes and then added four. + break; + } else { + return null; // Wrong delimiter. + } + } + + // Read a group, one to four hex digits. + int value = 0; + groupOffset = i; + for (; i < limit; i++) { + char c = input.charAt(i); + int hexDigit = decodeHexDigit(c); + if (hexDigit == -1) break; + value = (value << 4) + hexDigit; + } + int groupLength = i - groupOffset; + if (groupLength == 0 || groupLength > 4) return null; // Group is the wrong size. + + // We've successfully read a group. Assign its value to our byte array. + address[b++] = (byte) ((value >>> 8) & 0xff); + address[b++] = (byte) (value & 0xff); + } + + // All done. If compression happened, we need to move bytes to the right place in the + // address. Here's a sample: + // + // input: "1111:2222:3333::7777:8888" + // before: { 11, 11, 22, 22, 33, 33, 00, 00, 77, 77, 88, 88, 00, 00, 00, 00 } + // compress: 6 + // b: 10 + // after: { 11, 11, 22, 22, 33, 33, 00, 00, 00, 00, 00, 00, 77, 77, 88, 88 } + // + if (b != address.length) { + if (compress == -1) return null; // Address didn't have compression or enough groups. + System.arraycopy(address, compress, address, address.length - (b - compress), b - compress); + Arrays.fill(address, compress, compress + (address.length - b), (byte) 0); + } + + try { + return InetAddress.getByAddress(address); + } catch (UnknownHostException e) { + throw new AssertionError(); + } + } + + /** Decodes an IPv4 address suffix of an IPv6 address, like 1111::5555:6666:192.168.0.1. */ + private static boolean decodeIpv4Suffix(String input, int pos, int limit, byte[] address, int addressOffset) { + int b = addressOffset; + + for (int i = pos; i < limit; ) { + if (b == address.length) return false; // Too many groups. + + // Read a delimiter. + if (b != addressOffset) { + if (input.charAt(i) != '.') return false; // Wrong delimiter. + i++; + } + + // Read 1 or more decimal digits for a value in 0..255. + int value = 0; + int groupOffset = i; + for (; i < limit; i++) { + char c = input.charAt(i); + if (c < '0' || c > '9') break; + if (value == 0 && groupOffset != i) return false; // Reject unnecessary leading '0's. + value = (value * 10) + c - '0'; + if (value > 255) return false; // Value out of range. + } + int groupLength = i - groupOffset; + if (groupLength == 0) return false; // No digits. + + // We've successfully read a byte. + address[b++] = (byte) value; + } + + if (b != addressOffset + 4) return false; // Too few groups. We wanted exactly four. + return true; // Success. + } + + /** Encodes an IPv6 address in canonical form according to RFC 5952. */ + private static String inet6AddressToAscii(byte[] address) { + // Go through the address looking for the longest run of 0s. Each group is 2-bytes. + // A run must be longer than one group (section 4.2.2). + // If there are multiple equal runs, the first one must be used (section 4.2.3). + int longestRunOffset = -1; + int longestRunLength = 0; + for (int i = 0; i < address.length; i += 2) { + int currentRunOffset = i; + while (i < 16 && address[i] == 0 && address[i + 1] == 0) { + i += 2; + } + int currentRunLength = i - currentRunOffset; + if (currentRunLength > longestRunLength && currentRunLength >= 4) { + longestRunOffset = currentRunOffset; + longestRunLength = currentRunLength; + } + } + + // Emit each 2-byte group in hex, separated by ':'. The longest run of zeroes is "::". + Buffer result = new Buffer(); + for (int i = 0; i < address.length; ) { + if (i == longestRunOffset) { + result.writeByte(':'); + i += longestRunLength; + if (i == 16) result.writeByte(':'); + } else { + if (i > 0) result.writeByte(':'); + int group = (address[i] & 0xff) << 8 | address[i + 1] & 0xff; + result.writeHexadecimalUnsignedLong(group); + i += 2; + } + } + return result.readUtf8(); + } + + public static X509TrustManager platformTrustManager() { + try { + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers)); + } + return (X509TrustManager) trustManagers[0]; + } catch (GeneralSecurityException e) { + throw assertionError("No System TLS", e); // The system has no TLS. Just give up. + } + } + + public static Headers toHeaders(List

headerBlock) { + Headers.Builder builder = new Headers.Builder(); + for (Header header : headerBlock) { + Internal.instance.addLenient(builder, header.name.utf8(), header.value.utf8()); + } + return builder.build(); + } } diff --git a/communication/src/main/java/okhttp3/internal/platform/PatchPlatform.java b/communication/src/main/java/okhttp3/internal/platform/PatchPlatform.java index dc9f9c2d4b6..792a4b51894 100644 --- a/communication/src/main/java/okhttp3/internal/platform/PatchPlatform.java +++ b/communication/src/main/java/okhttp3/internal/platform/PatchPlatform.java @@ -78,255 +78,251 @@ *

Supported on Android 6.0+ via {@code NetworkSecurityPolicy}. */ public class PatchPlatform { - private static final Platform PLATFORM = findPlatform(); - public static final int INFO = 4; - public static final int WARN = 5; - private static final Logger logger = Logger.getLogger(OkHttpClient.class.getName()); - - public static Platform get() { - return PLATFORM; - } - - /** Prefix used on custom headers. */ - public String getPrefix() { - return "OkHttp"; - } - - @SuppressForbidden // allow this use of Class.forName() - protected @Nullable X509TrustManager trustManager(SSLSocketFactory sslSocketFactory) { - // Attempt to get the trust manager from an OpenJDK socket factory. We attempt this on all - // platforms in order to support Robolectric, which mixes classes from both Android and the - // Oracle JDK. Note that we don't support HTTP/2 or other nice features on Robolectric. - try { - Class sslContextClass = Class.forName("sun.security.ssl.SSLContextImpl"); - Object context = readFieldOrNull(sslSocketFactory, sslContextClass, "context"); - if (context == null) return null; - return readFieldOrNull(context, X509TrustManager.class, "trustManager"); - } catch (ClassNotFoundException e) { - return null; + private static final Platform PLATFORM = findPlatform(); + public static final int INFO = 4; + public static final int WARN = 5; + private static final Logger logger = Logger.getLogger(OkHttpClient.class.getName()); + + public static Platform get() { + return PLATFORM; + } + + /** Prefix used on custom headers. */ + public String getPrefix() { + return "OkHttp"; + } + + @SuppressForbidden // allow this use of Class.forName() + protected @Nullable X509TrustManager trustManager(SSLSocketFactory sslSocketFactory) { + // Attempt to get the trust manager from an OpenJDK socket factory. We attempt this on all + // platforms in order to support Robolectric, which mixes classes from both Android and the + // Oracle JDK. Note that we don't support HTTP/2 or other nice features on Robolectric. + try { + Class sslContextClass = Class.forName("sun.security.ssl.SSLContextImpl"); + Object context = readFieldOrNull(sslSocketFactory, sslContextClass, "context"); + if (context == null) return null; + return readFieldOrNull(context, X509TrustManager.class, "trustManager"); + } catch (ClassNotFoundException e) { + return null; + } } - } - - /** - * Configure TLS extensions on {@code sslSocket} for {@code route}. - * - * @param hostname non-null for client-side handshakes; null for server-side handshakes. - */ - public void configureTlsExtensions( - SSLSocket sslSocket, @Nullable String hostname, List protocols) - throws IOException {} - - /** - * Called after the TLS handshake to release resources allocated by {@link - * #configureTlsExtensions}. - */ - public void afterHandshake(SSLSocket sslSocket) {} - - /** Returns the negotiated protocol, or null if no protocol was negotiated. */ - public @Nullable String getSelectedProtocol(SSLSocket socket) { - return null; - } - - public void connectSocket(Socket socket, InetSocketAddress address, int connectTimeout) - throws IOException { - socket.connect(address, connectTimeout); - } - - public void log(int level, String message, @Nullable Throwable t) { - Level logLevel = level == WARN ? Level.WARNING : Level.INFO; - logger.log(logLevel, message, t); - } - - public boolean isCleartextTrafficPermitted(String hostname) { - return true; - } - - /** - * Returns an object that holds a stack trace created at the moment this method is executed. This - * should be used specifically for {@link java.io.Closeable} objects and in conjunction with - * {@link #logCloseableLeak(String, Object)}. - */ - public Object getStackTraceForCloseable(String closer) { - if (logger.isLoggable(Level.FINE)) { - return new Throwable(closer); // These are expensive to allocate. + + /** + * Configure TLS extensions on {@code sslSocket} for {@code route}. + * + * @param hostname non-null for client-side handshakes; null for server-side handshakes. + */ + public void configureTlsExtensions(SSLSocket sslSocket, @Nullable String hostname, List protocols) + throws IOException {} + + /** + * Called after the TLS handshake to release resources allocated by {@link + * #configureTlsExtensions}. + */ + public void afterHandshake(SSLSocket sslSocket) {} + + /** Returns the negotiated protocol, or null if no protocol was negotiated. */ + public @Nullable String getSelectedProtocol(SSLSocket socket) { + return null; } - return null; - } - - public void logCloseableLeak(String message, Object stackTrace) { - if (stackTrace == null) { - message += - " To see where this was allocated, set the OkHttpClient logger level to FINE: " - + "Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);"; + + public void connectSocket(Socket socket, InetSocketAddress address, int connectTimeout) throws IOException { + socket.connect(address, connectTimeout); } - log(WARN, message, (Throwable) stackTrace); - } - - public static List alpnProtocolNames(List protocols) { - List names = new ArrayList<>(protocols.size()); - for (int i = 0, size = protocols.size(); i < size; i++) { - Protocol protocol = protocols.get(i); - if (protocol == Protocol.HTTP_1_0) continue; // No HTTP/1.0 for ALPN. - names.add(protocol.toString()); + + public void log(int level, String message, @Nullable Throwable t) { + Level logLevel = level == WARN ? Level.WARNING : Level.INFO; + logger.log(logLevel, message, t); } - return names; - } - - public CertificateChainCleaner buildCertificateChainCleaner(X509TrustManager trustManager) { - return new BasicCertificateChainCleaner(buildTrustRootIndex(trustManager)); - } - - public CertificateChainCleaner buildCertificateChainCleaner(SSLSocketFactory sslSocketFactory) { - X509TrustManager trustManager = trustManager(sslSocketFactory); - - if (trustManager == null) { - throw new IllegalStateException( - "Unable to extract the trust manager on " - + Platform.get() - + ", sslSocketFactory is " - + sslSocketFactory.getClass()); + + public boolean isCleartextTrafficPermitted(String hostname) { + return true; + } + + /** + * Returns an object that holds a stack trace created at the moment this method is executed. This + * should be used specifically for {@link java.io.Closeable} objects and in conjunction with + * {@link #logCloseableLeak(String, Object)}. + */ + public Object getStackTraceForCloseable(String closer) { + if (logger.isLoggable(Level.FINE)) { + return new Throwable(closer); // These are expensive to allocate. + } + return null; + } + + public void logCloseableLeak(String message, Object stackTrace) { + if (stackTrace == null) { + message += " To see where this was allocated, set the OkHttpClient logger level to FINE: " + + "Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);"; + } + log(WARN, message, (Throwable) stackTrace); + } + + public static List alpnProtocolNames(List protocols) { + List names = new ArrayList<>(protocols.size()); + for (int i = 0, size = protocols.size(); i < size; i++) { + Protocol protocol = protocols.get(i); + if (protocol == Protocol.HTTP_1_0) continue; // No HTTP/1.0 for ALPN. + names.add(protocol.toString()); + } + return names; } - return buildCertificateChainCleaner(trustManager); - } - - /* Avoid eager call to Security.getProviders() - ---------------------------------------------- - | public static boolean isConscryptPreferred() { - | // mainly to allow tests to run cleanly - | if ("conscrypt".equals(System.getProperty("okhttp.platform"))) { - | return true; - | } - | - | // check if Provider manually installed - | String preferredProvider = Security.getProviders()[0].getName(); - | return "Conscrypt".equals(preferredProvider); - | } - ---------------------------------------------- */ - - /** Attempt to match the host runtime to a capable Platform implementation. */ - private static Platform findPlatform() { - if (isAndroid()) { - return findAndroidPlatform(); - } else { - return findJvmPlatform(); + public CertificateChainCleaner buildCertificateChainCleaner(X509TrustManager trustManager) { + return new BasicCertificateChainCleaner(buildTrustRootIndex(trustManager)); } - } - public static boolean isAndroid() { - // This explicit check avoids activating in Android Studio with Android specific classes - // available when running plugins inside the IDE. - return "Dalvik".equals(System.getProperty("java.vm.name")); - } + public CertificateChainCleaner buildCertificateChainCleaner(SSLSocketFactory sslSocketFactory) { + X509TrustManager trustManager = trustManager(sslSocketFactory); + + if (trustManager == null) { + throw new IllegalStateException("Unable to extract the trust manager on " + + Platform.get() + + ", sslSocketFactory is " + + sslSocketFactory.getClass()); + } + + return buildCertificateChainCleaner(trustManager); + } - private static Platform findJvmPlatform() { /* Avoid eager call to Security.getProviders() ---------------------------------------------- - | if (isConscryptPreferred()) { - | Platform conscrypt = ConscryptPlatform.buildIfSupported(); - | - | if (conscrypt != null) { - | return conscrypt; + | public static boolean isConscryptPreferred() { + | // mainly to allow tests to run cleanly + | if ("conscrypt".equals(System.getProperty("okhttp.platform"))) { + | return true; | } + | + | // check if Provider manually installed + | String preferredProvider = Security.getProviders()[0].getName(); + | return "Conscrypt".equals(preferredProvider); | } ---------------------------------------------- */ - Platform jdk9 = Jdk9Platform.buildIfSupported(); + /** Attempt to match the host runtime to a capable Platform implementation. */ + private static Platform findPlatform() { + if (isAndroid()) { + return findAndroidPlatform(); + } else { + return findJvmPlatform(); + } + } - if (jdk9 != null) { - return jdk9; + public static boolean isAndroid() { + // This explicit check avoids activating in Android Studio with Android specific classes + // available when running plugins inside the IDE. + return "Dalvik".equals(System.getProperty("java.vm.name")); } - Platform jdkWithJettyBoot = JdkWithJettyBootPlatform.buildIfSupported(); + private static Platform findJvmPlatform() { + /* Avoid eager call to Security.getProviders() + ---------------------------------------------- + | if (isConscryptPreferred()) { + | Platform conscrypt = ConscryptPlatform.buildIfSupported(); + | + | if (conscrypt != null) { + | return conscrypt; + | } + | } + ---------------------------------------------- */ - if (jdkWithJettyBoot != null) { - return jdkWithJettyBoot; - } + Platform jdk9 = Jdk9Platform.buildIfSupported(); - // Probably an Oracle JDK like OpenJDK. - return new Platform(); - } + if (jdk9 != null) { + return jdk9; + } - private static Platform findAndroidPlatform() { - Platform android10 = Android10Platform.buildIfSupported(); + Platform jdkWithJettyBoot = JdkWithJettyBootPlatform.buildIfSupported(); - if (android10 != null) { - return android10; + if (jdkWithJettyBoot != null) { + return jdkWithJettyBoot; + } + + // Probably an Oracle JDK like OpenJDK. + return new Platform(); } - Platform android = AndroidPlatform.buildIfSupported(); + private static Platform findAndroidPlatform() { + Platform android10 = Android10Platform.buildIfSupported(); - if (android == null) { - throw new NullPointerException("No platform found on Android"); - } + if (android10 != null) { + return android10; + } - return android; - } - - /** - * Returns the concatenation of 8-bit, length prefixed protocol names. - * http://tools.ietf.org/html/draft-agl-tls-nextprotoneg-04#page-4 - */ - static byte[] concatLengthPrefixed(List protocols) { - Buffer result = new Buffer(); - for (int i = 0, size = protocols.size(); i < size; i++) { - Protocol protocol = protocols.get(i); - if (protocol == Protocol.HTTP_1_0) continue; // No HTTP/1.0 for ALPN. - result.writeByte(protocol.toString().length()); - result.writeUtf8(protocol.toString()); - } - return result.readByteArray(); - } - - static @Nullable T readFieldOrNull(Object instance, Class fieldType, String fieldName) { - for (Class c = instance.getClass(); c != Object.class; c = c.getSuperclass()) { - try { - Field field = c.getDeclaredField(fieldName); - field.setAccessible(true); - Object value = field.get(instance); - if (value == null || !fieldType.isInstance(value)) return null; - return fieldType.cast(value); - } catch (NoSuchFieldException ignored) { - } catch (IllegalAccessException e) { - throw new AssertionError(); - } + Platform android = AndroidPlatform.buildIfSupported(); + + if (android == null) { + throw new NullPointerException("No platform found on Android"); + } + + return android; } - // Didn't find the field we wanted. As a last gasp attempt, try to find the value on a delegate. - if (!fieldName.equals("delegate")) { - Object delegate = readFieldOrNull(instance, Object.class, "delegate"); - if (delegate != null) return readFieldOrNull(delegate, fieldType, fieldName); + /** + * Returns the concatenation of 8-bit, length prefixed protocol names. + * http://tools.ietf.org/html/draft-agl-tls-nextprotoneg-04#page-4 + */ + static byte[] concatLengthPrefixed(List protocols) { + Buffer result = new Buffer(); + for (int i = 0, size = protocols.size(); i < size; i++) { + Protocol protocol = protocols.get(i); + if (protocol == Protocol.HTTP_1_0) continue; // No HTTP/1.0 for ALPN. + result.writeByte(protocol.toString().length()); + result.writeUtf8(protocol.toString()); + } + return result.readByteArray(); } - return null; - } - - public SSLContext getSSLContext() { - String jvmVersion = System.getProperty("java.specification.version"); - if ("1.7".equals(jvmVersion)) { - try { - // JDK 1.7 (public version) only support > TLSv1 with named protocols - return SSLContext.getInstance("TLSv1.2"); - } catch (NoSuchAlgorithmException e) { - // fallback to TLS - } + static @Nullable T readFieldOrNull(Object instance, Class fieldType, String fieldName) { + for (Class c = instance.getClass(); c != Object.class; c = c.getSuperclass()) { + try { + Field field = c.getDeclaredField(fieldName); + field.setAccessible(true); + Object value = field.get(instance); + if (value == null || !fieldType.isInstance(value)) return null; + return fieldType.cast(value); + } catch (NoSuchFieldException ignored) { + } catch (IllegalAccessException e) { + throw new AssertionError(); + } + } + + // Didn't find the field we wanted. As a last gasp attempt, try to find the value on a delegate. + if (!fieldName.equals("delegate")) { + Object delegate = readFieldOrNull(instance, Object.class, "delegate"); + if (delegate != null) return readFieldOrNull(delegate, fieldType, fieldName); + } + + return null; } - try { - return SSLContext.getInstance("TLS"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("No TLS provider", e); + public SSLContext getSSLContext() { + String jvmVersion = System.getProperty("java.specification.version"); + if ("1.7".equals(jvmVersion)) { + try { + // JDK 1.7 (public version) only support > TLSv1 with named protocols + return SSLContext.getInstance("TLSv1.2"); + } catch (NoSuchAlgorithmException e) { + // fallback to TLS + } + } + + try { + return SSLContext.getInstance("TLS"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("No TLS provider", e); + } } - } - public TrustRootIndex buildTrustRootIndex(X509TrustManager trustManager) { - return new BasicTrustRootIndex(trustManager.getAcceptedIssuers()); - } + public TrustRootIndex buildTrustRootIndex(X509TrustManager trustManager) { + return new BasicTrustRootIndex(trustManager.getAcceptedIssuers()); + } - public void configureSslSocketFactory(SSLSocketFactory socketFactory) {} + public void configureSslSocketFactory(SSLSocketFactory socketFactory) {} - @Override - public String toString() { - return getClass().getSimpleName(); - } + @Override + public String toString() { + return getClass().getSimpleName(); + } } diff --git a/communication/src/test/groovy/datadog/communication/serialization/FlushingBufferTest.java b/communication/src/test/groovy/datadog/communication/serialization/FlushingBufferTest.java index 708a5cb8514..b0b95f1e249 100644 --- a/communication/src/test/groovy/datadog/communication/serialization/FlushingBufferTest.java +++ b/communication/src/test/groovy/datadog/communication/serialization/FlushingBufferTest.java @@ -6,49 +6,49 @@ public class FlushingBufferTest { - @Test - public void testBufferCapacity() { - assertEquals(5, new FlushingBuffer(5, (messageCount, buffer) -> {}).capacity()); - } - - @Test - public void testMessageCount() { - FlushingBuffer fb = new FlushingBuffer(10, (messageCount, buffer) -> {}); - - // initial counter - assertEquals(0, fb.getMessageCount()); - - fb.mark(); - fb.mark(); - - // counter doesn't change if no data pushed into the buffer - assertEquals(0, fb.getMessageCount()); - - fb.put((byte) 1); - // still zero because the message counter increases on mark - assertEquals(0, fb.getMessageCount()); - - fb.mark(); - // expect increased message counter - assertEquals(1, fb.getMessageCount()); - - fb.mark(); - fb.mark(); - // no change to the counter expected for consecutive mark calls - - fb.putChar('a'); - fb.putChar('b'); - fb.putChar('c'); - // no change to the message counter expected before mark call - assertEquals(1, fb.getMessageCount()); - - fb.mark(); - // expect increased message counter - assertEquals(2, fb.getMessageCount()); - - fb.mark(); - fb.mark(); - // no change to the counter expected for consecutive mark calls - assertEquals(2, fb.getMessageCount()); - } + @Test + public void testBufferCapacity() { + assertEquals(5, new FlushingBuffer(5, (messageCount, buffer) -> {}).capacity()); + } + + @Test + public void testMessageCount() { + FlushingBuffer fb = new FlushingBuffer(10, (messageCount, buffer) -> {}); + + // initial counter + assertEquals(0, fb.getMessageCount()); + + fb.mark(); + fb.mark(); + + // counter doesn't change if no data pushed into the buffer + assertEquals(0, fb.getMessageCount()); + + fb.put((byte) 1); + // still zero because the message counter increases on mark + assertEquals(0, fb.getMessageCount()); + + fb.mark(); + // expect increased message counter + assertEquals(1, fb.getMessageCount()); + + fb.mark(); + fb.mark(); + // no change to the counter expected for consecutive mark calls + + fb.putChar('a'); + fb.putChar('b'); + fb.putChar('c'); + // no change to the message counter expected before mark call + assertEquals(1, fb.getMessageCount()); + + fb.mark(); + // expect increased message counter + assertEquals(2, fb.getMessageCount()); + + fb.mark(); + fb.mark(); + // no change to the counter expected for consecutive mark calls + assertEquals(2, fb.getMessageCount()); + } } diff --git a/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java b/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java index 44690ef1c3f..e651ca50368 100644 --- a/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java +++ b/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java @@ -30,207 +30,194 @@ class BackendApiFactoryTest { - private static final MediaType JSON = MediaType.parse("application/json"); - - @ParameterizedTest - @ValueSource(strings = {"datadoghq.com", "custom.example", "DATADOGHQ.EU"}) - void eventPlatformDirectIntakeUsesExactHttpsHost(String site) { - final HttpUrl url = BackendApiFactory.buildEventPlatformIntakeUrl(site); - - assertEquals("https", url.scheme()); - assertEquals("event-platform-intake." + site.toLowerCase(Locale.ROOT), url.host()); - assertEquals(443, url.port()); - assertEquals("/api/v2/", url.encodedPath()); - assertEquals("", url.username()); - assertEquals("", url.password()); - assertNull(url.encodedQuery()); - assertNull(url.encodedFragment()); - } - - @ParameterizedTest - @NullAndEmptySource - @ValueSource( - strings = { - "datadoghq.com@evil.example", - "datadoghq.com:password@evil.example", - "https://datadoghq.com", - "datadoghq.com:443", - "datadoghq.com:8443", - "datadoghq.com/path", - "datadoghq.com?query=value", - "datadoghq.com#fragment", - "dätadoghq.com", - "data doghq.com", - " datadoghq.com", - "datadoghq.com ", - "datadoghq.com\\evil.example" - }) - void eventPlatformDirectIntakeRejectsUnsafeSite(String site) { - assertThrows( - IllegalArgumentException.class, () -> BackendApiFactory.buildEventPlatformIntakeUrl(site)); - } - - @ParameterizedTest - @ValueSource(ints = {301, 302, 307, 308}) - void featureFlagDirectIntakeDoesNotFollowRedirects(final int statusCode) throws Exception { - final MockWebServer intake = new MockWebServer(); - final MockWebServer redirectTarget = new MockWebServer(); - final OkHttpClient sharedClient = new OkHttpClient.Builder().build(); - final OkHttpClient directClient = BackendApiFactory.directIntakeHttpClient(sharedClient, false); - redirectTarget.start(); - intake.enqueue( - new MockResponse() - .setResponseCode(statusCode) - .setHeader("Location", redirectTarget.url("/redirected"))); - intake.start(); - try { - final IntakeApi api = - new IntakeApi( - intake.url("/api/v2/"), - "api-key", - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - directClient, - false); - - assertThrows( - IOException.class, - () -> - api.post( - "flagevaluation", - RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), - stream -> null, - null, - false)); - - final RecordedRequest request = intake.takeRequest(); - assertEquals("api-key", request.getHeader("DD-API-KEY")); - assertEquals(1, intake.getRequestCount()); - assertEquals(0, redirectTarget.getRequestCount()); - } finally { - directClient.dispatcher().executorService().shutdownNow(); - directClient.connectionPool().evictAll(); - sharedClient.dispatcher().executorService().shutdownNow(); - sharedClient.connectionPool().evictAll(); - intake.shutdown(); - redirectTarget.shutdown(); + private static final MediaType JSON = MediaType.parse("application/json"); + + @ParameterizedTest + @ValueSource(strings = {"datadoghq.com", "custom.example", "DATADOGHQ.EU"}) + void eventPlatformDirectIntakeUsesExactHttpsHost(String site) { + final HttpUrl url = BackendApiFactory.buildEventPlatformIntakeUrl(site); + + assertEquals("https", url.scheme()); + assertEquals("event-platform-intake." + site.toLowerCase(Locale.ROOT), url.host()); + assertEquals(443, url.port()); + assertEquals("/api/v2/", url.encodedPath()); + assertEquals("", url.username()); + assertEquals("", url.password()); + assertNull(url.encodedQuery()); + assertNull(url.encodedFragment()); } - } - - @Test - void noBackendApiWhenAgentDoesNotAdvertiseEvpProxy() { - final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(null); - final BackendApiFactory factory = - new BackendApiFactory(Config.get(), sharedCommunicationObjects(discovery, null)); - - assertNull(factory.createBackendApi(Intake.EVENT_PLATFORM, false)); - } - - @Test - void advertisedEvpProxyEndpointSupportsDisabledResponseCompression() throws Exception { - final MockWebServer agent = new MockWebServer(); - agent.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - agent.start(); - try { - final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(V4_EVP_PROXY_ENDPOINT); - final BackendApiFactory factory = - new BackendApiFactory( - Config.get(), sharedCommunicationObjects(discovery, agent.url("/"))); - final BackendApi api = factory.createBackendApi(Intake.EVENT_PLATFORM, false); - - assertNotNull(api); - api.post( - "flagevaluation", - RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), - stream -> null, - null, - false); - - final RecordedRequest request = agent.takeRequest(); - assertEquals("/evp_proxy/v4/api/v2/flagevaluation", request.getPath()); - } finally { - agent.shutdown(); + + @ParameterizedTest + @NullAndEmptySource + @ValueSource( + strings = { + "datadoghq.com@evil.example", + "datadoghq.com:password@evil.example", + "https://datadoghq.com", + "datadoghq.com:443", + "datadoghq.com:8443", + "datadoghq.com/path", + "datadoghq.com?query=value", + "datadoghq.com#fragment", + "dätadoghq.com", + "data doghq.com", + " datadoghq.com", + "datadoghq.com ", + "datadoghq.com\\evil.example" + }) + void eventPlatformDirectIntakeRejectsUnsafeSite(String site) { + assertThrows(IllegalArgumentException.class, () -> BackendApiFactory.buildEventPlatformIntakeUrl(site)); } - } - - @Test - void explicitNoRetryProxyPolicyDoesNotReplayAmbiguousFailure() throws Exception { - final MockWebServer agent = new MockWebServer(); - agent.enqueue(new MockResponse().setResponseCode(500).setBody("ambiguous")); - agent.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - agent.start(); - try { - final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(V4_EVP_PROXY_ENDPOINT); - final BackendApiFactory factory = - new BackendApiFactory( - Config.get(), sharedCommunicationObjects(discovery, agent.url("/"))); - final BackendApi api = - factory.createEvpProxyApi( - Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); - - assertNotNull(api); - assertThrows( - HttpResponseException.class, - () -> - api.post( - "flagevaluation", - RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), - stream -> null, - null, - false)); - - assertEquals(1, agent.getRequestCount()); - } finally { - agent.shutdown(); + + @ParameterizedTest + @ValueSource(ints = {301, 302, 307, 308}) + void featureFlagDirectIntakeDoesNotFollowRedirects(final int statusCode) throws Exception { + final MockWebServer intake = new MockWebServer(); + final MockWebServer redirectTarget = new MockWebServer(); + final OkHttpClient sharedClient = new OkHttpClient.Builder().build(); + final OkHttpClient directClient = BackendApiFactory.directIntakeHttpClient(sharedClient, false); + redirectTarget.start(); + intake.enqueue(new MockResponse() + .setResponseCode(statusCode) + .setHeader("Location", redirectTarget.url("/redirected"))); + intake.start(); + try { + final IntakeApi api = new IntakeApi( + intake.url("/api/v2/"), "api-key", "123", HttpRetryPolicy.Factory.NEVER_RETRY, directClient, false); + + assertThrows( + IOException.class, + () -> api.post( + "flagevaluation", + RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), + stream -> null, + null, + false)); + + final RecordedRequest request = intake.takeRequest(); + assertEquals("api-key", request.getHeader("DD-API-KEY")); + assertEquals(1, intake.getRequestCount()); + assertEquals(0, redirectTarget.getRequestCount()); + } finally { + directClient.dispatcher().executorService().shutdownNow(); + directClient.connectionPool().evictAll(); + sharedClient.dispatcher().executorService().shutdownNow(); + sharedClient.connectionPool().evictAll(); + intake.shutdown(); + redirectTarget.shutdown(); + } } - } - private static SharedCommunicationObjects sharedCommunicationObjects( - final DDAgentFeaturesDiscovery discovery, final HttpUrl agentUrl) { - final TestSharedCommunicationObjects sco = new TestSharedCommunicationObjects(discovery); - sco.agentUrl = agentUrl != null ? agentUrl : HttpUrl.get("http://localhost:8126/"); - sco.agentHttpClient = new OkHttpClient(); - return sco; - } + @Test + void noBackendApiWhenAgentDoesNotAdvertiseEvpProxy() { + final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(null); + final BackendApiFactory factory = + new BackendApiFactory(Config.get(), sharedCommunicationObjects(discovery, null)); - private static final class TestSharedCommunicationObjects extends SharedCommunicationObjects { - private final DDAgentFeaturesDiscovery discovery; + assertNull(factory.createBackendApi(Intake.EVENT_PLATFORM, false)); + } - private TestSharedCommunicationObjects(final DDAgentFeaturesDiscovery discovery) { - this.discovery = discovery; + @Test + void advertisedEvpProxyEndpointSupportsDisabledResponseCompression() throws Exception { + final MockWebServer agent = new MockWebServer(); + agent.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + agent.start(); + try { + final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(V4_EVP_PROXY_ENDPOINT); + final BackendApiFactory factory = + new BackendApiFactory(Config.get(), sharedCommunicationObjects(discovery, agent.url("/"))); + final BackendApi api = factory.createBackendApi(Intake.EVENT_PLATFORM, false); + + assertNotNull(api); + api.post( + "flagevaluation", + RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), + stream -> null, + null, + false); + + final RecordedRequest request = agent.takeRequest(); + assertEquals("/evp_proxy/v4/api/v2/flagevaluation", request.getPath()); + } finally { + agent.shutdown(); + } } - @Override - public DDAgentFeaturesDiscovery featuresDiscovery(final Config config) { - return discovery; + @Test + void explicitNoRetryProxyPolicyDoesNotReplayAmbiguousFailure() throws Exception { + final MockWebServer agent = new MockWebServer(); + agent.enqueue(new MockResponse().setResponseCode(500).setBody("ambiguous")); + agent.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + agent.start(); + try { + final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(V4_EVP_PROXY_ENDPOINT); + final BackendApiFactory factory = + new BackendApiFactory(Config.get(), sharedCommunicationObjects(discovery, agent.url("/"))); + final BackendApi api = + factory.createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); + + assertNotNull(api); + assertThrows( + HttpResponseException.class, + () -> api.post( + "flagevaluation", + RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), + stream -> null, + null, + false)); + + assertEquals(1, agent.getRequestCount()); + } finally { + agent.shutdown(); + } } - } - - private static final class FakeFeaturesDiscovery extends DDAgentFeaturesDiscovery { - private final String evpProxyEndpoint; - - private FakeFeaturesDiscovery(final String evpProxyEndpoint) { - super( - new OkHttpClient(), - Monitoring.DISABLED, - HttpUrl.get("http://localhost:8126/"), - ProtocolVersion.V0_5, - true, - false); - this.evpProxyEndpoint = evpProxyEndpoint; + + private static SharedCommunicationObjects sharedCommunicationObjects( + final DDAgentFeaturesDiscovery discovery, final HttpUrl agentUrl) { + final TestSharedCommunicationObjects sco = new TestSharedCommunicationObjects(discovery); + sco.agentUrl = agentUrl != null ? agentUrl : HttpUrl.get("http://localhost:8126/"); + sco.agentHttpClient = new OkHttpClient(); + return sco; } - @Override - public void discoverIfOutdated() {} + private static final class TestSharedCommunicationObjects extends SharedCommunicationObjects { + private final DDAgentFeaturesDiscovery discovery; + + private TestSharedCommunicationObjects(final DDAgentFeaturesDiscovery discovery) { + this.discovery = discovery; + } - @Override - public String getEvpProxyEndpoint() { - return evpProxyEndpoint; + @Override + public DDAgentFeaturesDiscovery featuresDiscovery(final Config config) { + return discovery; + } } - @Override - public boolean supportsEvpProxy() { - return evpProxyEndpoint != null; + private static final class FakeFeaturesDiscovery extends DDAgentFeaturesDiscovery { + private final String evpProxyEndpoint; + + private FakeFeaturesDiscovery(final String evpProxyEndpoint) { + super( + new OkHttpClient(), + Monitoring.DISABLED, + HttpUrl.get("http://localhost:8126/"), + ProtocolVersion.V0_5, + true, + false); + this.evpProxyEndpoint = evpProxyEndpoint; + } + + @Override + public void discoverIfOutdated() {} + + @Override + public String getEvpProxyEndpoint() { + return evpProxyEndpoint; + } + + @Override + public boolean supportsEvpProxy() { + return evpProxyEndpoint != null; + } } - } } diff --git a/communication/src/test/java/datadog/communication/EvpProxyApiTest.java b/communication/src/test/java/datadog/communication/EvpProxyApiTest.java index 14c6962bf8e..126661fff20 100644 --- a/communication/src/test/java/datadog/communication/EvpProxyApiTest.java +++ b/communication/src/test/java/datadog/communication/EvpProxyApiTest.java @@ -17,49 +17,46 @@ class EvpProxyApiTest { - private MockWebServer server; - private OkHttpClient client; - - @BeforeEach - void setUp() throws IOException { - server = new MockWebServer(); - server.start(); - client = new OkHttpClient.Builder().build(); - } - - @AfterEach - void tearDown() throws IOException { - client.dispatcher().executorService().shutdownNow(); - client.connectionPool().evictAll(); - server.shutdown(); - } - - @Test - void reportsHttpStatusForRejectedRequest() throws Exception { - server.enqueue(new MockResponse().setResponseCode(404).setBody("not found")); - final EvpProxyApi api = - new EvpProxyApi( - "123", - server.url("/evp_proxy/v4/"), - "event-platform-intake", - HttpRetryPolicy.Factory.NEVER_RETRY, - client, - false); - - final HttpResponseException exception = - assertThrows( - HttpResponseException.class, - () -> - api.post( - "exposures", - RequestBody.create(MediaType.parse("application/json"), "{}"), - stream -> null, - null, - false)); - - assertEquals(404, exception.getStatusCode()); - final RecordedRequest request = server.takeRequest(); - assertEquals("/evp_proxy/v4/api/v2/exposures", request.getPath()); - assertEquals("event-platform-intake", request.getHeader("X-Datadog-EVP-Subdomain")); - } + private MockWebServer server; + private OkHttpClient client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = new OkHttpClient.Builder().build(); + } + + @AfterEach + void tearDown() throws IOException { + client.dispatcher().executorService().shutdownNow(); + client.connectionPool().evictAll(); + server.shutdown(); + } + + @Test + void reportsHttpStatusForRejectedRequest() throws Exception { + server.enqueue(new MockResponse().setResponseCode(404).setBody("not found")); + final EvpProxyApi api = new EvpProxyApi( + "123", + server.url("/evp_proxy/v4/"), + "event-platform-intake", + HttpRetryPolicy.Factory.NEVER_RETRY, + client, + false); + + final HttpResponseException exception = assertThrows( + HttpResponseException.class, + () -> api.post( + "exposures", + RequestBody.create(MediaType.parse("application/json"), "{}"), + stream -> null, + null, + false)); + + assertEquals(404, exception.getStatusCode()); + final RecordedRequest request = server.takeRequest(); + assertEquals("/evp_proxy/v4/api/v2/exposures", request.getPath()); + assertEquals("event-platform-intake", request.getHeader("X-Datadog-EVP-Subdomain")); + } } diff --git a/communication/src/test/java/datadog/communication/IntakeApiTest.java b/communication/src/test/java/datadog/communication/IntakeApiTest.java index 326cf21ca84..b27a23cd3cd 100644 --- a/communication/src/test/java/datadog/communication/IntakeApiTest.java +++ b/communication/src/test/java/datadog/communication/IntakeApiTest.java @@ -16,50 +16,49 @@ class IntakeApiTest { - private static final MediaType JSON = MediaType.parse("application/json"); + private static final MediaType JSON = MediaType.parse("application/json"); - private MockWebServer server; - private OkHttpClient client; + private MockWebServer server; + private OkHttpClient client; - @BeforeEach - void setUp() throws IOException { - server = new MockWebServer(); - server.start(); - client = new OkHttpClient.Builder().build(); - } + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = new OkHttpClient.Builder().build(); + } - @AfterEach - void tearDown() throws IOException { - client.dispatcher().executorService().shutdownNow(); - client.connectionPool().evictAll(); - server.shutdown(); - } + @AfterEach + void tearDown() throws IOException { + client.dispatcher().executorService().shutdownNow(); + client.connectionPool().evictAll(); + server.shutdown(); + } - @Test - void requestsGzipResponseCompressionWhenEnabled() throws Exception { - assertEquals("gzip", postAndReadAcceptEncoding(true)); - } + @Test + void requestsGzipResponseCompressionWhenEnabled() throws Exception { + assertEquals("gzip", postAndReadAcceptEncoding(true)); + } - @Test - void requestsIdentityResponseEncodingWhenCompressionIsDisabled() throws Exception { - assertEquals("identity", postAndReadAcceptEncoding(false)); - } + @Test + void requestsIdentityResponseEncodingWhenCompressionIsDisabled() throws Exception { + assertEquals("identity", postAndReadAcceptEncoding(false)); + } - private String postAndReadAcceptEncoding(final boolean responseCompression) throws Exception { - server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - final IntakeApi api = - new IntakeApi( - server.url("/api/v2/"), - "api-key", - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - client, - responseCompression); + private String postAndReadAcceptEncoding(final boolean responseCompression) throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + final IntakeApi api = new IntakeApi( + server.url("/api/v2/"), + "api-key", + "123", + HttpRetryPolicy.Factory.NEVER_RETRY, + client, + responseCompression); - api.post("flagevaluation", RequestBody.create(JSON, "{}"), responseBody -> null, null, false); + api.post("flagevaluation", RequestBody.create(JSON, "{}"), responseBody -> null, null, false); - final RecordedRequest request = server.takeRequest(); - assertEquals("/api/v2/flagevaluation", request.getPath()); - return request.getHeader("Accept-Encoding"); - } + final RecordedRequest request = server.takeRequest(); + assertEquals("/api/v2/flagevaluation", request.getPath()); + return request.getHeader("Accept-Encoding"); + } } diff --git a/communication/src/test/java/datadog/communication/ddagent/AgentVersionTest.java b/communication/src/test/java/datadog/communication/ddagent/AgentVersionTest.java index 6ca2ee1da75..d4284809b06 100644 --- a/communication/src/test/java/datadog/communication/ddagent/AgentVersionTest.java +++ b/communication/src/test/java/datadog/communication/ddagent/AgentVersionTest.java @@ -7,59 +7,59 @@ class AgentVersionTest { - @Test - void testIsVersionBelow_VersionBelowThreshold() { - assertTrue(AgentVersion.isVersionBelow("7.64.0", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("7.64.9", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("6.99.99", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("7.0.0", 7, 65, 0)); - } + @Test + void testIsVersionBelow_VersionBelowThreshold() { + assertTrue(AgentVersion.isVersionBelow("7.64.0", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("7.64.9", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("6.99.99", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("7.0.0", 7, 65, 0)); + } - @Test - void testIsVersionBelow_VersionEqualToThreshold() { - assertFalse(AgentVersion.isVersionBelow("7.65.0", 7, 65, 0)); - } + @Test + void testIsVersionBelow_VersionEqualToThreshold() { + assertFalse(AgentVersion.isVersionBelow("7.65.0", 7, 65, 0)); + } - @Test - void testIsVersionBelow_VersionAboveThreshold() { - assertFalse(AgentVersion.isVersionBelow("7.65.1", 7, 65, 0)); - assertFalse(AgentVersion.isVersionBelow("7.66.0", 7, 65, 0)); - assertFalse(AgentVersion.isVersionBelow("8.0.0", 7, 65, 0)); - assertFalse(AgentVersion.isVersionBelow("7.65.10", 7, 65, 0)); - } + @Test + void testIsVersionBelow_VersionAboveThreshold() { + assertFalse(AgentVersion.isVersionBelow("7.65.1", 7, 65, 0)); + assertFalse(AgentVersion.isVersionBelow("7.66.0", 7, 65, 0)); + assertFalse(AgentVersion.isVersionBelow("8.0.0", 7, 65, 0)); + assertFalse(AgentVersion.isVersionBelow("7.65.10", 7, 65, 0)); + } - @Test - void testIsVersionBelow_MajorVersionComparison() { - assertTrue(AgentVersion.isVersionBelow("6.100.100", 7, 0, 0)); - assertFalse(AgentVersion.isVersionBelow("8.0.0", 7, 100, 100)); - } + @Test + void testIsVersionBelow_MajorVersionComparison() { + assertTrue(AgentVersion.isVersionBelow("6.100.100", 7, 0, 0)); + assertFalse(AgentVersion.isVersionBelow("8.0.0", 7, 100, 100)); + } - @Test - void testIsVersionBelow_MinorVersionComparison() { - assertTrue(AgentVersion.isVersionBelow("7.64.100", 7, 65, 0)); - assertFalse(AgentVersion.isVersionBelow("7.66.0", 7, 65, 100)); - } + @Test + void testIsVersionBelow_MinorVersionComparison() { + assertTrue(AgentVersion.isVersionBelow("7.64.100", 7, 65, 0)); + assertFalse(AgentVersion.isVersionBelow("7.66.0", 7, 65, 100)); + } - @Test - void testIsVersionBelow_WithSuffix() { - assertTrue(AgentVersion.isVersionBelow("7.64.0-rc.1", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("7.65.0-rc.1", 7, 65, 0)); - assertFalse(AgentVersion.isVersionBelow("7.65.1-snapshot", 7, 65, 0)); - } + @Test + void testIsVersionBelow_WithSuffix() { + assertTrue(AgentVersion.isVersionBelow("7.64.0-rc.1", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("7.65.0-rc.1", 7, 65, 0)); + assertFalse(AgentVersion.isVersionBelow("7.65.1-snapshot", 7, 65, 0)); + } - @Test - void testIsVersionBelow_NullOrEmpty() { - assertTrue(AgentVersion.isVersionBelow(null, 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("", 7, 65, 0)); - } + @Test + void testIsVersionBelow_NullOrEmpty() { + assertTrue(AgentVersion.isVersionBelow(null, 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("", 7, 65, 0)); + } - @Test - void testIsVersionBelow_InvalidFormat() { - assertTrue(AgentVersion.isVersionBelow("invalid", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("7", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("7.", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("7.65", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("7.65.", 7, 65, 0)); - assertTrue(AgentVersion.isVersionBelow("a.b.c", 7, 65, 0)); - } + @Test + void testIsVersionBelow_InvalidFormat() { + assertTrue(AgentVersion.isVersionBelow("invalid", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("7", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("7.", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("7.65", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("7.65.", 7, 65, 0)); + assertTrue(AgentVersion.isVersionBelow("a.b.c", 7, 65, 0)); + } } diff --git a/communication/src/test/java/datadog/communication/http/OkHttpUtilsRetryTest.java b/communication/src/test/java/datadog/communication/http/OkHttpUtilsRetryTest.java index 45f84a4810a..00ec6d0efed 100644 --- a/communication/src/test/java/datadog/communication/http/OkHttpUtilsRetryTest.java +++ b/communication/src/test/java/datadog/communication/http/OkHttpUtilsRetryTest.java @@ -15,39 +15,36 @@ class OkHttpUtilsRetryTest { - @Test - void retriesResponseMappingFailureThroughCallFactory() throws Exception { - final MockWebServer server = new MockWebServer(); - final OkHttpClient client = new OkHttpClient(); - final AtomicInteger mappingAttempts = new AtomicInteger(); - server.enqueue(new MockResponse().setBody("first")); - server.enqueue(new MockResponse().setBody("second")); - server.start(); + @Test + void retriesResponseMappingFailureThroughCallFactory() throws Exception { + final MockWebServer server = new MockWebServer(); + final OkHttpClient client = new OkHttpClient(); + final AtomicInteger mappingAttempts = new AtomicInteger(); + server.enqueue(new MockResponse().setBody("first")); + server.enqueue(new MockResponse().setBody("second")); + server.start(); - try { - final Request request = new Request.Builder().url(server.url("/configuration")).build(); + try { + final Request request = + new Request.Builder().url(server.url("/configuration")).build(); - final String body = - sendWithRetries( - (Call.Factory) client, - new HttpRetryPolicy.Factory(1, 0, 1), - request, - response -> { - try (ResponseBody responseBody = response.body()) { - if (mappingAttempts.getAndIncrement() == 0) { - throw new ConnectException("response body could not be mapped"); - } - return responseBody.string(); - } - }); + final String body = + sendWithRetries((Call.Factory) client, new HttpRetryPolicy.Factory(1, 0, 1), request, response -> { + try (ResponseBody responseBody = response.body()) { + if (mappingAttempts.getAndIncrement() == 0) { + throw new ConnectException("response body could not be mapped"); + } + return responseBody.string(); + } + }); - assertEquals("second", body); - assertEquals(2, mappingAttempts.get()); - assertEquals(2, server.getRequestCount()); - } finally { - client.dispatcher().executorService().shutdownNow(); - client.connectionPool().evictAll(); - server.shutdown(); + assertEquals("second", body); + assertEquals(2, mappingAttempts.get()); + assertEquals(2, server.getRequestCount()); + } finally { + client.dispatcher().executorService().shutdownNow(); + client.connectionPool().evictAll(); + server.shutdown(); + } } - } } diff --git a/communication/src/test/java/datadog/communication/serialization/CachingTest.java b/communication/src/test/java/datadog/communication/serialization/CachingTest.java index 1ca9cc9b12c..fca2c16bd4d 100644 --- a/communication/src/test/java/datadog/communication/serialization/CachingTest.java +++ b/communication/src/test/java/datadog/communication/serialization/CachingTest.java @@ -7,39 +7,39 @@ import org.junit.jupiter.api.Test; public class CachingTest { - @Test - public void capacity() { - // exact - assertEquals(64, Caching.cacheSizeFor(64)); - assertEquals(128, Caching.cacheSizeFor(128)); - - // next power of 2 - assertEquals(64, Caching.cacheSizeFor(63)); - assertEquals(64, Caching.cacheSizeFor(33)); - } - - @Test - public void marking_exact() { - int[] marks = new int[Caching.cacheSizeFor(32)]; - - assertFalse(Caching.mark(marks, 31)); - assertTrue(Caching.mark(marks, 31)); - - // should have been reset - assertFalse(Caching.mark(marks, 31)); - } - - @Test - public void marking_collision() { - // deliberately using tiny array to force collision - int[] marks = new int[1]; - - // powers of 2 to reduce false positives in test - assertFalse(Caching.mark(marks, 128)); - assertFalse(Caching.mark(marks, 64)); - - assertTrue(Caching.mark(marks, 128)); - // should now be reset - assertFalse(Caching.mark(marks, 64)); - } + @Test + public void capacity() { + // exact + assertEquals(64, Caching.cacheSizeFor(64)); + assertEquals(128, Caching.cacheSizeFor(128)); + + // next power of 2 + assertEquals(64, Caching.cacheSizeFor(63)); + assertEquals(64, Caching.cacheSizeFor(33)); + } + + @Test + public void marking_exact() { + int[] marks = new int[Caching.cacheSizeFor(32)]; + + assertFalse(Caching.mark(marks, 31)); + assertTrue(Caching.mark(marks, 31)); + + // should have been reset + assertFalse(Caching.mark(marks, 31)); + } + + @Test + public void marking_collision() { + // deliberately using tiny array to force collision + int[] marks = new int[1]; + + // powers of 2 to reduce false positives in test + assertFalse(Caching.mark(marks, 128)); + assertFalse(Caching.mark(marks, 64)); + + assertTrue(Caching.mark(marks, 128)); + // should now be reset + assertFalse(Caching.mark(marks, 64)); + } } diff --git a/communication/src/test/java/datadog/communication/serialization/GenerationalUtf8CacheTest.java b/communication/src/test/java/datadog/communication/serialization/GenerationalUtf8CacheTest.java index f7f9ff502dc..9ab453cadb9 100644 --- a/communication/src/test/java/datadog/communication/serialization/GenerationalUtf8CacheTest.java +++ b/communication/src/test/java/datadog/communication/serialization/GenerationalUtf8CacheTest.java @@ -18,335 +18,324 @@ import org.junit.jupiter.params.provider.ValueSource; public class GenerationalUtf8CacheTest { - static final GenerationalUtf8Cache create() { - return new GenerationalUtf8Cache(64, 128); - } - - @ParameterizedTest - @ValueSource(strings = {"foo", "bar", "baz", "quux"}) - public void getUtf8(String value) { - GenerationalUtf8Cache cache = create(); - - for (int i = 0; i < 10; ++i) { - byte[] valueUtf8 = cache.getUtf8(value); - assertArrayEquals(value.getBytes(StandardCharsets.UTF_8), valueUtf8); + static final GenerationalUtf8Cache create() { + return new GenerationalUtf8Cache(64, 128); } - } - - @Test - public void capacity() { - GenerationalUtf8Cache cache = new GenerationalUtf8Cache(192); - assertEquals(64, cache.edenCapacity()); - assertEquals(128, cache.tenuredCapacity()); - } - - @Test - public void capacity_twoArg() { - GenerationalUtf8Cache cache = new GenerationalUtf8Cache(64, 256); - assertEquals(64, cache.edenCapacity()); - assertEquals(256, cache.tenuredCapacity()); - } - - @Test - public void maxCapacity() { - GenerationalUtf8Cache cache = - new GenerationalUtf8Cache( - GenerationalUtf8Cache.MAX_EDEN_CAPACITY + 1, - GenerationalUtf8Cache.MAX_TENURED_CAPACITY + 1); - - assertEquals(GenerationalUtf8Cache.MAX_EDEN_CAPACITY, cache.edenCapacity()); - assertEquals(GenerationalUtf8Cache.MAX_TENURED_CAPACITY, cache.tenuredCapacity()); - } - - @Test - public void maxCapacity_combined() { - GenerationalUtf8Cache cache = - new GenerationalUtf8Cache( - GenerationalUtf8Cache.MAX_EDEN_CAPACITY - + GenerationalUtf8Cache.MAX_TENURED_CAPACITY - + 2); - - assertEquals(GenerationalUtf8Cache.MAX_EDEN_CAPACITY, cache.edenCapacity()); - assertEquals(GenerationalUtf8Cache.MAX_TENURED_CAPACITY, cache.tenuredCapacity()); - } - - @Test - public void caching() { - GenerationalUtf8Cache cache = create(); - - String value = "bar"; - byte[] expected = value.getBytes(StandardCharsets.UTF_8); - - byte[] first = cache.getUtf8(value); - assertArrayEquals(expected, first); - - // first request isn't cached - to avoid burning slots - byte[] second = cache.getUtf8(value); - assertArrayEquals(expected, second); - assertNotSame(first, second); - - // after first request, the entry should be cached - byte[] third = cache.getUtf8(value); - assertArrayEquals(expected, third); - assertSame(second, third); - - assertNotEquals(0, cache.edenHits); - } - - @Test - public void getUtf8_perCallAccessTime_overridesField() { - GenerationalUtf8Cache cache = create(); - // The field value should not leak into the entry when an explicit time is supplied. - cache.updateAccessTime(0L); - - String value = "bar"; - long callTime = 12345L; - - // First call only marks; the second call creates the entry. - cache.getUtf8(value, callTime); - cache.getUtf8(value, callTime); - - assertEquals(callTime, lookupEdenLastUsedMs(cache, value)); - - // Drive enough hits to promote into tenured. - while (cache.promotions == 0) { - cache.getUtf8(value, callTime); + + @ParameterizedTest + @ValueSource(strings = {"foo", "bar", "baz", "quux"}) + public void getUtf8(String value) { + GenerationalUtf8Cache cache = create(); + + for (int i = 0; i < 10; ++i) { + byte[] valueUtf8 = cache.getUtf8(value); + assertArrayEquals(value.getBytes(StandardCharsets.UTF_8), valueUtf8); + } } - assertEquals(callTime, lookupTenuredLastUsedMs(cache, value)); - } + @Test + public void capacity() { + GenerationalUtf8Cache cache = new GenerationalUtf8Cache(192); + assertEquals(64, cache.edenCapacity()); + assertEquals(128, cache.tenuredCapacity()); + } - @Test - public void promotion() { - GenerationalUtf8Cache cache = create(); + @Test + public void capacity_twoArg() { + GenerationalUtf8Cache cache = new GenerationalUtf8Cache(64, 256); + assertEquals(64, cache.edenCapacity()); + assertEquals(256, cache.tenuredCapacity()); + } - String value = "bar"; - byte[] expected = value.getBytes(StandardCharsets.UTF_8); + @Test + public void maxCapacity() { + GenerationalUtf8Cache cache = new GenerationalUtf8Cache( + GenerationalUtf8Cache.MAX_EDEN_CAPACITY + 1, GenerationalUtf8Cache.MAX_TENURED_CAPACITY + 1); - byte[] first = cache.getUtf8(value); - assertArrayEquals(expected, first); + assertEquals(GenerationalUtf8Cache.MAX_EDEN_CAPACITY, cache.edenCapacity()); + assertEquals(GenerationalUtf8Cache.MAX_TENURED_CAPACITY, cache.tenuredCapacity()); + } - byte[] second = cache.getUtf8(value); - assertArrayEquals(expected, second); - assertNotSame(second, first); + @Test + public void maxCapacity_combined() { + GenerationalUtf8Cache cache = new GenerationalUtf8Cache( + GenerationalUtf8Cache.MAX_EDEN_CAPACITY + GenerationalUtf8Cache.MAX_TENURED_CAPACITY + 2); - while (cache.promotions == 0) { - byte[] cached = cache.getUtf8(value); - assertArrayEquals(expected, cached); - assertSame(cached, second); + assertEquals(GenerationalUtf8Cache.MAX_EDEN_CAPACITY, cache.edenCapacity()); + assertEquals(GenerationalUtf8Cache.MAX_TENURED_CAPACITY, cache.tenuredCapacity()); } - assertNotEquals(0, cache.edenHits); + @Test + public void caching() { + GenerationalUtf8Cache cache = create(); - for (int i = 0; i < 10; ++i) { - byte[] cached = cache.getUtf8(value); + String value = "bar"; + byte[] expected = value.getBytes(StandardCharsets.UTF_8); - assertArrayEquals(expected, cached); - assertSame(cached, second); + byte[] first = cache.getUtf8(value); + assertArrayEquals(expected, first); + + // first request isn't cached - to avoid burning slots + byte[] second = cache.getUtf8(value); + assertArrayEquals(expected, second); + assertNotSame(first, second); + + // after first request, the entry should be cached + byte[] third = cache.getUtf8(value); + assertArrayEquals(expected, third); + assertSame(second, third); + + assertNotEquals(0, cache.edenHits); } - assertNotEquals(0, cache.tenuredHits); - } + @Test + public void getUtf8_perCallAccessTime_overridesField() { + GenerationalUtf8Cache cache = create(); + // The field value should not leak into the entry when an explicit time is supplied. + cache.updateAccessTime(0L); - @Test - public void fuzz() { - Random random = ThreadLocalRandom.current(); + String value = "bar"; + long callTime = 12345L; - int edenHits = 0; - int promotedHits = 0; + // First call only marks; the second call creates the entry. + cache.getUtf8(value, callTime); + cache.getUtf8(value, callTime); - GenerationalUtf8Cache cache = create(); - for (int i = 0; i < 1_000; ++i) { - cache.recalibrate(); + assertEquals(callTime, lookupEdenLastUsedMs(cache, value)); - int cycles = 500 + random.nextInt(2_000); - for (int j = 0; j < cycles; ++j) { - String nextTag = nextTag(); - String nextValue = nextValue(); - byte[] nextExpected = nextValue.getBytes(StandardCharsets.UTF_8); + // Drive enough hits to promote into tenured. + while (cache.promotions == 0) { + cache.getUtf8(value, callTime); + } + + assertEquals(callTime, lookupTenuredLastUsedMs(cache, value)); + } - byte[] nextValueUtf8 = cache.getUtf8(nextValue); - assertArrayEquals(nextExpected, nextValueUtf8); - } + @Test + public void promotion() { + GenerationalUtf8Cache cache = create(); - edenHits += cache.edenHits; - promotedHits += cache.tenuredHits; + String value = "bar"; + byte[] expected = value.getBytes(StandardCharsets.UTF_8); - printStats(cache); + byte[] first = cache.getUtf8(value); + assertArrayEquals(expected, first); + + byte[] second = cache.getUtf8(value); + assertArrayEquals(expected, second); + assertNotSame(second, first); + + while (cache.promotions == 0) { + byte[] cached = cache.getUtf8(value); + assertArrayEquals(expected, cached); + assertSame(cached, second); + } + + assertNotEquals(0, cache.edenHits); + + for (int i = 0; i < 10; ++i) { + byte[] cached = cache.getUtf8(value); + + assertArrayEquals(expected, cached); + assertSame(cached, second); + } + + assertNotEquals(0, cache.tenuredHits); } - assertNotEquals(0, edenHits); - assertNotEquals(0, promotedHits); - } - - @Test - public void concurrentAccess_neverThrowsOrReturnsWrongBytes() throws InterruptedException { - // Regression test for a lookup-then-read race in getUtf8(): a slot can be nulled (recalibrate - // purge / eviction) or reassigned to a *different* value between lookupEntryIndex() and the - // array read. Before the fix this either NPE'd (null slot) or, worse, silently returned another - // value's bytes (reassigned slot). Serialization is single-threaded today, but the cache is - // built to allow concurrent use, so this exercises that contract. - final GenerationalUtf8Cache cache = create(); - - // More distinct values than the cache can hold, so promotions/evictions churn slots hard. - final String[] values = new String[256]; - for (int i = 0; i < values.length; ++i) { - values[i] = "value-" + i; + @Test + public void fuzz() { + Random random = ThreadLocalRandom.current(); + + int edenHits = 0; + int promotedHits = 0; + + GenerationalUtf8Cache cache = create(); + for (int i = 0; i < 1_000; ++i) { + cache.recalibrate(); + + int cycles = 500 + random.nextInt(2_000); + for (int j = 0; j < cycles; ++j) { + String nextTag = nextTag(); + String nextValue = nextValue(); + byte[] nextExpected = nextValue.getBytes(StandardCharsets.UTF_8); + + byte[] nextValueUtf8 = cache.getUtf8(nextValue); + assertArrayEquals(nextExpected, nextValueUtf8); + } + + edenHits += cache.edenHits; + promotedHits += cache.tenuredHits; + + printStats(cache); + } + + assertNotEquals(0, edenHits); + assertNotEquals(0, promotedHits); } - final int threadCount = 8; - final int iterationsPerThread = 200_000; - final CountDownLatch start = new CountDownLatch(1); - final AtomicReference failure = new AtomicReference<>(); - final AtomicInteger readersRunning = new AtomicInteger(threadCount); - - Thread[] readers = new Thread[threadCount]; - for (int t = 0; t < threadCount; ++t) { - readers[t] = - new Thread( - () -> { + @Test + public void concurrentAccess_neverThrowsOrReturnsWrongBytes() throws InterruptedException { + // Regression test for a lookup-then-read race in getUtf8(): a slot can be nulled (recalibrate + // purge / eviction) or reassigned to a *different* value between lookupEntryIndex() and the + // array read. Before the fix this either NPE'd (null slot) or, worse, silently returned another + // value's bytes (reassigned slot). Serialization is single-threaded today, but the cache is + // built to allow concurrent use, so this exercises that contract. + final GenerationalUtf8Cache cache = create(); + + // More distinct values than the cache can hold, so promotions/evictions churn slots hard. + final String[] values = new String[256]; + for (int i = 0; i < values.length; ++i) { + values[i] = "value-" + i; + } + + final int threadCount = 8; + final int iterationsPerThread = 200_000; + final CountDownLatch start = new CountDownLatch(1); + final AtomicReference failure = new AtomicReference<>(); + final AtomicInteger readersRunning = new AtomicInteger(threadCount); + + Thread[] readers = new Thread[threadCount]; + for (int t = 0; t < threadCount; ++t) { + readers[t] = new Thread(() -> { try { - start.await(); - ThreadLocalRandom random = ThreadLocalRandom.current(); - for (int i = 0; i < iterationsPerThread && failure.get() == null; ++i) { - String value = values[random.nextInt(values.length)]; - byte[] result = cache.getUtf8(value); - if (!Arrays.equals(value.getBytes(StandardCharsets.UTF_8), result)) { - failure.compareAndSet( - null, - new AssertionError( - "getUtf8(\"" - + value - + "\") returned bytes for \"" - + new String(result, StandardCharsets.UTF_8) - + "\"")); - return; + start.await(); + ThreadLocalRandom random = ThreadLocalRandom.current(); + for (int i = 0; i < iterationsPerThread && failure.get() == null; ++i) { + String value = values[random.nextInt(values.length)]; + byte[] result = cache.getUtf8(value); + if (!Arrays.equals(value.getBytes(StandardCharsets.UTF_8), result)) { + failure.compareAndSet( + null, + new AssertionError("getUtf8(\"" + + value + + "\") returned bytes for \"" + + new String(result, StandardCharsets.UTF_8) + + "\"")); + return; + } } - } } catch (Throwable e) { - failure.compareAndSet(null, e); + failure.compareAndSet(null, e); } finally { - readersRunning.decrementAndGet(); + readersRunning.decrementAndGet(); } - }); - } + }); + } - // Recalibrate in a tight loop for the duration, nulling decayed slots concurrently with reads. - Thread recalibrator = - new Thread( - () -> { - try { + // Recalibrate in a tight loop for the duration, nulling decayed slots concurrently with reads. + Thread recalibrator = new Thread(() -> { + try { start.await(); while (readersRunning.get() > 0 && failure.get() == null) { - cache.recalibrate(); + cache.recalibrate(); } - } catch (Throwable e) { + } catch (Throwable e) { failure.compareAndSet(null, e); - } - }); - - for (Thread reader : readers) { - reader.start(); - } - recalibrator.start(); - start.countDown(); - - for (Thread reader : readers) { - reader.join(); - } - recalibrator.join(); - - if (failure.get() != null) { - throw new AssertionError("concurrent getUtf8() failed", failure.get()); + } + }); + + for (Thread reader : readers) { + reader.start(); + } + recalibrator.start(); + start.countDown(); + + for (Thread reader : readers) { + reader.join(); + } + recalibrator.join(); + + if (failure.get() != null) { + throw new AssertionError("concurrent getUtf8() failed", failure.get()); + } } - } - @Test - public void bigString_dont_cache() { - String lorem = "Lorem ipsum dolor sit amet"; - while (lorem.length() <= GenerationalUtf8Cache.MAX_ENTRY_LEN) { - lorem += lorem; + @Test + public void bigString_dont_cache() { + String lorem = "Lorem ipsum dolor sit amet"; + while (lorem.length() <= GenerationalUtf8Cache.MAX_ENTRY_LEN) { + lorem += lorem; + } + byte[] expected = lorem.getBytes(StandardCharsets.UTF_8); + + GenerationalUtf8Cache cache = create(); + byte[] first = cache.getUtf8(lorem); + assertArrayEquals(expected, first); + + byte[] second = cache.getUtf8(lorem); + assertArrayEquals(expected, second); + assertNotSame(first, second); + + for (int i = 0; i < 10; ++i) { + byte[] result = cache.getUtf8(lorem); + assertArrayEquals(expected, result); + + assertNotSame(first, result); + assertNotSame(second, result); + } + assertEquals(0, cache.edenHits); + assertEquals(0, cache.tenuredHits); } - byte[] expected = lorem.getBytes(StandardCharsets.UTF_8); - GenerationalUtf8Cache cache = create(); - byte[] first = cache.getUtf8(lorem); - assertArrayEquals(expected, first); + static final String[] TAGS = {"foo", "bar", "baz"}; - byte[] second = cache.getUtf8(lorem); - assertArrayEquals(expected, second); - assertNotSame(first, second); + static final String[] BASE_STRINGS = {"Hello", "world", "foo", "bar", "baz", "quux"}; - for (int i = 0; i < 10; ++i) { - byte[] result = cache.getUtf8(lorem); - assertArrayEquals(expected, result); + static final String nextTag() { + ThreadLocalRandom random = ThreadLocalRandom.current(); - assertNotSame(first, result); - assertNotSame(second, result); + int tagIndex = random.nextInt(TAGS.length + 1); + if (tagIndex >= TAGS.length) { + return "tag-" + random.nextInt(); + } else { + return TAGS[tagIndex]; + } } - assertEquals(0, cache.edenHits); - assertEquals(0, cache.tenuredHits); - } - static final String[] TAGS = {"foo", "bar", "baz"}; + static final String nextValue() { + ThreadLocalRandom random = ThreadLocalRandom.current(); - static final String[] BASE_STRINGS = {"Hello", "world", "foo", "bar", "baz", "quux"}; + if (random.nextDouble() < 0.1) { + return Integer.toString(random.nextInt()); + } - static final String nextTag() { - ThreadLocalRandom random = ThreadLocalRandom.current(); - - int tagIndex = random.nextInt(TAGS.length + 1); - if (tagIndex >= TAGS.length) { - return "tag-" + random.nextInt(); - } else { - return TAGS[tagIndex]; - } - } + int baseIndex = random.nextInt(BASE_STRINGS.length); + String baseString = BASE_STRINGS[baseIndex]; - static final String nextValue() { - ThreadLocalRandom random = ThreadLocalRandom.current(); + if (random.nextDouble() < 0.2) { + baseString = baseString.toLowerCase(); + } - if (random.nextDouble() < 0.1) { - return Integer.toString(random.nextInt()); + int valueSuffix = random.nextInt(2 * baseIndex + 1); + return baseString + valueSuffix; } - int baseIndex = random.nextInt(BASE_STRINGS.length); - String baseString = BASE_STRINGS[baseIndex]; - - if (random.nextDouble() < 0.2) { - baseString = baseString.toLowerCase(); + static long lookupEdenLastUsedMs(GenerationalUtf8Cache cache, String value) { + return lookupLastUsedMs(cache.edenEntries, "edenEntries", value); } - int valueSuffix = random.nextInt(2 * baseIndex + 1); - return baseString + valueSuffix; - } - - static long lookupEdenLastUsedMs(GenerationalUtf8Cache cache, String value) { - return lookupLastUsedMs(cache.edenEntries, "edenEntries", value); - } + static long lookupTenuredLastUsedMs(GenerationalUtf8Cache cache, String value) { + return lookupLastUsedMs(cache.tenuredEntries, "tenuredEntries", value); + } - static long lookupTenuredLastUsedMs(GenerationalUtf8Cache cache, String value) { - return lookupLastUsedMs(cache.tenuredEntries, "tenuredEntries", value); - } + private static long lookupLastUsedMs(GenerationalUtf8Cache.CacheEntry[] entries, String arrayName, String value) { + for (GenerationalUtf8Cache.CacheEntry entry : entries) { + if (entry != null && value.equals(entry.value)) { + return entry.lastUsedMs(); + } + } + throw new AssertionError("entry for value '" + value + "' not found in " + arrayName); + } - private static long lookupLastUsedMs( - GenerationalUtf8Cache.CacheEntry[] entries, String arrayName, String value) { - for (GenerationalUtf8Cache.CacheEntry entry : entries) { - if (entry != null && value.equals(entry.value)) { - return entry.lastUsedMs(); - } + static final void printStats(GenerationalUtf8Cache cache) { + System.out.printf( + "eden hits: %5d\tpromotion hits: %5d\tpromotions: %5d\tearly: %5d\tlocal evictions: %5d\tglobal evictions: %5d%n", + cache.edenHits, + cache.tenuredHits, + cache.promotions, + cache.earlyPromotions, + cache.edenEvictions, + cache.tenuredEvictions); } - throw new AssertionError("entry for value '" + value + "' not found in " + arrayName); - } - - static final void printStats(GenerationalUtf8Cache cache) { - System.out.printf( - "eden hits: %5d\tpromotion hits: %5d\tpromotions: %5d\tearly: %5d\tlocal evictions: %5d\tglobal evictions: %5d%n", - cache.edenHits, - cache.tenuredHits, - cache.promotions, - cache.earlyPromotions, - cache.edenEvictions, - cache.tenuredEvictions); - } } diff --git a/communication/src/test/java/datadog/communication/serialization/GrowableBufferTest.java b/communication/src/test/java/datadog/communication/serialization/GrowableBufferTest.java index c018cce7aa3..d86860c5bb8 100644 --- a/communication/src/test/java/datadog/communication/serialization/GrowableBufferTest.java +++ b/communication/src/test/java/datadog/communication/serialization/GrowableBufferTest.java @@ -9,77 +9,77 @@ public class GrowableBufferTest { - @Test - public void byteBufferTriggersResize() { - GrowableBuffer gb = new GrowableBuffer(5); - ByteBuffer buffer = ByteBuffer.allocate(20); - for (int i = 0; i < 5; ++i) { - buffer.putInt(i); + @Test + public void byteBufferTriggersResize() { + GrowableBuffer gb = new GrowableBuffer(5); + ByteBuffer buffer = ByteBuffer.allocate(20); + for (int i = 0; i < 5; ++i) { + buffer.putInt(i); + } + buffer.flip(); + gb.put(buffer); + ByteBuffer contentsAfterResize = gb.slice(); + for (int i = 0; i < 5; ++i) { + assertEquals(i, contentsAfterResize.getInt()); + } } - buffer.flip(); - gb.put(buffer); - ByteBuffer contentsAfterResize = gb.slice(); - for (int i = 0; i < 5; ++i) { - assertEquals(i, contentsAfterResize.getInt()); - } - } - @Test - public void testBufferCapacity() { - GrowableBuffer gb = new GrowableBuffer(5); - assertEquals(8, gb.capacity()); - ByteBuffer buffer = ByteBuffer.allocate(20); - for (int i = 0; i < 5; ++i) { - buffer.putInt(i); + @Test + public void testBufferCapacity() { + GrowableBuffer gb = new GrowableBuffer(5); + assertEquals(8, gb.capacity()); + ByteBuffer buffer = ByteBuffer.allocate(20); + for (int i = 0; i < 5; ++i) { + buffer.putInt(i); + } + buffer.flip(); + gb.put(buffer); + assertEquals(32, gb.capacity()); } - buffer.flip(); - gb.put(buffer); - assertEquals(32, gb.capacity()); - } - @Test - public void testBufferReset() { - GrowableBuffer gb = new GrowableBuffer(5); - gb.put((byte) 1); - gb.mark(); - assertTrue(gb.isDirty()); + @Test + public void testBufferReset() { + GrowableBuffer gb = new GrowableBuffer(5); + gb.put((byte) 1); + gb.mark(); + assertTrue(gb.isDirty()); - gb.reset(); - assertFalse(gb.isDirty()); + gb.reset(); + assertFalse(gb.isDirty()); - gb.put((byte) 42); - ByteBuffer slice = gb.slice(); - assertEquals(0, gb.messageCount()); - assertEquals(42, slice.get()); - } + gb.put((byte) 42); + ByteBuffer slice = gb.slice(); + assertEquals(0, gb.messageCount()); + assertEquals(42, slice.get()); + } - @Test - public void testFlush() { - GrowableBuffer gb = new GrowableBuffer(5); - gb.put((byte) 1); - assertFalse(gb.flush()); - } + @Test + public void testFlush() { + GrowableBuffer gb = new GrowableBuffer(5); + gb.put((byte) 1); + assertFalse(gb.flush()); + } - @Test - public void testPutVariants() { - GrowableBuffer gb = new GrowableBuffer(5); - gb.putShort((short) 1); - gb.putChar((char) 2); - gb.putInt(4); - gb.putLong(8L); - gb.putFloat(16.0f); - gb.putDouble(32.0); - gb.put(new byte[] {64}); - gb.put(new byte[] {-128}, 0, 1); + @Test + public void testPutVariants() { + GrowableBuffer gb = new GrowableBuffer(5); + gb.putShort((short) 1); + gb.putChar((char) 2); + gb.putInt(4); + gb.putLong(8L); + gb.putFloat(16.0f); + gb.putDouble(32.0); + gb.put(new byte[] {64}); + gb.put(new byte[] {-128}, 0, 1); - ByteBuffer slice = gb.slice(); - assertEquals((short) 1, slice.getShort()); - assertEquals((char) 2, slice.getChar()); - assertEquals(4, slice.getInt()); - assertEquals(8L, slice.getLong()); - assertEquals(16.0f, slice.getFloat()); - assertEquals(32.0, slice.getDouble()); - assertEquals((byte) 64, slice.get()); - assertEquals((byte) -128, slice.get()); - } + ByteBuffer slice = gb.slice(); + assertEquals((short) 1, slice.getShort()); + assertEquals((char) 2, slice.getChar()); + assertEquals(4, slice.getInt()); + assertEquals(8L, slice.getLong()); + assertEquals(16.0f, slice.getFloat()); + assertEquals(32.0, slice.getDouble()); + assertEquals((byte) 64, slice.get()); + assertEquals((byte) -128, slice.get()); + } } diff --git a/communication/src/test/java/datadog/communication/serialization/IntPackingTest.java b/communication/src/test/java/datadog/communication/serialization/IntPackingTest.java index ef5f1c8b13e..7c7ea041c54 100644 --- a/communication/src/test/java/datadog/communication/serialization/IntPackingTest.java +++ b/communication/src/test/java/datadog/communication/serialization/IntPackingTest.java @@ -14,137 +14,131 @@ public class IntPackingTest { - public static Object[][] inputs() { - return new Object[][] { - { - new long[] { - -1, - Long.MIN_VALUE, - Long.MAX_VALUE, - 0, - 1, - 0x80, - 0xF, - 0xFF, - 0xFFF, - 0xFFFF, - 0xF000, - 0xFFFFF, - 0xFFFFFF, - 0xFFFFFF, - 0xFFFFFFFF, - 0xFFFFFFFFFL, - 0xFFFFFFFFFFL, - 0xFFFFFFFFFFFFL, - 0xEFEFEFEFEFEFEFEFL, - -0xF, - -0xFF, - -0xFFF, - -0xFFFF, - -0xF000, - -0xFFFFF, - -0xFFFFFF, - -0xFFFFFF, - -0xFFFFFFFF, - -0xFFFFFFFFFL, - -0xFFFFFFFFFFL, - -0xFFFFFFFFFFFFL - } - }, - { - new long[] { - -1, - Integer.MIN_VALUE, - Integer.MAX_VALUE, - 0, - 1, - 0x80, - 0xF, - 0xFF, - 0xFFF, - 0xFFFF, - 0xF000, - 0xFFFFF, - 0xFFFFFF, - 0xFFFFFF, - 0xFFFFFFFF, - 0xEFEFEFEF, - -0xF, - -0xFF, - -0xFFF, - -0xFFFF, - -0xFFFFFF - } - }, - {random(100)}, - {random(10_000)}, - {random(100_000)}, - }; - } + public static Object[][] inputs() { + return new Object[][] { + { + new long[] { + -1, + Long.MIN_VALUE, + Long.MAX_VALUE, + 0, + 1, + 0x80, + 0xF, + 0xFF, + 0xFFF, + 0xFFFF, + 0xF000, + 0xFFFFF, + 0xFFFFFF, + 0xFFFFFF, + 0xFFFFFFFF, + 0xFFFFFFFFFL, + 0xFFFFFFFFFFL, + 0xFFFFFFFFFFFFL, + 0xEFEFEFEFEFEFEFEFL, + -0xF, + -0xFF, + -0xFFF, + -0xFFFF, + -0xF000, + -0xFFFFF, + -0xFFFFFF, + -0xFFFFFF, + -0xFFFFFFFF, + -0xFFFFFFFFFL, + -0xFFFFFFFFFFL, + -0xFFFFFFFFFFFFL + } + }, + { + new long[] { + -1, + Integer.MIN_VALUE, + Integer.MAX_VALUE, + 0, + 1, + 0x80, + 0xF, + 0xFF, + 0xFFF, + 0xFFFF, + 0xF000, + 0xFFFFF, + 0xFFFFFF, + 0xFFFFFF, + 0xFFFFFFFF, + 0xEFEFEFEF, + -0xF, + -0xFF, + -0xFFF, + -0xFFFF, + -0xFFFFFF + } + }, + {random(100)}, + {random(10_000)}, + {random(100_000)}, + }; + } - private static long[] random(int size) { - long[] random = new long[size]; - for (int i = 0; i < random.length; ++i) { - // yeah, JDK7... - int signum = ThreadLocalRandom.current().nextBoolean() ? 1 : -1; - random[i] = signum * ThreadLocalRandom.current().nextLong(0, Long.MAX_VALUE); + private static long[] random(int size) { + long[] random = new long[size]; + for (int i = 0; i < random.length; ++i) { + // yeah, JDK7... + int signum = ThreadLocalRandom.current().nextBoolean() ? 1 : -1; + random[i] = signum * ThreadLocalRandom.current().nextLong(0, Long.MAX_VALUE); + } + return random; } - return random; - } - @ParameterizedTest(name = "Input case {index}") - @MethodSource("inputs") - public void packLongs(long[] input) { - ByteBuffer buffer = ByteBuffer.allocate(input.length * 9 + 10); - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - input.length * 9 + 10, - (messageCount, buffy) -> { - try { - MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy); - assertEquals(1, messageCount); - assertEquals(input.length, unpacker.unpackArrayHeader()); - for (long i : input) { - assertEquals(i, unpacker.unpackLong()); + @ParameterizedTest(name = "Input case {index}") + @MethodSource("inputs") + public void packLongs(long[] input) { + ByteBuffer buffer = ByteBuffer.allocate(input.length * 9 + 10); + MessageFormatter messageFormatter = + new MsgPackWriter(newBuffer(input.length * 9 + 10, (messageCount, buffy) -> { + try { + MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy); + assertEquals(1, messageCount); + assertEquals(input.length, unpacker.unpackArrayHeader()); + for (long i : input) { + assertEquals(i, unpacker.unpackLong()); + } + } catch (IOException e) { + Assertions.fail(e.getMessage()); } - } catch (IOException e) { - Assertions.fail(e.getMessage()); - } })); - messageFormatter.format(input, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @ParameterizedTest(name = "Input case {index}") - @MethodSource("inputs") - public void packInts(long[] input) { - final int[] asInts = new int[input.length]; - for (int i = 0; i < input.length; ++i) { - asInts[i] = (int) input[i]; + messageFormatter.format(input, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); } - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - input.length * 5 + 10, - (messageCount, buffy) -> { - try { - MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy); - assertEquals(1, messageCount); - assertEquals(asInts.length, unpacker.unpackArrayHeader()); - for (int i : asInts) { - assertEquals(i, unpacker.unpackInt()); + + @ParameterizedTest(name = "Input case {index}") + @MethodSource("inputs") + public void packInts(long[] input) { + final int[] asInts = new int[input.length]; + for (int i = 0; i < input.length; ++i) { + asInts[i] = (int) input[i]; + } + MessageFormatter messageFormatter = + new MsgPackWriter(newBuffer(input.length * 5 + 10, (messageCount, buffy) -> { + try { + MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy); + assertEquals(1, messageCount); + assertEquals(asInts.length, unpacker.unpackArrayHeader()); + for (int i : asInts) { + assertEquals(i, unpacker.unpackInt()); + } + } catch (IOException e) { + Assertions.fail(e.getMessage()); } - } catch (IOException e) { - Assertions.fail(e.getMessage()); - } })); - messageFormatter.format(asInts, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } + messageFormatter.format(asInts, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } - private StreamingBuffer newBuffer(int capacity, ByteBufferConsumer consumer) { - return new FlushingBuffer(capacity, consumer); - } + private StreamingBuffer newBuffer(int capacity, ByteBufferConsumer consumer) { + return new FlushingBuffer(capacity, consumer); + } } diff --git a/communication/src/test/java/datadog/communication/serialization/SimpleUtf8CacheTest.java b/communication/src/test/java/datadog/communication/serialization/SimpleUtf8CacheTest.java index d07221bd892..be095eca915 100644 --- a/communication/src/test/java/datadog/communication/serialization/SimpleUtf8CacheTest.java +++ b/communication/src/test/java/datadog/communication/serialization/SimpleUtf8CacheTest.java @@ -14,145 +14,145 @@ import org.junit.jupiter.params.provider.ValueSource; public class SimpleUtf8CacheTest { - static final SimpleUtf8Cache create() { - return new SimpleUtf8Cache(64); - } - - @Test - public void capacity() { - SimpleUtf8Cache cache = new SimpleUtf8Cache(128); - assertEquals(128, cache.capacity()); - } - - @Test - public void maxCapacity() { - SimpleUtf8Cache cache = new SimpleUtf8Cache(SimpleUtf8Cache.MAX_CAPACITY + 1); - - assertEquals(SimpleUtf8Cache.MAX_CAPACITY, cache.capacity()); - } - - @ParameterizedTest - @ValueSource(strings = {"foo", "bar", "baz", "quux"}) - public void getUtf8(String value) { - SimpleUtf8Cache cache = create(); - - for (int i = 0; i < 10; ++i) { - byte[] valueUtf8 = cache.getUtf8(value); - assertArrayEquals(value.getBytes(StandardCharsets.UTF_8), valueUtf8); + static final SimpleUtf8Cache create() { + return new SimpleUtf8Cache(64); } - } - @Test - public void caching() { - SimpleUtf8Cache cache = create(); + @Test + public void capacity() { + SimpleUtf8Cache cache = new SimpleUtf8Cache(128); + assertEquals(128, cache.capacity()); + } - String value = "bar"; - byte[] expected = value.getBytes(StandardCharsets.UTF_8); + @Test + public void maxCapacity() { + SimpleUtf8Cache cache = new SimpleUtf8Cache(SimpleUtf8Cache.MAX_CAPACITY + 1); - byte[] first = cache.getUtf8(value); - assertArrayEquals(expected, first); + assertEquals(SimpleUtf8Cache.MAX_CAPACITY, cache.capacity()); + } - // first request isn't cached - to avoid burning slots - byte[] second = cache.getUtf8(value); - assertArrayEquals(expected, second); - assertNotSame(first, second); + @ParameterizedTest + @ValueSource(strings = {"foo", "bar", "baz", "quux"}) + public void getUtf8(String value) { + SimpleUtf8Cache cache = create(); - // after first request, the entry should be cached - byte[] third = cache.getUtf8(value); - assertArrayEquals(expected, third); - assertSame(second, third); + for (int i = 0; i < 10; ++i) { + byte[] valueUtf8 = cache.getUtf8(value); + assertArrayEquals(value.getBytes(StandardCharsets.UTF_8), valueUtf8); + } + } - assertNotEquals(0, cache.hits); - } + @Test + public void caching() { + SimpleUtf8Cache cache = create(); - @Test - public void fuzz() { - Random random = ThreadLocalRandom.current(); + String value = "bar"; + byte[] expected = value.getBytes(StandardCharsets.UTF_8); - int hits = 0; + byte[] first = cache.getUtf8(value); + assertArrayEquals(expected, first); - SimpleUtf8Cache cache = create(); - for (int i = 0; i < 1_000; ++i) { - cache.recalibrate(); + // first request isn't cached - to avoid burning slots + byte[] second = cache.getUtf8(value); + assertArrayEquals(expected, second); + assertNotSame(first, second); - int cycles = 500 + random.nextInt(2_000); - for (int j = 0; j < cycles; ++j) { - String nextTag = nextTag(); - String nextValue = nextValue(); - byte[] nextExpected = nextValue.getBytes(StandardCharsets.UTF_8); + // after first request, the entry should be cached + byte[] third = cache.getUtf8(value); + assertArrayEquals(expected, third); + assertSame(second, third); - byte[] nextValueUtf8 = cache.getUtf8(nextValue); - assertArrayEquals(nextExpected, nextValueUtf8); - } + assertNotEquals(0, cache.hits); + } - hits += cache.hits; + @Test + public void fuzz() { + Random random = ThreadLocalRandom.current(); - printStats(cache); - } + int hits = 0; - assertNotEquals(0, hits); - } + SimpleUtf8Cache cache = create(); + for (int i = 0; i < 1_000; ++i) { + cache.recalibrate(); - @Test - public void bigString_dont_cache() { - String lorem = "Lorem ipsum dolor sit amet"; - while (lorem.length() <= SimpleUtf8Cache.MAX_ENTRY_LEN) { - lorem += lorem; - } - byte[] expected = lorem.getBytes(StandardCharsets.UTF_8); + int cycles = 500 + random.nextInt(2_000); + for (int j = 0; j < cycles; ++j) { + String nextTag = nextTag(); + String nextValue = nextValue(); + byte[] nextExpected = nextValue.getBytes(StandardCharsets.UTF_8); - SimpleUtf8Cache cache = create(); - byte[] first = cache.getUtf8(lorem); - assertArrayEquals(expected, first); + byte[] nextValueUtf8 = cache.getUtf8(nextValue); + assertArrayEquals(nextExpected, nextValueUtf8); + } - byte[] second = cache.getUtf8(lorem); - assertArrayEquals(expected, second); - assertNotSame(first, second); + hits += cache.hits; - for (int i = 0; i < 10; ++i) { - byte[] result = cache.getUtf8(lorem); - assertArrayEquals(expected, result); + printStats(cache); + } - assertNotSame(first, result); - assertNotSame(second, result); + assertNotEquals(0, hits); } - assertEquals(0, cache.hits); - } - static final String[] TAGS = {"foo", "bar", "baz"}; + @Test + public void bigString_dont_cache() { + String lorem = "Lorem ipsum dolor sit amet"; + while (lorem.length() <= SimpleUtf8Cache.MAX_ENTRY_LEN) { + lorem += lorem; + } + byte[] expected = lorem.getBytes(StandardCharsets.UTF_8); + + SimpleUtf8Cache cache = create(); + byte[] first = cache.getUtf8(lorem); + assertArrayEquals(expected, first); + + byte[] second = cache.getUtf8(lorem); + assertArrayEquals(expected, second); + assertNotSame(first, second); + + for (int i = 0; i < 10; ++i) { + byte[] result = cache.getUtf8(lorem); + assertArrayEquals(expected, result); + + assertNotSame(first, result); + assertNotSame(second, result); + } + assertEquals(0, cache.hits); + } + + static final String[] TAGS = {"foo", "bar", "baz"}; - static final String[] BASE_STRINGS = {"Hello", "world", "foo", "bar", "baz", "quux"}; + static final String[] BASE_STRINGS = {"Hello", "world", "foo", "bar", "baz", "quux"}; - static final String nextTag() { - ThreadLocalRandom random = ThreadLocalRandom.current(); + static final String nextTag() { + ThreadLocalRandom random = ThreadLocalRandom.current(); - int tagIndex = random.nextInt(TAGS.length + 1); - if (tagIndex >= TAGS.length) { - return "tag-" + random.nextInt(); - } else { - return TAGS[tagIndex]; + int tagIndex = random.nextInt(TAGS.length + 1); + if (tagIndex >= TAGS.length) { + return "tag-" + random.nextInt(); + } else { + return TAGS[tagIndex]; + } } - } - static final String nextValue() { - ThreadLocalRandom random = ThreadLocalRandom.current(); + static final String nextValue() { + ThreadLocalRandom random = ThreadLocalRandom.current(); - if (random.nextDouble() < 0.1) { - return Integer.toString(random.nextInt()); - } + if (random.nextDouble() < 0.1) { + return Integer.toString(random.nextInt()); + } - int baseIndex = random.nextInt(BASE_STRINGS.length); - String baseString = BASE_STRINGS[baseIndex]; + int baseIndex = random.nextInt(BASE_STRINGS.length); + String baseString = BASE_STRINGS[baseIndex]; - if (random.nextDouble() < 0.2) { - baseString = baseString.toLowerCase(); - } + if (random.nextDouble() < 0.2) { + baseString = baseString.toLowerCase(); + } - int valueSuffix = random.nextInt(2 * baseIndex + 1); - return baseString + valueSuffix; - } + int valueSuffix = random.nextInt(2 * baseIndex + 1); + return baseString + valueSuffix; + } - static final void printStats(SimpleUtf8Cache cache) { - System.out.printf("eden hits: %5d\tpromotions: %5d%n", cache.hits, cache.evictions); - } + static final void printStats(SimpleUtf8Cache cache) { + System.out.printf("eden hits: %5d\tpromotions: %5d%n", cache.hits, cache.evictions); + } } diff --git a/communication/src/test/java/datadog/communication/serialization/SmokeTest.java b/communication/src/test/java/datadog/communication/serialization/SmokeTest.java index c646d0b835d..56260307c9f 100644 --- a/communication/src/test/java/datadog/communication/serialization/SmokeTest.java +++ b/communication/src/test/java/datadog/communication/serialization/SmokeTest.java @@ -20,102 +20,95 @@ public class SmokeTest { - static class Foo { - long id1; - long id2; - String name; - int[] values; - Map tags; + static class Foo { + long id1; + long id2; + String name; + int[] values; + Map tags; - static Foo create() { - Foo foo = new Foo(); - foo.id1 = ThreadLocalRandom.current().nextLong(); - foo.id2 = ThreadLocalRandom.current().nextLong(); - foo.name = UUID.randomUUID().toString(); - foo.values = new int[10]; - Arrays.fill(foo.values, ThreadLocalRandom.current().nextInt()); - foo.tags = new HashMap<>(); - foo.tags.put("foo", 9f); - foo.tags.put("bar", 9d); - foo.tags.put("qux", "tag"); - foo.tags.put("provoke size recalculation", "道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道"); - foo.tags.put("provoke re-encoding", "Straßenschilder"); - foo.tags.put("list", Collections.singletonList("element")); - return foo; + static Foo create() { + Foo foo = new Foo(); + foo.id1 = ThreadLocalRandom.current().nextLong(); + foo.id2 = ThreadLocalRandom.current().nextLong(); + foo.name = UUID.randomUUID().toString(); + foo.values = new int[10]; + Arrays.fill(foo.values, ThreadLocalRandom.current().nextInt()); + foo.tags = new HashMap<>(); + foo.tags.put("foo", 9f); + foo.tags.put("bar", 9d); + foo.tags.put("qux", "tag"); + foo.tags.put("provoke size recalculation", "道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道道"); + foo.tags.put("provoke re-encoding", "Straßenschilder"); + foo.tags.put("list", Collections.singletonList("element")); + return foo; + } } - } - Map constantPool = - new HashMap() { + Map constantPool = new HashMap() { { - put("foo", "foo".getBytes(StandardCharsets.UTF_8)); - put("id1", "id1".getBytes(StandardCharsets.UTF_8)); + put("foo", "foo".getBytes(StandardCharsets.UTF_8)); + put("id1", "id1".getBytes(StandardCharsets.UTF_8)); } - }; - EncodingCache encodingCache = null; + }; + EncodingCache encodingCache = null; - @Test - public void testWriteMessage() { - final Foo message = Foo.create(); - MsgPackWriter packer = - new MsgPackWriter( - new FlushingBuffer( - 1024, - (messageCount, buffy) -> { - assertEquals(1, messageCount); - try { - MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy); - assertEquals("id1", unpacker.unpackString()); - assertEquals(message.id1, unpacker.unpackLong()); - assertEquals("id2", unpacker.unpackString()); - assertEquals(message.id2, unpacker.unpackLong()); - assertEquals("name", unpacker.unpackString()); - assertEquals(message.name, unpacker.unpackString()); - assertEquals("values", unpacker.unpackString()); - int length = unpacker.unpackArrayHeader(); - assertEquals(message.values.length, length); - for (int i : message.values) { - assertEquals(i, unpacker.unpackInt()); - } - assertEquals("tags", unpacker.unpackString()); - int mapHeader = unpacker.unpackMapHeader(); - assertEquals(message.tags.size(), mapHeader); - for (int i = 0; i < mapHeader; ++i) { - String key = unpacker.unpackString(); - Object expected = message.tags.get(key); - assertNotNull(expected); - if (expected instanceof Float) { + @Test + public void testWriteMessage() { + final Foo message = Foo.create(); + MsgPackWriter packer = new MsgPackWriter(new FlushingBuffer(1024, (messageCount, buffy) -> { + assertEquals(1, messageCount); + try { + MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy); + assertEquals("id1", unpacker.unpackString()); + assertEquals(message.id1, unpacker.unpackLong()); + assertEquals("id2", unpacker.unpackString()); + assertEquals(message.id2, unpacker.unpackLong()); + assertEquals("name", unpacker.unpackString()); + assertEquals(message.name, unpacker.unpackString()); + assertEquals("values", unpacker.unpackString()); + int length = unpacker.unpackArrayHeader(); + assertEquals(message.values.length, length); + for (int i : message.values) { + assertEquals(i, unpacker.unpackInt()); + } + assertEquals("tags", unpacker.unpackString()); + int mapHeader = unpacker.unpackMapHeader(); + assertEquals(message.tags.size(), mapHeader); + for (int i = 0; i < mapHeader; ++i) { + String key = unpacker.unpackString(); + Object expected = message.tags.get(key); + assertNotNull(expected); + if (expected instanceof Float) { assertEquals((Float) expected, unpacker.unpackFloat(), 0.0001); - } else if (expected instanceof Double) { + } else if (expected instanceof Double) { assertEquals((Double) expected, unpacker.unpackDouble(), 0.0001); - } else if (expected instanceof List) { + } else if (expected instanceof List) { List l = (List) expected; assertEquals(l.size(), unpacker.unpackArrayHeader()); for (String element : l) { - assertEquals(element, unpacker.unpackString()); + assertEquals(element, unpacker.unpackString()); } - } else if (expected instanceof String) { + } else if (expected instanceof String) { assertEquals(expected, unpacker.unpackString()); - } } - } catch (IOException e) { - Assertions.fail(e.getMessage()); - } - })); - packer.format( - message, - (data, p) -> { - p.writeString("id1", encodingCache); - p.writeLong(data.id1); - p.writeString("id2", encodingCache); - p.writeLong(data.id2); - p.writeString("name", encodingCache); - p.writeString(data.name, encodingCache); - p.writeString("values", encodingCache); - p.writeObject(data.values, encodingCache); - p.writeString("tags", encodingCache); - p.writeMap(data.tags, encodingCache); + } + } catch (IOException e) { + Assertions.fail(e.getMessage()); + } + })); + packer.format(message, (data, p) -> { + p.writeString("id1", encodingCache); + p.writeLong(data.id1); + p.writeString("id2", encodingCache); + p.writeLong(data.id2); + p.writeString("name", encodingCache); + p.writeString(data.name, encodingCache); + p.writeString("values", encodingCache); + p.writeObject(data.values, encodingCache); + p.writeString("tags", encodingCache); + p.writeMap(data.tags, encodingCache); }); - packer.flush(); - } + packer.flush(); + } } diff --git a/communication/src/test/java/datadog/communication/serialization/StringWritingTest.java b/communication/src/test/java/datadog/communication/serialization/StringWritingTest.java index 17732d2b9a4..3384c96ac53 100644 --- a/communication/src/test/java/datadog/communication/serialization/StringWritingTest.java +++ b/communication/src/test/java/datadog/communication/serialization/StringWritingTest.java @@ -19,146 +19,144 @@ public class StringWritingTest { - private static final EncodingCache NO_CACHE = null; + private static final EncodingCache NO_CACHE = null; - private static final Map MEMOISATION = new HashMap<>(); - private static final EncodingCache CACHE = - s -> MEMOISATION.computeIfAbsent(s, s1 -> ((String) s1).getBytes(StandardCharsets.UTF_8)); + private static final Map MEMOISATION = new HashMap<>(); + private static final EncodingCache CACHE = + s -> MEMOISATION.computeIfAbsent(s, s1 -> ((String) s1).getBytes(StandardCharsets.UTF_8)); - private static final int TEN_KB = 10 << 10; + private static final int TEN_KB = 10 << 10; - public static Object[][] maps() { - return new Object[][] { - { - Arrays.asList( - new HashMap() { - { - put("english", "bye"); - put("foo", "bar"); - } + public static Object[][] maps() { + return new Object[][] { + { + Arrays.asList( + new HashMap() { + { + put("english", "bye"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put("german", "tschüß"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put("hani", "道"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put("hani", "道道道"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put("CJK", "罿潯罿潯罿潯罿潯罿潯"); + } + }), }, - new HashMap() { - { - put("german", "tschüß"); - put("foo", "bar"); - } + { + Arrays.asList( + new HashMap() { + { + put("123456789012390-2394-3", "alshjdhlasjhLKASJKLDAHsdlkAHSDKLJAHsdklHASDKSa"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put("123456789012390-2394-3", "Straßenschilder"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put("hani", "道可道非常道名可名非常名"); + put("foo", "bar"); + } + }) }, - new HashMap() { - { - put("hani", "道"); - put("foo", "bar"); - } + { + Arrays.asList( + new HashMap() { + { + put( + "123456789012390-2394-3", + "ßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßß"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put("hani", "道可道非常道名可名非常名名名名名名名名名名名"); + put("foo", "bar"); + } + }) }, - new HashMap() { - { - put("hani", "道道道"); - put("foo", "bar"); - } - }, - new HashMap() { - { - put("CJK", "罿潯罿潯罿潯罿潯罿潯"); - } - }), - }, - { - Arrays.asList( - new HashMap() { - { - put("123456789012390-2394-3", "alshjdhlasjhLKASJKLDAHsdlkAHSDKLJAHsdklHASDKSa"); - put("foo", "bar"); - } - }, - new HashMap() { - { - put("123456789012390-2394-3", "Straßenschilder"); - put("foo", "bar"); - } - }, - new HashMap() { - { - put("hani", "道可道非常道名可名非常名"); - put("foo", "bar"); - } - }) - }, - { - Arrays.asList( - new HashMap() { - { - put( - "123456789012390-2394-3", - "ßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßßß"); - put("foo", "bar"); - } - }, - new HashMap() { - { - put("hani", "道可道非常道名可名非常名名名名名名名名名名名"); - put("foo", "bar"); - } - }) - }, - { - Arrays.asList( - new HashMap() { - { - put("emoji", "\uD83D\uDC4D"); - put("foo", "bar"); - } - }, - new HashMap() { - { - put( - "emoji", - "\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D"); - put("foo", "bar"); - } - }) - } - }; - } + { + Arrays.asList( + new HashMap() { + { + put("emoji", "\uD83D\uDC4D"); + put("foo", "bar"); + } + }, + new HashMap() { + { + put( + "emoji", + "\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D\uD83D\uDC4D"); + put("foo", "bar"); + } + }) + } + }; + } - @ParameterizedTest - @MethodSource("maps") - public void testSerialiseTextMapWithCache(List> maps) { - MsgPackWriter packer = - new MsgPackWriter( - new FlushingBuffer(TEN_KB, (messageCount, buffer) -> testBufferContents(buffer, maps))); - for (Map map : maps) { - packer.format(map, (m, p) -> p.writeMap(m, CACHE)); + @ParameterizedTest + @MethodSource("maps") + public void testSerialiseTextMapWithCache(List> maps) { + MsgPackWriter packer = new MsgPackWriter( + new FlushingBuffer(TEN_KB, (messageCount, buffer) -> testBufferContents(buffer, maps))); + for (Map map : maps) { + packer.format(map, (m, p) -> p.writeMap(m, CACHE)); + } + packer.flush(); } - packer.flush(); - } - @ParameterizedTest - @MethodSource("maps") - public void testSerialiseTextMapWithoutCache(List> maps) { - MsgPackWriter packer = - new MsgPackWriter( - new FlushingBuffer(TEN_KB, (messageCount, buffer) -> testBufferContents(buffer, maps))); - for (Map map : maps) { - packer.format(map, (m, p) -> p.writeMap(m, NO_CACHE)); + @ParameterizedTest + @MethodSource("maps") + public void testSerialiseTextMapWithoutCache(List> maps) { + MsgPackWriter packer = new MsgPackWriter( + new FlushingBuffer(TEN_KB, (messageCount, buffer) -> testBufferContents(buffer, maps))); + for (Map map : maps) { + packer.format(map, (m, p) -> p.writeMap(m, NO_CACHE)); + } + packer.flush(); } - packer.flush(); - } - private void testBufferContents(ByteBuffer buffer, List> maps) { - try { - MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer); - for (Map map : maps) { - int mapSize = unpacker.unpackMapHeader(); - assertEquals(map.size(), mapSize); - int pos = 0; - while (pos++ < mapSize) { - String key = unpacker.unpackString(); - String expectedValue = map.get(key); - assertNotNull(expectedValue); - assertEquals(expectedValue, unpacker.unpackString()); + private void testBufferContents(ByteBuffer buffer, List> maps) { + try { + MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer); + for (Map map : maps) { + int mapSize = unpacker.unpackMapHeader(); + assertEquals(map.size(), mapSize); + int pos = 0; + while (pos++ < mapSize) { + String key = unpacker.unpackString(); + String expectedValue = map.get(key); + assertNotNull(expectedValue); + assertEquals(expectedValue, unpacker.unpackString()); + } + } + } catch (IOException e) { + fail(e.getMessage()); } - } - } catch (IOException e) { - fail(e.getMessage()); } - } } diff --git a/communication/src/test/java/datadog/communication/serialization/msgpack/MsgPackWriterTest.java b/communication/src/test/java/datadog/communication/serialization/msgpack/MsgPackWriterTest.java index ecd57f010e5..ef04967f306 100644 --- a/communication/src/test/java/datadog/communication/serialization/msgpack/MsgPackWriterTest.java +++ b/communication/src/test/java/datadog/communication/serialization/msgpack/MsgPackWriterTest.java @@ -34,959 +34,791 @@ import org.msgpack.core.MessageUnpacker; public class MsgPackWriterTest { - private static final String NON_ASCII_STRING = "foobár_☺"; - private static final byte[] NON_ASCII_BYTES = NON_ASCII_STRING.getBytes(UTF_8); - private static final int NON_ASCII_BUFFER_CAPACITY = NON_ASCII_BYTES.length + 1; - - @Test - public void testOverflow() { - MessageFormatter packer = new MsgPackWriter(newBuffer(25, (messageCount, buffer) -> {})); - assertFalse( - packer.format( - new HashMap() { - { - put("foo", "abcdefghijklmnopqrstuvwxyz"); - } - }, - (Mapper>) (data, writable) -> writable.writeObject(data, null))); - } - - @Test - public void testInsertAfterOverflow() { - Mapper mapper = (data, writable) -> writable.writeString(data, null); - MessageFormatter packer = - new MsgPackWriter( - newBuffer( + private static final String NON_ASCII_STRING = "foobár_☺"; + private static final byte[] NON_ASCII_BYTES = NON_ASCII_STRING.getBytes(UTF_8); + private static final int NON_ASCII_BUFFER_CAPACITY = NON_ASCII_BYTES.length + 1; + + @Test + public void testOverflow() { + MessageFormatter packer = new MsgPackWriter(newBuffer(25, (messageCount, buffer) -> {})); + assertFalse(packer.format( + new HashMap() { + { + put("foo", "abcdefghijklmnopqrstuvwxyz"); + } + }, + (Mapper>) (data, writable) -> writable.writeObject(data, null))); + } + + @Test + public void testInsertAfterOverflow() { + Mapper mapper = (data, writable) -> writable.writeString(data, null); + MessageFormatter packer = new MsgPackWriter(newBuffer( 2 + 25, // enough space for a 25 element string and its 2 byte header (messageCount, buffer) -> {})); - assertTrue(packer.format("abcdefghijklmnopqrstuvwxy", mapper), "data fits in buffer"); - assertFalse( - packer.format("abcdefghijklmnopqrstuvwxyz", mapper), "data doesn't fit in finite buffer"); - assertTrue( - packer.format("abcdefghijklmnopqrstuvwxy", mapper), "data fits in buffer after overflow"); - } - - @Test - public void testMapperResetWhenOversizedMessageRejectedFromEmptyBuffer() { - CountingResetMapper mapper = new CountingResetMapper(); - MessageFormatter packer = new MsgPackWriter(newBuffer(2 + 25, (messageCount, buffer) -> {})); - assertFalse(packer.format("abcdefghijklmnopqrstuvwxyz", mapper)); - assertEquals(1, mapper.resets); - } - - @Test - public void testMapperResetWhenOversizedMessageRejectedAfterFlush() { - CountingResetMapper mapper = new CountingResetMapper(); - MessageFormatter packer = new MsgPackWriter(newBuffer(2 + 25, (messageCount, buffer) -> {})); - assertTrue(packer.format("abc", mapper)); - assertFalse(packer.format("abcdefghijklmnopqrstuvwxyz", mapper)); - // once before the retry, once after the retry is rejected - assertEquals(2, mapper.resets); - } - - private static final class CountingResetMapper implements Mapper { - int resets; - - @Override - public void map(String data, Writable writable) { - writable.writeString(data, null); - } - - @Override - public void reset() { - resets++; - } - } - - @Test - public void testFlushOfOverflow() { - final List flushed = new ArrayList<>(); - Mapper mapper = (data, writable) -> writable.writeString(data, null); - MessageFormatter packer = - new MsgPackWriter( - newBuffer( + assertTrue(packer.format("abcdefghijklmnopqrstuvwxy", mapper), "data fits in buffer"); + assertFalse(packer.format("abcdefghijklmnopqrstuvwxyz", mapper), "data doesn't fit in finite buffer"); + assertTrue(packer.format("abcdefghijklmnopqrstuvwxy", mapper), "data fits in buffer after overflow"); + } + + @Test + public void testMapperResetWhenOversizedMessageRejectedFromEmptyBuffer() { + CountingResetMapper mapper = new CountingResetMapper(); + MessageFormatter packer = new MsgPackWriter(newBuffer(2 + 25, (messageCount, buffer) -> {})); + assertFalse(packer.format("abcdefghijklmnopqrstuvwxyz", mapper)); + assertEquals(1, mapper.resets); + } + + @Test + public void testMapperResetWhenOversizedMessageRejectedAfterFlush() { + CountingResetMapper mapper = new CountingResetMapper(); + MessageFormatter packer = new MsgPackWriter(newBuffer(2 + 25, (messageCount, buffer) -> {})); + assertTrue(packer.format("abc", mapper)); + assertFalse(packer.format("abcdefghijklmnopqrstuvwxyz", mapper)); + // once before the retry, once after the retry is rejected + assertEquals(2, mapper.resets); + } + + private static final class CountingResetMapper implements Mapper { + int resets; + + @Override + public void map(String data, Writable writable) { + writable.writeString(data, null); + } + + @Override + public void reset() { + resets++; + } + } + + @Test + public void testFlushOfOverflow() { + final List flushed = new ArrayList<>(); + Mapper mapper = (data, writable) -> writable.writeString(data, null); + MessageFormatter packer = new MsgPackWriter(newBuffer( 2 + 25, // enough space for a 25 element string and its 2 byte header (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - for (int i = 0; i < messageCount; ++i) { - try { - flushed.add(unpacker.unpackString()); - } catch (Exception error) { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + for (int i = 0; i < messageCount; ++i) { + try { + flushed.add(unpacker.unpackString()); + } catch (Exception error) { + fail(error.getMessage()); + } + } + } catch (IOException error) { fail(error.getMessage()); - } } - } catch (IOException error) { - fail(error.getMessage()); - } })); - assertTrue(packer.format("abcdefghijklm", mapper), "data fits in buffer"); - assertTrue( - packer.format("nopqrstuvwxyz", mapper), "data fits in empty buffer but triggers flush"); - assertTrue( - packer.format("abcdefghijklmnopqrstuvwxy", mapper), "data fits in buffer after overflow"); - assertEquals(2, flushed.size()); - assertEquals("abcdefghijklm", flushed.get(0)); - assertEquals("nopqrstuvwxyz", flushed.get(1)); - } - - @Test - public void testRecycle() { - final AtomicInteger i = new AtomicInteger(); - Map map = - new HashMap() { - { - put("foo", "abcd"); - } + assertTrue(packer.format("abcdefghijklm", mapper), "data fits in buffer"); + assertTrue(packer.format("nopqrstuvwxyz", mapper), "data fits in empty buffer but triggers flush"); + assertTrue(packer.format("abcdefghijklmnopqrstuvwxy", mapper), "data fits in buffer after overflow"); + assertEquals(2, flushed.size()); + assertEquals("abcdefghijklm", flushed.get(0)); + assertEquals("nopqrstuvwxyz", flushed.get(1)); + } + + @Test + public void testRecycle() { + final AtomicInteger i = new AtomicInteger(); + Map map = new HashMap() { + { + put("foo", "abcd"); + } }; - MsgPackWriter packer = - new MsgPackWriter(newBuffer(25, (messageCount, buffer) -> i.getAndIncrement())); - Mapper mapper = (data, writable) -> writable.writeObject(data, null); - packer.format(map, mapper); - packer.format(map, mapper); - packer.format(map, mapper); - packer.format(map, mapper); - assertEquals(1, i.getAndIncrement()); - } - - @Test - public void testWriteBinary() { - final byte[] data = new byte[] {1, 2, 3, 4}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - int length = unpacker.unpackBinaryHeader(); - assertEquals(4, length); - assertArrayEquals(data, unpacker.readPayload(length)); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format( - data, (data1, writable) -> writable.writeBinary(data1, 0, data1.length)); - messageFormatter.flush(); - } - - @Test - public void testWriteBinaryNoArgVariant() { - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - 10, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(6, unpacker.unpackBinaryHeader()); - assertArrayEquals(unpacker.readPayload(6), "foobar".getBytes(UTF_8)); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.writeBinary("foobar".getBytes(UTF_8)); - } - - @Test - public void testWriteBinaryLongPair() { - final long hi = 0x1122334455667788L; - final long lo = 0x99AABBCCDDEEFF00L; - final byte[] data = ByteBuffer.allocate(16).putLong(hi).putLong(lo).array(); - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - int length = unpacker.unpackBinaryHeader(); - assertEquals(16, length); - assertArrayEquals(data, unpacker.readPayload(length)); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (ignored, writable) -> writable.writeBinary(hi, lo)); - messageFormatter.flush(); - } - - @Test - public void testWriteBinaryAsObject() { - final byte[] data = new byte[] {1, 2, 3, 4}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - int length = unpacker.unpackBinaryHeader(); - assertEquals(4, length); - assertArrayEquals(data, unpacker.readPayload(length)); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (ba, writable) -> writable.writeObject(ba, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteByteBuffer() { - final byte[] data = new byte[] {1, 2, 3, 4}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - int length = unpacker.unpackBinaryHeader(); - assertEquals(4, length); - assertArrayEquals(data, unpacker.readPayload(length)); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(ByteBuffer.wrap(data), (bb, writable) -> writable.writeBinary(bb)); - messageFormatter.flush(); - } - - @Test - public void testWriteByteBufferAsObject() { - final byte[] data = new byte[] {1, 2, 3, 4}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - int length = unpacker.unpackBinaryHeader(); - assertEquals(4, length); - assertArrayEquals(data, unpacker.readPayload(length)); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format( - ByteBuffer.wrap(data), (bb, writable) -> writable.writeObject(bb, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteNull() { - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - unpacker.unpackNil(); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(null, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteBooleanAsObject() { - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertTrue(unpacker.unpackBoolean()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(true, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteBoolean() { - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertTrue(unpacker.unpackBoolean()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(true, (x, w) -> w.writeBoolean(x)); - messageFormatter.flush(); - } - - @Test - public void testWriteGenericNumber() { - final BigDecimal data = BigDecimal.valueOf(47.11); - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data.doubleValue(), unpacker.unpackDouble(), 0); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (Mapper) (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteCharArray() { - final String data = "xyz"; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data, unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data.toCharArray(), (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteUTF8ByteString() { - final UTF8BytesString utf8BytesString = UTF8BytesString.create("xyz"); - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals("xyz", unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(utf8BytesString, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteBooleanArray() { - final boolean[] data = new boolean[] {true, false, true, true}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 25, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(4, unpacker.unpackArrayHeader()); - for (boolean datum : data) { - assertEquals(datum, unpacker.unpackBoolean()); - } - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteFloatArray() { - final float[] data = new float[] {0.1f, 0.2f, 0.3f, 0.4f}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(4, unpacker.unpackArrayHeader()); - for (float datum : data) { - assertEquals(datum, unpacker.unpackFloat(), 0.001); - } - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteDoubleArray() { - final double[] data = new double[] {0.1f, 0.2f, 0.3f, 0.4f}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(4, unpacker.unpackArrayHeader()); - for (double datum : data) { - assertEquals(datum, unpacker.unpackDouble(), 0.001); - } - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteLongArray() { - final long[] data = new long[] {1, 2, 3, 4}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(4, unpacker.unpackArrayHeader()); - for (long datum : data) { - assertEquals(datum, unpacker.unpackLong()); - } - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteIntArray() { - final int[] data = new int[] {1, 2, 3, 4}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(4, unpacker.unpackArrayHeader()); - for (int datum : data) { - assertEquals(datum, unpacker.unpackInt()); - } - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteShortArray() { - final short[] data = new short[] {1, 2, 3, 4}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(4, unpacker.unpackArrayHeader()); - for (short datum : data) { - assertEquals(datum, unpacker.unpackInt()); - } - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteLongBoxed() { - final long data = 1234L; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data, unpacker.unpackLong()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteLongPrimitive() { - final long data = 1234L; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data, unpacker.unpackLong()); - assertEquals(data, unpacker.unpackLong()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format( - data, - (x, w) -> { - w.writeLong(x); - w.writeSignedLong(x); + MsgPackWriter packer = new MsgPackWriter(newBuffer(25, (messageCount, buffer) -> i.getAndIncrement())); + Mapper mapper = (data, writable) -> writable.writeObject(data, null); + packer.format(map, mapper); + packer.format(map, mapper); + packer.format(map, mapper); + packer.format(map, mapper); + assertEquals(1, i.getAndIncrement()); + } + + @Test + public void testWriteBinary() { + final byte[] data = new byte[] {1, 2, 3, 4}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + int length = unpacker.unpackBinaryHeader(); + assertEquals(4, length); + assertArrayEquals(data, unpacker.readPayload(length)); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (data1, writable) -> writable.writeBinary(data1, 0, data1.length)); + messageFormatter.flush(); + } + + @Test + public void testWriteBinaryNoArgVariant() { + MsgPackWriter writer = new MsgPackWriter(newBuffer(10, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(6, unpacker.unpackBinaryHeader()); + assertArrayEquals(unpacker.readPayload(6), "foobar".getBytes(UTF_8)); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.writeBinary("foobar".getBytes(UTF_8)); + } + + @Test + public void testWriteBinaryLongPair() { + final long hi = 0x1122334455667788L; + final long lo = 0x99AABBCCDDEEFF00L; + final byte[] data = ByteBuffer.allocate(16).putLong(hi).putLong(lo).array(); + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + int length = unpacker.unpackBinaryHeader(); + assertEquals(16, length); + assertArrayEquals(data, unpacker.readPayload(length)); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (ignored, writable) -> writable.writeBinary(hi, lo)); + messageFormatter.flush(); + } + + @Test + public void testWriteBinaryAsObject() { + final byte[] data = new byte[] {1, 2, 3, 4}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + int length = unpacker.unpackBinaryHeader(); + assertEquals(4, length); + assertArrayEquals(data, unpacker.readPayload(length)); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (ba, writable) -> writable.writeObject(ba, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteByteBuffer() { + final byte[] data = new byte[] {1, 2, 3, 4}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + int length = unpacker.unpackBinaryHeader(); + assertEquals(4, length); + assertArrayEquals(data, unpacker.readPayload(length)); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(ByteBuffer.wrap(data), (bb, writable) -> writable.writeBinary(bb)); + messageFormatter.flush(); + } + + @Test + public void testWriteByteBufferAsObject() { + final byte[] data = new byte[] {1, 2, 3, 4}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + int length = unpacker.unpackBinaryHeader(); + assertEquals(4, length); + assertArrayEquals(data, unpacker.readPayload(length)); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(ByteBuffer.wrap(data), (bb, writable) -> writable.writeObject(bb, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteNull() { + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + unpacker.unpackNil(); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(null, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteBooleanAsObject() { + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertTrue(unpacker.unpackBoolean()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(true, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteBoolean() { + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertTrue(unpacker.unpackBoolean()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(true, (x, w) -> w.writeBoolean(x)); + messageFormatter.flush(); + } + + @Test + public void testWriteGenericNumber() { + final BigDecimal data = BigDecimal.valueOf(47.11); + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data.doubleValue(), unpacker.unpackDouble(), 0); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (Mapper) (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteCharArray() { + final String data = "xyz"; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data, unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data.toCharArray(), (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteUTF8ByteString() { + final UTF8BytesString utf8BytesString = UTF8BytesString.create("xyz"); + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals("xyz", unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(utf8BytesString, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteBooleanArray() { + final boolean[] data = new boolean[] {true, false, true, true}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(25, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(4, unpacker.unpackArrayHeader()); + for (boolean datum : data) { + assertEquals(datum, unpacker.unpackBoolean()); + } + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteFloatArray() { + final float[] data = new float[] {0.1f, 0.2f, 0.3f, 0.4f}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(4, unpacker.unpackArrayHeader()); + for (float datum : data) { + assertEquals(datum, unpacker.unpackFloat(), 0.001); + } + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteDoubleArray() { + final double[] data = new double[] {0.1f, 0.2f, 0.3f, 0.4f}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(4, unpacker.unpackArrayHeader()); + for (double datum : data) { + assertEquals(datum, unpacker.unpackDouble(), 0.001); + } + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteLongArray() { + final long[] data = new long[] {1, 2, 3, 4}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(4, unpacker.unpackArrayHeader()); + for (long datum : data) { + assertEquals(datum, unpacker.unpackLong()); + } + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteIntArray() { + final int[] data = new int[] {1, 2, 3, 4}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(4, unpacker.unpackArrayHeader()); + for (int datum : data) { + assertEquals(datum, unpacker.unpackInt()); + } + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteShortArray() { + final short[] data = new short[] {1, 2, 3, 4}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(4, unpacker.unpackArrayHeader()); + for (short datum : data) { + assertEquals(datum, unpacker.unpackInt()); + } + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteLongBoxed() { + final long data = 1234L; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data, unpacker.unpackLong()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteLongPrimitive() { + final long data = 1234L; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data, unpacker.unpackLong()); + assertEquals(data, unpacker.unpackLong()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> { + w.writeLong(x); + w.writeSignedLong(x); }); - messageFormatter.flush(); - } - - @Test - public void testWriteNegativeLongPrimitive() { - final long data = -1234L; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data, unpacker.unpackLong()); - assertEquals(data, unpacker.unpackLong()); - // Can't unpack unsigned long directly as the unpacker refuses negative values - assertEquals(data, unpacker.unpackValue().asNumberValue().toLong()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format( - data, - (x, w) -> { - w.writeLong(x); - w.writeSignedLong(x); - w.writeUnsignedLong(x); + messageFormatter.flush(); + } + + @Test + public void testWriteNegativeLongPrimitive() { + final long data = -1234L; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data, unpacker.unpackLong()); + assertEquals(data, unpacker.unpackLong()); + // Can't unpack unsigned long directly as the unpacker refuses negative values + assertEquals(data, unpacker.unpackValue().asNumberValue().toLong()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> { + w.writeLong(x); + w.writeSignedLong(x); + w.writeUnsignedLong(x); }); - messageFormatter.flush(); - } - - @Test - public void testWriteIntBoxed() { - final int data = 1234; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data, unpacker.unpackInt()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteIntPrimitive() { - final int data = 1234; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data, unpacker.unpackInt()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeInt(x)); - messageFormatter.flush(); - } - - @Test - public void testWriteShortBoxed() { - final short data = 1234; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data, unpacker.unpackInt()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testUnknownObject() { - final Object data = Codec.INSTANCE; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data.toString(), unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteObjectArray() { - final Object[] data = new Object[] {"foo", "bar"}; - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100, - (messageCount, buffy) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { - assertEquals(data.length, unpacker.unpackArrayHeader()); - assertEquals(data[0].toString(), unpacker.unpackString()); - assertEquals(data[1].toString(), unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testWriteStringUTF8BytesString() { - UTF8BytesString value = UTF8BytesString.create(NON_ASCII_STRING); - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - NON_ASCII_BUFFER_CAPACITY * 2, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(NON_ASCII_STRING, unpacker.unpackString()); - assertEquals(NON_ASCII_STRING, unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.writeObjectString(value, null); - writer.writeString(value, null); - } - - @Test - public void testWriteStringNull() { - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - 20, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - unpacker.unpackNil(); - unpacker.unpackNil(); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.writeObjectString(null, null); - writer.writeString(null, null); - } - - @Test - public void testWriteObjectStringGeneralPath() { - Object value = - new Object() { - @Override - public String toString() { - return NON_ASCII_STRING; - } + messageFormatter.flush(); + } + + @Test + public void testWriteIntBoxed() { + final int data = 1234; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data, unpacker.unpackInt()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteIntPrimitive() { + final int data = 1234; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data, unpacker.unpackInt()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeInt(x)); + messageFormatter.flush(); + } + + @Test + public void testWriteShortBoxed() { + final short data = 1234; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data, unpacker.unpackInt()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testUnknownObject() { + final Object data = Codec.INSTANCE; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data.toString(), unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteObjectArray() { + final Object[] data = new Object[] {"foo", "bar"}; + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100, (messageCount, buffy) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffy)) { + assertEquals(data.length, unpacker.unpackArrayHeader()); + assertEquals(data[0].toString(), unpacker.unpackString()); + assertEquals(data[1].toString(), unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testWriteStringUTF8BytesString() { + UTF8BytesString value = UTF8BytesString.create(NON_ASCII_STRING); + MsgPackWriter writer = new MsgPackWriter(newBuffer(NON_ASCII_BUFFER_CAPACITY * 2, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(NON_ASCII_STRING, unpacker.unpackString()); + assertEquals(NON_ASCII_STRING, unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.writeObjectString(value, null); + writer.writeString(value, null); + } + + @Test + public void testWriteStringNull() { + MsgPackWriter writer = new MsgPackWriter(newBuffer(20, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + unpacker.unpackNil(); + unpacker.unpackNil(); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.writeObjectString(null, null); + writer.writeString(null, null); + } + + @Test + public void testWriteObjectStringGeneralPath() { + Object value = new Object() { + @Override + public String toString() { + return NON_ASCII_STRING; + } }; - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - 40, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(NON_ASCII_STRING, unpacker.unpackString()); - assertEquals(NON_ASCII_STRING, unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.writeObjectString(value, null); - writer.writeObjectString(value, s -> NON_ASCII_BYTES); - } - - @Test - public void testWriteStringGeneralCharSequence() { - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - NON_ASCII_BUFFER_CAPACITY, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(NON_ASCII_STRING, unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - CharBuffer charSeq = CharBuffer.wrap(NON_ASCII_STRING); - writer.writeString(charSeq, null); - } - - @Test - public void testWriteStringEncodingCache() { - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - NON_ASCII_BUFFER_CAPACITY, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(NON_ASCII_STRING, unpacker.unpackString()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.writeString("", s -> NON_ASCII_BYTES); - } - - @Test - public void testStartArray() { - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - 10, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(1, unpacker.unpackArrayHeader()); - assertEquals(0xFFFF, unpacker.unpackArrayHeader()); - assertEquals(0x10000, unpacker.unpackArrayHeader()); - assertEquals(1, unpacker.unpackArrayHeader()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.startArray(1); - writer.startArray(0xFFFF); - writer.startArray(0x10000); - writer.startStruct(1); - } - - @Test - public void testStartMap() { - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - 10, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(1, unpacker.unpackMapHeader()); - assertEquals(0xFFFF, unpacker.unpackMapHeader()); - assertEquals(0x10000, unpacker.unpackMapHeader()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.startMap(1); - writer.startMap(0xFFFF); - writer.startMap(0x10000); - } - - @Test - public void testStartStringHeader() { - MsgPackWriter writer = - new MsgPackWriter( - newBuffer( - 10, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - assertEquals(1, unpacker.unpackRawStringHeader()); - assertEquals(0xFFFF, unpacker.unpackRawStringHeader()); - assertEquals(0x10000, unpacker.unpackRawStringHeader()); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - writer.writeStringHeader(1); - writer.writeStringHeader(0xFFFF); - writer.writeStringHeader(0x10000); - } - - @Test - public void testStackTraceBatch() { - Map> batch = new HashMap<>(); - batch.put("product_key", buildStacktraceEvents()); - testStackTraceBatch(batch); - } - - @Test - public void tesEmptyContentInStackTraceBatch() { - Map> batch = new HashMap<>(); - testStackTraceBatch(batch); - } - - @Test - public void testSimpleStackTraceEvent() { - final StackTraceFrame frame = - new StackTraceFrame(1, new StackTraceElement("class1", "method1", "file1", 1)); - StackTraceEvent data = new StackTraceEvent(Collections.singletonList(frame), null, null, null); - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 1000, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - checkStackTraceEvent(data, unpacker); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - @Test - public void testSimpleStackTraceFrame() { - final StackTraceFrame frame = new StackTraceFrame(1, null, "null", -1, null, null); - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 1000, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - checkStackTraceFrame(frame, unpacker); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(frame, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - private void testStackTraceBatch(final Map> batch) { - MessageFormatter messageFormatter = - new MsgPackWriter( - newBuffer( - 100000, - (messageCount, buffer) -> { - try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { - checkStackTraceBatch(batch, unpacker); - } catch (IOException e) { - fail(e.getMessage()); - } - })); - messageFormatter.format(batch, (x, w) -> w.writeObject(x, null)); - messageFormatter.flush(); - } - - private void checkStackTraceBatch( - Map> batch, MessageUnpacker unpacker) throws IOException { - - assertEquals(batch.size(), unpacker.unpackMapHeader()); - if (batch.isEmpty()) { - return; - } - assertEquals("product_key", unpacker.unpackString()); - List events = batch.get("product_key"); - assertEquals(events.size(), unpacker.unpackArrayHeader()); - for (StackTraceEvent event : events) { - checkStackTraceEvent(event, unpacker); - } - } - - private void checkStackTraceEvent(StackTraceEvent event, MessageUnpacker unpacker) - throws IOException { - boolean hasId = event.getId() != null && !event.getId().isEmpty(); - boolean hasLanguage = event.getLanguage() != null && !event.getLanguage().isEmpty(); - boolean hasMessage = event.getMessage() != null && !event.getMessage().isEmpty(); - int mapSize = 1 + (hasId ? 1 : 0) + (hasLanguage ? 1 : 0) + (hasMessage ? 1 : 0); - assertEquals(mapSize, unpacker.unpackMapHeader()); - if (hasId) { - assertEquals("id", unpacker.unpackString()); - assertEquals(event.getId(), unpacker.unpackString()); - } - if (hasLanguage) { - assertEquals("language", unpacker.unpackString()); - assertEquals(event.getLanguage(), unpacker.unpackString()); - } - if (hasMessage) { - assertEquals("message", unpacker.unpackString()); - assertEquals(event.getMessage(), unpacker.unpackString()); - } - assertEquals("frames", unpacker.unpackString()); - assertEquals(event.getFrames().size(), unpacker.unpackArrayHeader()); - for (StackTraceFrame frame : event.getFrames()) { - checkStackTraceFrame(frame, unpacker); - } - } - - private void checkStackTraceFrame(StackTraceFrame frame, MessageUnpacker unpacker) - throws IOException { - boolean hasText = frame.getText() != null && !frame.getText().isEmpty(); - boolean hasFile = frame.getFile() != null && !frame.getFile().isEmpty(); - boolean hasLine = frame.getLine() != null; - boolean hasClass = frame.getClass_name() != null && !frame.getClass_name().isEmpty(); - boolean hasFunction = frame.getFunction() != null && !frame.getFunction().isEmpty(); - - int mapSize = 1; // id is always present - if (hasText) { - mapSize++; - } - if (hasFile) { - mapSize++; - } - if (hasLine) { - mapSize++; - } - if (hasClass) { - mapSize++; - } - if (hasFunction) { - mapSize++; - } - assertEquals(mapSize, unpacker.unpackMapHeader()); - assertEquals("id", unpacker.unpackString()); - assertEquals(frame.getId(), unpacker.unpackInt()); - if (hasText) { - assertEquals("text", unpacker.unpackString()); - assertEquals(frame.getText(), unpacker.unpackString()); - } - if (hasFile) { - assertEquals("file", unpacker.unpackString()); - assertEquals(frame.getFile(), unpacker.unpackString()); - } - if (hasLine) { - assertEquals("line", unpacker.unpackString()); - assertEquals(frame.getLine(), unpacker.unpackInt()); - } - if (hasClass) { - assertEquals("class_name", unpacker.unpackString()); - assertEquals(frame.getClass_name(), unpacker.unpackString()); - } - if (hasFunction) { - assertEquals("function", unpacker.unpackString()); - assertEquals(frame.getFunction(), unpacker.unpackString()); - } - } - - private List buildStacktraceEvents() { - final StackTraceFrame frame1 = - new StackTraceFrame(1, new StackTraceElement("class1", "method1", "file1", 1)); - final StackTraceFrame frame2 = - new StackTraceFrame(2, new StackTraceElement("class2", "method2", "file2", 2)); - - return Arrays.asList( - new StackTraceEvent(Arrays.asList(frame1, frame2), DEFAULT_LANGUAGE, "id1", "event1"), - new StackTraceEvent(Arrays.asList(frame1, frame2), DEFAULT_LANGUAGE, "id2", "event2")); - } - - private StreamingBuffer newBuffer(int capacity, ByteBufferConsumer consumer) { - return new FlushingBuffer(capacity, consumer); - } + MsgPackWriter writer = new MsgPackWriter(newBuffer(40, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(NON_ASCII_STRING, unpacker.unpackString()); + assertEquals(NON_ASCII_STRING, unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.writeObjectString(value, null); + writer.writeObjectString(value, s -> NON_ASCII_BYTES); + } + + @Test + public void testWriteStringGeneralCharSequence() { + MsgPackWriter writer = new MsgPackWriter(newBuffer(NON_ASCII_BUFFER_CAPACITY, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(NON_ASCII_STRING, unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + CharBuffer charSeq = CharBuffer.wrap(NON_ASCII_STRING); + writer.writeString(charSeq, null); + } + + @Test + public void testWriteStringEncodingCache() { + MsgPackWriter writer = new MsgPackWriter(newBuffer(NON_ASCII_BUFFER_CAPACITY, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(NON_ASCII_STRING, unpacker.unpackString()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.writeString("", s -> NON_ASCII_BYTES); + } + + @Test + public void testStartArray() { + MsgPackWriter writer = new MsgPackWriter(newBuffer(10, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(1, unpacker.unpackArrayHeader()); + assertEquals(0xFFFF, unpacker.unpackArrayHeader()); + assertEquals(0x10000, unpacker.unpackArrayHeader()); + assertEquals(1, unpacker.unpackArrayHeader()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.startArray(1); + writer.startArray(0xFFFF); + writer.startArray(0x10000); + writer.startStruct(1); + } + + @Test + public void testStartMap() { + MsgPackWriter writer = new MsgPackWriter(newBuffer(10, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(1, unpacker.unpackMapHeader()); + assertEquals(0xFFFF, unpacker.unpackMapHeader()); + assertEquals(0x10000, unpacker.unpackMapHeader()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.startMap(1); + writer.startMap(0xFFFF); + writer.startMap(0x10000); + } + + @Test + public void testStartStringHeader() { + MsgPackWriter writer = new MsgPackWriter(newBuffer(10, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + assertEquals(1, unpacker.unpackRawStringHeader()); + assertEquals(0xFFFF, unpacker.unpackRawStringHeader()); + assertEquals(0x10000, unpacker.unpackRawStringHeader()); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + writer.writeStringHeader(1); + writer.writeStringHeader(0xFFFF); + writer.writeStringHeader(0x10000); + } + + @Test + public void testStackTraceBatch() { + Map> batch = new HashMap<>(); + batch.put("product_key", buildStacktraceEvents()); + testStackTraceBatch(batch); + } + + @Test + public void tesEmptyContentInStackTraceBatch() { + Map> batch = new HashMap<>(); + testStackTraceBatch(batch); + } + + @Test + public void testSimpleStackTraceEvent() { + final StackTraceFrame frame = new StackTraceFrame(1, new StackTraceElement("class1", "method1", "file1", 1)); + StackTraceEvent data = new StackTraceEvent(Collections.singletonList(frame), null, null, null); + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(1000, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + checkStackTraceEvent(data, unpacker); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(data, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + @Test + public void testSimpleStackTraceFrame() { + final StackTraceFrame frame = new StackTraceFrame(1, null, "null", -1, null, null); + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(1000, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + checkStackTraceFrame(frame, unpacker); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(frame, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + private void testStackTraceBatch(final Map> batch) { + MessageFormatter messageFormatter = new MsgPackWriter(newBuffer(100000, (messageCount, buffer) -> { + try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(buffer)) { + checkStackTraceBatch(batch, unpacker); + } catch (IOException e) { + fail(e.getMessage()); + } + })); + messageFormatter.format(batch, (x, w) -> w.writeObject(x, null)); + messageFormatter.flush(); + } + + private void checkStackTraceBatch(Map> batch, MessageUnpacker unpacker) + throws IOException { + + assertEquals(batch.size(), unpacker.unpackMapHeader()); + if (batch.isEmpty()) { + return; + } + assertEquals("product_key", unpacker.unpackString()); + List events = batch.get("product_key"); + assertEquals(events.size(), unpacker.unpackArrayHeader()); + for (StackTraceEvent event : events) { + checkStackTraceEvent(event, unpacker); + } + } + + private void checkStackTraceEvent(StackTraceEvent event, MessageUnpacker unpacker) throws IOException { + boolean hasId = event.getId() != null && !event.getId().isEmpty(); + boolean hasLanguage = + event.getLanguage() != null && !event.getLanguage().isEmpty(); + boolean hasMessage = event.getMessage() != null && !event.getMessage().isEmpty(); + int mapSize = 1 + (hasId ? 1 : 0) + (hasLanguage ? 1 : 0) + (hasMessage ? 1 : 0); + assertEquals(mapSize, unpacker.unpackMapHeader()); + if (hasId) { + assertEquals("id", unpacker.unpackString()); + assertEquals(event.getId(), unpacker.unpackString()); + } + if (hasLanguage) { + assertEquals("language", unpacker.unpackString()); + assertEquals(event.getLanguage(), unpacker.unpackString()); + } + if (hasMessage) { + assertEquals("message", unpacker.unpackString()); + assertEquals(event.getMessage(), unpacker.unpackString()); + } + assertEquals("frames", unpacker.unpackString()); + assertEquals(event.getFrames().size(), unpacker.unpackArrayHeader()); + for (StackTraceFrame frame : event.getFrames()) { + checkStackTraceFrame(frame, unpacker); + } + } + + private void checkStackTraceFrame(StackTraceFrame frame, MessageUnpacker unpacker) throws IOException { + boolean hasText = frame.getText() != null && !frame.getText().isEmpty(); + boolean hasFile = frame.getFile() != null && !frame.getFile().isEmpty(); + boolean hasLine = frame.getLine() != null; + boolean hasClass = + frame.getClass_name() != null && !frame.getClass_name().isEmpty(); + boolean hasFunction = + frame.getFunction() != null && !frame.getFunction().isEmpty(); + + int mapSize = 1; // id is always present + if (hasText) { + mapSize++; + } + if (hasFile) { + mapSize++; + } + if (hasLine) { + mapSize++; + } + if (hasClass) { + mapSize++; + } + if (hasFunction) { + mapSize++; + } + assertEquals(mapSize, unpacker.unpackMapHeader()); + assertEquals("id", unpacker.unpackString()); + assertEquals(frame.getId(), unpacker.unpackInt()); + if (hasText) { + assertEquals("text", unpacker.unpackString()); + assertEquals(frame.getText(), unpacker.unpackString()); + } + if (hasFile) { + assertEquals("file", unpacker.unpackString()); + assertEquals(frame.getFile(), unpacker.unpackString()); + } + if (hasLine) { + assertEquals("line", unpacker.unpackString()); + assertEquals(frame.getLine(), unpacker.unpackInt()); + } + if (hasClass) { + assertEquals("class_name", unpacker.unpackString()); + assertEquals(frame.getClass_name(), unpacker.unpackString()); + } + if (hasFunction) { + assertEquals("function", unpacker.unpackString()); + assertEquals(frame.getFunction(), unpacker.unpackString()); + } + } + + private List buildStacktraceEvents() { + final StackTraceFrame frame1 = new StackTraceFrame(1, new StackTraceElement("class1", "method1", "file1", 1)); + final StackTraceFrame frame2 = new StackTraceFrame(2, new StackTraceElement("class2", "method2", "file2", 2)); + + return Arrays.asList( + new StackTraceEvent(Arrays.asList(frame1, frame2), DEFAULT_LANGUAGE, "id1", "event1"), + new StackTraceEvent(Arrays.asList(frame1, frame2), DEFAULT_LANGUAGE, "id2", "event2")); + } + + private StreamingBuffer newBuffer(int capacity, ByteBufferConsumer consumer) { + return new FlushingBuffer(capacity, consumer); + } } diff --git a/components/context/src/main/java/datadog/context/Context.java b/components/context/src/main/java/datadog/context/Context.java index 34a07524de8..cabe1c68ab4 100644 --- a/components/context/src/main/java/datadog/context/Context.java +++ b/components/context/src/main/java/datadog/context/Context.java @@ -36,145 +36,142 @@ * @see ContextKey */ public interface Context { - /** - * Returns the root context. - * - * @return the initial local context that all contexts extend. - */ - static Context root() { - return EmptyContext.INSTANCE; - } + /** + * Returns the root context. + * + * @return the initial local context that all contexts extend. + */ + static Context root() { + return EmptyContext.INSTANCE; + } - /** - * Returns the context attached to the current execution unit. - * - * @return the attached context; {@link #root()} if there is none. - */ - static Context current() { - return manager().current(); - } + /** + * Returns the context attached to the current execution unit. + * + * @return the attached context; {@link #root()} if there is none. + */ + static Context current() { + return manager().current(); + } - /** - * Attaches this context to the current execution unit. - * - * @return a scope to be closed when the context is invalid. - */ - default ContextScope attach() { - return manager().attach(this); - } + /** + * Attaches this context to the current execution unit. + * + * @return a scope to be closed when the context is invalid. + */ + default ContextScope attach() { + return manager().attach(this); + } - /** - * Swaps this context with the one attached to current execution unit. - * - * @return the previously attached context; {@link #root()} if there was none. - */ - default Context swap() { - return manager().swap(this); - } + /** + * Swaps this context with the one attached to current execution unit. + * + * @return the previously attached context; {@link #root()} if there was none. + */ + default Context swap() { + return manager().swap(this); + } - /** - * Returns the context attached to the given carrier object. - * - * @param carrier the carrier object to get the context from. - * @return the attached context; {@link #root()} if there is none. - */ - static Context from(Object carrier) { - return binder().from(carrier); - } + /** + * Returns the context attached to the given carrier object. + * + * @param carrier the carrier object to get the context from. + * @return the attached context; {@link #root()} if there is none. + */ + static Context from(Object carrier) { + return binder().from(carrier); + } - /** - * Attaches this context to the given carrier object. - * - * @param carrier the object to carry the context. - */ - default void attachTo(Object carrier) { - binder().attachTo(carrier, this); - } + /** + * Attaches this context to the given carrier object. + * + * @param carrier the object to carry the context. + */ + default void attachTo(Object carrier) { + binder().attachTo(carrier, this); + } - /** - * Detaches the context attached to the given carrier object, leaving it context-less. - * - * @param carrier the carrier object to detach its context from. - * @return the previously attached context; {@link #root()} if there was none. - */ - static Context detachFrom(Object carrier) { - return binder().detachFrom(carrier); - } + /** + * Detaches the context attached to the given carrier object, leaving it context-less. + * + * @param carrier the carrier object to detach its context from. + * @return the previously attached context; {@link #root()} if there was none. + */ + static Context detachFrom(Object carrier) { + return binder().detachFrom(carrier); + } - /** - * Captures this (attached) context so it can be resumed in another execution unit. - * - * @return continuation capturing this context. - */ - default ContextContinuation capture() { - return manager().capture(this); - } + /** + * Captures this (attached) context so it can be resumed in another execution unit. + * + * @return continuation capturing this context. + */ + default ContextContinuation capture() { + return manager().capture(this); + } - /** - * Gets the value stored in this context under the given key. - * - * @param the type of the value. - * @param key the key used to store the value. - * @return the value stored under the key; {@code null} if there is none. - */ - @Nullable - T get(ContextKey key); + /** + * Gets the value stored in this context under the given key. + * + * @param the type of the value. + * @param key the key used to store the value. + * @return the value stored under the key; {@code null} if there is none. + */ + @Nullable + T get(ContextKey key); - /** - * Creates a copy of this context with the given key-value set. - * - *

Existing value with the given key will be replaced. Mapping to a {@code null} value will - * remove the key-value from the context copy. - * - * @param the type of the value. - * @param key the key to store the value. - * @param value the value to store. - * @return a new context with the key-value set. - */ - Context with(ContextKey key, @Nullable T value); + /** + * Creates a copy of this context with the given key-value set. + * + *

Existing value with the given key will be replaced. Mapping to a {@code null} value will + * remove the key-value from the context copy. + * + * @param the type of the value. + * @param key the key to store the value. + * @param value the value to store. + * @return a new context with the key-value set. + */ + Context with(ContextKey key, @Nullable T value); - /** - * Creates a copy of this context with the given pair of key-values. - * - *

Existing values with the given keys will be replaced. Mapping to a {@code null} value will - * remove the key-value from the context copy. - * - * @param the type of the first value. - * @param the type of the second value. - * @param firstKey the first key to store the first value. - * @param firstValue the first value to store. - * @param secondKey the second key to store the second value. - * @param secondValue the second value to store. - * @return a new context with the pair of key-values set. - */ - default Context with( - ContextKey firstKey, - @Nullable T firstValue, - ContextKey secondKey, - @Nullable U secondValue) { - return with(firstKey, firstValue).with(secondKey, secondValue); - } + /** + * Creates a copy of this context with the given pair of key-values. + * + *

Existing values with the given keys will be replaced. Mapping to a {@code null} value will + * remove the key-value from the context copy. + * + * @param the type of the first value. + * @param the type of the second value. + * @param firstKey the first key to store the first value. + * @param firstValue the first value to store. + * @param secondKey the second key to store the second value. + * @param secondValue the second value to store. + * @return a new context with the pair of key-values set. + */ + default Context with( + ContextKey firstKey, @Nullable T firstValue, ContextKey secondKey, @Nullable U secondValue) { + return with(firstKey, firstValue).with(secondKey, secondValue); + } - /** - * Creates a copy of this context with the implicit key is mapped to the value. - * - * @param value the value to store. - * @return a new context with the implicitly keyed value set. - * @see #with(ContextKey, Object) - */ - default Context with(@Nullable ImplicitContextKeyed value) { - if (value == null) { - return this; + /** + * Creates a copy of this context with the implicit key is mapped to the value. + * + * @param value the value to store. + * @return a new context with the implicitly keyed value set. + * @see #with(ContextKey, Object) + */ + default Context with(@Nullable ImplicitContextKeyed value) { + if (value == null) { + return this; + } + return value.storeInto(this); } - return value.storeInto(this); - } - /** - * Wraps context as a scope without attaching it to the current execution unit. - * - * @return a scope that has no effect on execution units. - */ - default ContextScope asScope() { - return new NoopContextScope(this); - } + /** + * Wraps context as a scope without attaching it to the current execution unit. + * + * @return a scope that has no effect on execution units. + */ + default ContextScope asScope() { + return new NoopContextScope(this); + } } diff --git a/components/context/src/main/java/datadog/context/ContextBinder.java b/components/context/src/main/java/datadog/context/ContextBinder.java index 8a268028c52..0bc71ec2f81 100644 --- a/components/context/src/main/java/datadog/context/ContextBinder.java +++ b/components/context/src/main/java/datadog/context/ContextBinder.java @@ -2,48 +2,48 @@ /** Binds context to carrier objects. */ public interface ContextBinder { - /** - * Returns the context attached to the given carrier object. - * - * @param carrier the carrier object to get the context from. - * @return the attached context; {@link Context#root()} if there is none. - */ - Context from(Object carrier); + /** + * Returns the context attached to the given carrier object. + * + * @param carrier the carrier object to get the context from. + * @return the attached context; {@link Context#root()} if there is none. + */ + Context from(Object carrier); - /** - * Attaches the given context to the given carrier object. - * - * @param carrier the object to carry the context. - * @param context the context to attach. - */ - void attachTo(Object carrier, Context context); + /** + * Attaches the given context to the given carrier object. + * + * @param carrier the object to carry the context. + * @param context the context to attach. + */ + void attachTo(Object carrier, Context context); - /** - * Detaches the context attached to the given carrier object, leaving it context-less. - * - * @param carrier the carrier object to detach its context from. - * @return the previously attached context; {@link Context#root()} if there was none. - */ - Context detachFrom(Object carrier); + /** + * Detaches the context attached to the given carrier object, leaving it context-less. + * + * @param carrier the carrier object to detach its context from. + * @return the previously attached context; {@link Context#root()} if there was none. + */ + Context detachFrom(Object carrier); - /** - * Requests use of a custom {@link ContextBinder}. - * - *

Once the registered binder is used it cannot be replaced and this method will have no - * effect. To test different binders, make sure {@link #allowTesting()} is called early on. - * - * @param binder the binder to use. - */ - static void register(ContextBinder binder) { - ContextProviders.customBinder = binder; - } + /** + * Requests use of a custom {@link ContextBinder}. + * + *

Once the registered binder is used it cannot be replaced and this method will have no + * effect. To test different binders, make sure {@link #allowTesting()} is called early on. + * + * @param binder the binder to use. + */ + static void register(ContextBinder binder) { + ContextProviders.customBinder = binder; + } - /** - * Allow re-registration of custom {@link ContextBinder}s for testing. - * - * @return {@code true} if re-registration is allowed; otherwise {@code false} - */ - static boolean allowTesting() { - return TestContextBinder.register(); - } + /** + * Allow re-registration of custom {@link ContextBinder}s for testing. + * + * @return {@code true} if re-registration is allowed; otherwise {@code false} + */ + static boolean allowTesting() { + return TestContextBinder.register(); + } } diff --git a/components/context/src/main/java/datadog/context/ContextContinuation.java b/components/context/src/main/java/datadog/context/ContextContinuation.java index c516a7b35bf..713ebab1777 100644 --- a/components/context/src/main/java/datadog/context/ContextContinuation.java +++ b/components/context/src/main/java/datadog/context/ContextContinuation.java @@ -49,31 +49,31 @@ */ public interface ContextContinuation { - /** - * Optional builder method to stop {@link #resume()} from implicitly releasing the captured - * context. This is useful when multiple threads may concurrently resume the context. You must - * then explicitly {@link #release() release} the context once all threads are resumed/done. - * - * @return this continuation, but with implicit release-after-resume turned off. - */ - ContextContinuation hold(); + /** + * Optional builder method to stop {@link #resume()} from implicitly releasing the captured + * context. This is useful when multiple threads may concurrently resume the context. You must + * then explicitly {@link #release() release} the context once all threads are resumed/done. + * + * @return this continuation, but with implicit release-after-resume turned off. + */ + ContextContinuation hold(); - /** - * Returns the context captured by this continuation. - * - * @return the captured context. - */ - Context context(); + /** + * Returns the context captured by this continuation. + * + * @return the captured context. + */ + Context context(); - /** - * Resumes the context captured by this continuation by attaching it to the current execution - * unit. Implicitly {@link #release() releases} the captured context at the end of the resumed - * scope, unless {@link #hold()} was called when creating the continuation. - * - * @return a scope to be closed when the resumed context is invalid. - */ - ContextScope resume(); + /** + * Resumes the context captured by this continuation by attaching it to the current execution + * unit. Implicitly {@link #release() releases} the captured context at the end of the resumed + * scope, unless {@link #hold()} was called when creating the continuation. + * + * @return a scope to be closed when the resumed context is invalid. + */ + ContextScope resume(); - /** Explicitly releases the context captured by this continuation. */ - void release(); + /** Explicitly releases the context captured by this continuation. */ + void release(); } diff --git a/components/context/src/main/java/datadog/context/ContextHelpers.java b/components/context/src/main/java/datadog/context/ContextHelpers.java index 12eddf91e99..20cb513f91d 100644 --- a/components/context/src/main/java/datadog/context/ContextHelpers.java +++ b/components/context/src/main/java/datadog/context/ContextHelpers.java @@ -32,134 +32,134 @@ * where {@link #CURRENT} denotes a carrier with the current context. */ public final class ContextHelpers { - /** A helper object carrying the {@link Context#current()} context. */ - public static final Object CURRENT = new Object(); + /** A helper object carrying the {@link Context#current()} context. */ + public static final Object CURRENT = new Object(); - private ContextHelpers() {} + private ContextHelpers() {} - /** - * Find the first context value from given context carriers. - * - * @param key The key used to store the value. - * @param carriers The carrier to get context and value from. - * @param The type of the value to look for. - * @return The first context value found, {@code null} if not found. - */ - public static T findFirst(ContextKey key, Object... carriers) { - requireNonNull(key, "key cannot be null"); - for (Object carrier : carriers) { - requireNonNull(carrier, "carrier cannot be null"); - Context context = carrier == CURRENT ? Context.current() : Context.from(carrier); - T value = context.get(key); - if (value != null) { - return value; - } + /** + * Find the first context value from given context carriers. + * + * @param key The key used to store the value. + * @param carriers The carrier to get context and value from. + * @param The type of the value to look for. + * @return The first context value found, {@code null} if not found. + */ + public static T findFirst(ContextKey key, Object... carriers) { + requireNonNull(key, "key cannot be null"); + for (Object carrier : carriers) { + requireNonNull(carrier, "carrier cannot be null"); + Context context = carrier == CURRENT ? Context.current() : Context.from(carrier); + T value = context.get(key); + if (value != null) { + return value; + } + } + return null; } - return null; - } - /** - * Find all the context values from the given context carriers. - * - * @param key The key used to store the value. - * @param carriers The carriers to get context and value from. - * @param The type of the values to look for. - * @return A list of all values found, in context order. - */ - public static List findAll(ContextKey key, Object... carriers) { - requireNonNull(key, "key cannot be null"); - List values = new ArrayList<>(carriers.length); - for (Object carrier : carriers) { - requireNonNull(carrier, "carrier cannot be null"); - Context context = carrier == CURRENT ? Context.current() : Context.from(carrier); - T value = context.get(key); - if (value != null) { - values.add(value); - } + /** + * Find all the context values from the given context carriers. + * + * @param key The key used to store the value. + * @param carriers The carriers to get context and value from. + * @param The type of the values to look for. + * @return A list of all values found, in context order. + */ + public static List findAll(ContextKey key, Object... carriers) { + requireNonNull(key, "key cannot be null"); + List values = new ArrayList<>(carriers.length); + for (Object carrier : carriers) { + requireNonNull(carrier, "carrier cannot be null"); + Context context = carrier == CURRENT ? Context.current() : Context.from(carrier); + T value = context.get(key); + if (value != null) { + values.add(value); + } + } + return values; } - return values; - } - - /** - * Combine contexts and their values, keeping the first founds. - * - * @param contexts The contexts to combine. - * @return A context containing all the values from all the given context, keeping the first value - * found for a given key. - */ - public static Context combine(Context... contexts) { - return combine(ContextHelpers::combineKeepingFirst, contexts); - } - /** - * Combine multiple contexts into a single one. - * - * @param combiner The context combiner, taking already combined context as first parameter, any - * following one as second parameter, and returning the combined context. - * @param contexts The contexts to combine. - * @return The combined context. - */ - public static Context combine(BinaryOperator combiner, Context... contexts) { - requireNonNull(combiner, "combiner cannot be null"); - Context result = new IndexedContext(new Object[0]); - for (Context context : contexts) { - requireNonNull(context, "context cannot be null"); - result = combiner.apply(result, context); + /** + * Combine contexts and their values, keeping the first founds. + * + * @param contexts The contexts to combine. + * @return A context containing all the values from all the given context, keeping the first value + * found for a given key. + */ + public static Context combine(Context... contexts) { + return combine(ContextHelpers::combineKeepingFirst, contexts); } - return result; - } - private static Context combineKeepingFirst(Context current, Context next) { - if (!(current instanceof IndexedContext)) { - throw new IllegalStateException("Left context is supposed to be an IndexedContext"); + /** + * Combine multiple contexts into a single one. + * + * @param combiner The context combiner, taking already combined context as first parameter, any + * following one as second parameter, and returning the combined context. + * @param contexts The contexts to combine. + * @return The combined context. + */ + public static Context combine(BinaryOperator combiner, Context... contexts) { + requireNonNull(combiner, "combiner cannot be null"); + Context result = new IndexedContext(new Object[0]); + for (Context context : contexts) { + requireNonNull(context, "context cannot be null"); + result = combiner.apply(result, context); + } + return result; } - IndexedContext currentIndexed = (IndexedContext) current; - if (next instanceof EmptyContext) { - return current; - } else if (next instanceof SingletonContext) { - SingletonContext nextSingleton = (SingletonContext) next; - // Check if the single next value is already define in current so next context can be skipped - if (nextSingleton.index < currentIndexed.store.length - && currentIndexed.store[nextSingleton.index] != null) { - return current; - } - // Always store next value otherwise - Object[] store = - copyOfRange( - currentIndexed.store, 0, max(currentIndexed.store.length, nextSingleton.index + 1)); - store[nextSingleton.index] = nextSingleton.value; - return new IndexedContext(store); - } else if (next instanceof IndexedContext) { - IndexedContext nextIndexed = (IndexedContext) next; - // Don't prematurely allocate store. Only allocate if: - // * nextIndexed has more values that currentIndexed, - // so the additional values will always be kept - // * nextIndexed has values that currentIndexed do not have - Object[] store = null; - // Allocate store if nextIndexed has more elements than currentIndexed - if (nextIndexed.store.length > currentIndexed.store.length) { - store = copyOfRange(currentIndexed.store, 0, nextIndexed.store.length); - } - // Apply nextIndexed values if not set in currentIndexed - for (int i = 0; i < currentIndexed.store.length; i++) { - Object nextValue = nextIndexed.store[i]; - if (nextValue != null && currentIndexed.store[i] == null) { - if (store == null) { - store = copyOfRange(currentIndexed.store, 0, currentIndexed.store.length); - } - store[i] = nextValue; + + private static Context combineKeepingFirst(Context current, Context next) { + if (!(current instanceof IndexedContext)) { + throw new IllegalStateException("Left context is supposed to be an IndexedContext"); } - } - // Apply any additional values from nextIndexed if any - for (int i = currentIndexed.store.length; i < nextIndexed.store.length; i++) { - Object nextValue = nextIndexed.store[i]; - if (nextValue != null) { - store[i] = nextValue; + IndexedContext currentIndexed = (IndexedContext) current; + if (next instanceof EmptyContext) { + return current; + } else if (next instanceof SingletonContext) { + SingletonContext nextSingleton = (SingletonContext) next; + // Check if the single next value is already define in current so next context can be skipped + if (nextSingleton.index < currentIndexed.store.length + && currentIndexed.store[nextSingleton.index] != null) { + return current; + } + // Always store next value otherwise + Object[] store = + copyOfRange(currentIndexed.store, 0, max(currentIndexed.store.length, nextSingleton.index + 1)); + store[nextSingleton.index] = nextSingleton.value; + return new IndexedContext(store); + } else if (next instanceof IndexedContext) { + IndexedContext nextIndexed = (IndexedContext) next; + // Don't prematurely allocate store. Only allocate if: + // * nextIndexed has more values that currentIndexed, + // so the additional values will always be kept + // * nextIndexed has values that currentIndexed do not have + Object[] store = null; + // Allocate store if nextIndexed has more elements than currentIndexed + if (nextIndexed.store.length > currentIndexed.store.length) { + store = copyOfRange(currentIndexed.store, 0, nextIndexed.store.length); + } + // Apply nextIndexed values if not set in currentIndexed + for (int i = 0; i < currentIndexed.store.length; i++) { + Object nextValue = nextIndexed.store[i]; + if (nextValue != null && currentIndexed.store[i] == null) { + if (store == null) { + store = copyOfRange(currentIndexed.store, 0, currentIndexed.store.length); + } + store[i] = nextValue; + } + } + // Apply any additional values from nextIndexed if any + for (int i = currentIndexed.store.length; i < nextIndexed.store.length; i++) { + Object nextValue = nextIndexed.store[i]; + if (nextValue != null) { + store[i] = nextValue; + } + } + // If store was not allocated, no value from nextIndexed was taken + return store == null ? current : new IndexedContext(store); } - } - // If store was not allocated, no value from nextIndexed was taken - return store == null ? current : new IndexedContext(store); + throw new IllegalStateException( + "Unsupported context type: " + next.getClass().getName()); } - throw new IllegalStateException("Unsupported context type: " + next.getClass().getName()); - } } diff --git a/components/context/src/main/java/datadog/context/ContextKey.java b/components/context/src/main/java/datadog/context/ContextKey.java index 2576fec4a50..1be87dcf549 100644 --- a/components/context/src/main/java/datadog/context/ContextKey.java +++ b/components/context/src/main/java/datadog/context/ContextKey.java @@ -9,38 +9,38 @@ * share its key for re-use or implement {@link ImplicitContextKeyed} to keep its key private. */ public final class ContextKey { - private static final AtomicInteger NEXT_INDEX = new AtomicInteger(0); - - /** The key name, for debugging purpose only. */ - private final String name; - - /** The key unique context, related to {@link IndexedContext} implementation. */ - final int index; - - private ContextKey(String name) { - this.name = name; - this.index = NEXT_INDEX.getAndIncrement(); - } - - /** - * Creates a new key with the given name. - * - * @param name the key name, for debugging purpose only. - * @return the newly created unique key. - */ - public static ContextKey named(String name) { - return new ContextKey<>(name); - } - - @Override - public int hashCode() { - return this.index; - } - - // we want identity equality, so no need to override equals() - - @Override - public String toString() { - return this.name; - } + private static final AtomicInteger NEXT_INDEX = new AtomicInteger(0); + + /** The key name, for debugging purpose only. */ + private final String name; + + /** The key unique context, related to {@link IndexedContext} implementation. */ + final int index; + + private ContextKey(String name) { + this.name = name; + this.index = NEXT_INDEX.getAndIncrement(); + } + + /** + * Creates a new key with the given name. + * + * @param name the key name, for debugging purpose only. + * @return the newly created unique key. + */ + public static ContextKey named(String name) { + return new ContextKey<>(name); + } + + @Override + public int hashCode() { + return this.index; + } + + // we want identity equality, so no need to override equals() + + @Override + public String toString() { + return this.name; + } } diff --git a/components/context/src/main/java/datadog/context/ContextListener.java b/components/context/src/main/java/datadog/context/ContextListener.java index f657fb769aa..85a832662fc 100644 --- a/components/context/src/main/java/datadog/context/ContextListener.java +++ b/components/context/src/main/java/datadog/context/ContextListener.java @@ -3,25 +3,25 @@ /** Listener of context events. */ public interface ContextListener { - /** - * Notifies that the context has been updated for the current execution unit. - * - * @param before the context before. - * @param after the context after. - */ - default void onUpdate(Context before, Context after) {} + /** + * Notifies that the context has been updated for the current execution unit. + * + * @param before the context before. + * @param after the context after. + */ + default void onUpdate(Context before, Context after) {} - /** - * Notifies that the given context has been captured by a continuation. - * - * @param context the captured context. - */ - default void onCapture(Context context) {} + /** + * Notifies that the given context has been captured by a continuation. + * + * @param context the captured context. + */ + default void onCapture(Context context) {} - /** - * Notifies that the given context has been released from a continuation. - * - * @param context the released context. - */ - default void onRelease(Context context) {} + /** + * Notifies that the given context has been released from a continuation. + * + * @param context the released context. + */ + default void onRelease(Context context) {} } diff --git a/components/context/src/main/java/datadog/context/ContextManager.java b/components/context/src/main/java/datadog/context/ContextManager.java index 6828352d1d4..66ae8151c27 100644 --- a/components/context/src/main/java/datadog/context/ContextManager.java +++ b/components/context/src/main/java/datadog/context/ContextManager.java @@ -4,70 +4,70 @@ /** Manages context across execution units. */ public interface ContextManager { - /** - * Returns the context attached to the current execution unit. - * - * @return the attached context; {@link Context#root()} if there is none. - */ - Context current(); + /** + * Returns the context attached to the current execution unit. + * + * @return the attached context; {@link Context#root()} if there is none. + */ + Context current(); - /** - * Attaches the given context to the current execution unit. - * - * @param context the context to attach. - * @return a scope to be closed when the context is invalid. - */ - ContextScope attach(Context context); + /** + * Attaches the given context to the current execution unit. + * + * @param context the context to attach. + * @return a scope to be closed when the context is invalid. + */ + ContextScope attach(Context context); - /** - * Swaps the given context with the one attached to current execution unit. - * - * @param context the context to swap. - * @return the previously attached context; {@link Context#root()} if there was none. - */ - Context swap(Context context); + /** + * Swaps the given context with the one attached to current execution unit. + * + * @param context the context to swap. + * @return the previously attached context; {@link Context#root()} if there was none. + */ + Context swap(Context context); - /** - * Captures the given (attached) context so it can be resumed in another execution unit. - * - * @return continuation capturing the context. - */ - ContextContinuation capture(Context context); + /** + * Captures the given (attached) context so it can be resumed in another execution unit. + * + * @return continuation capturing the context. + */ + ContextContinuation capture(Context context); - /** - * Registers the given listener to receive context events. - * - * @param listener the listener to register - */ - void addListener(ContextListener listener); + /** + * Registers the given listener to receive context events. + * + * @param listener the listener to register + */ + void addListener(ContextListener listener); - /** - * Registers the given listener to receive context events. - * - * @param listener the listener to register. - */ - static void register(ContextListener listener) { - manager().addListener(listener); - } + /** + * Registers the given listener to receive context events. + * + * @param listener the listener to register. + */ + static void register(ContextListener listener) { + manager().addListener(listener); + } - /** - * Requests use of a custom {@link ContextManager}. - * - *

Once the registered manager is used it cannot be replaced and this method will have no - * effect. To test different managers, make sure {@link #allowTesting()} is called early on. - * - * @param manager the manager to use. - */ - static void register(ContextManager manager) { - ContextProviders.customManager = manager; - } + /** + * Requests use of a custom {@link ContextManager}. + * + *

Once the registered manager is used it cannot be replaced and this method will have no + * effect. To test different managers, make sure {@link #allowTesting()} is called early on. + * + * @param manager the manager to use. + */ + static void register(ContextManager manager) { + ContextProviders.customManager = manager; + } - /** - * Allow re-registration of custom {@link ContextManager}s for testing. - * - * @return {@code true} if re-registration is allowed; otherwise {@code false} - */ - static boolean allowTesting() { - return TestContextManager.register(); - } + /** + * Allow re-registration of custom {@link ContextManager}s for testing. + * + * @return {@code true} if re-registration is allowed; otherwise {@code false} + */ + static boolean allowTesting() { + return TestContextManager.register(); + } } diff --git a/components/context/src/main/java/datadog/context/ContextProviders.java b/components/context/src/main/java/datadog/context/ContextProviders.java index a7431eaa196..69b99df8761 100644 --- a/components/context/src/main/java/datadog/context/ContextProviders.java +++ b/components/context/src/main/java/datadog/context/ContextProviders.java @@ -3,28 +3,26 @@ /** Provides {@link ContextManager} and {@link ContextBinder} implementations. */ final class ContextProviders { - static volatile ContextManager customManager; - static volatile ContextBinder customBinder; + static volatile ContextManager customManager; + static volatile ContextBinder customBinder; - private static final class ProvidedManager { - static final ContextManager INSTANCE = - null != ContextProviders.customManager - ? ContextProviders.customManager - : ThreadLocalContextManager.INSTANCE; - } + private static final class ProvidedManager { + static final ContextManager INSTANCE = null != ContextProviders.customManager + ? ContextProviders.customManager + : ThreadLocalContextManager.INSTANCE; + } - private static final class ProvidedBinder { - static final ContextBinder INSTANCE = - null != ContextProviders.customBinder - ? ContextProviders.customBinder - : ObjectStoreContextBinder.INSTANCE; - } + private static final class ProvidedBinder { + static final ContextBinder INSTANCE = null != ContextProviders.customBinder + ? ContextProviders.customBinder + : ObjectStoreContextBinder.INSTANCE; + } - static ContextManager manager() { - return ProvidedManager.INSTANCE; // may be overridden by instrumentation - } + static ContextManager manager() { + return ProvidedManager.INSTANCE; // may be overridden by instrumentation + } - static ContextBinder binder() { - return ProvidedBinder.INSTANCE; // may be overridden by instrumentation - } + static ContextBinder binder() { + return ProvidedBinder.INSTANCE; // may be overridden by instrumentation + } } diff --git a/components/context/src/main/java/datadog/context/ContextScope.java b/components/context/src/main/java/datadog/context/ContextScope.java index 7788a077615..799a1c27f9e 100644 --- a/components/context/src/main/java/datadog/context/ContextScope.java +++ b/components/context/src/main/java/datadog/context/ContextScope.java @@ -2,10 +2,10 @@ /** Controls the validity of context attached to an execution unit. */ public interface ContextScope extends AutoCloseable { - /** Returns the context controlled by this scope. */ - Context context(); + /** Returns the context controlled by this scope. */ + Context context(); - /** Detaches the context from the execution unit. */ - @Override - void close(); + /** Detaches the context from the execution unit. */ + @Override + void close(); } diff --git a/components/context/src/main/java/datadog/context/EmptyContext.java b/components/context/src/main/java/datadog/context/EmptyContext.java index e9fee391373..459215bc6d4 100644 --- a/components/context/src/main/java/datadog/context/EmptyContext.java +++ b/components/context/src/main/java/datadog/context/EmptyContext.java @@ -6,27 +6,27 @@ /** {@link Context} containing no values. */ final class EmptyContext implements SelfScopedContext { - static final Context INSTANCE = new EmptyContext(); + static final Context INSTANCE = new EmptyContext(); - private EmptyContext() {} + private EmptyContext() {} - @Override - @Nullable - public T get(ContextKey key) { - return null; - } + @Override + @Nullable + public T get(ContextKey key) { + return null; + } - @Override - public Context with(ContextKey key, @Nullable T value) { - requireNonNull(key, "Context key cannot be null"); - if (value == null) { - return this; + @Override + public Context with(ContextKey key, @Nullable T value) { + requireNonNull(key, "Context key cannot be null"); + if (value == null) { + return this; + } + return new SingletonContext(key.index, value); } - return new SingletonContext(key.index, value); - } - @Override - public String toString() { - return "EmptyContext{}"; - } + @Override + public String toString() { + return "EmptyContext{}"; + } } diff --git a/components/context/src/main/java/datadog/context/ImplicitContextKeyed.java b/components/context/src/main/java/datadog/context/ImplicitContextKeyed.java index cdc4107d01a..596967a54ad 100644 --- a/components/context/src/main/java/datadog/context/ImplicitContextKeyed.java +++ b/components/context/src/main/java/datadog/context/ImplicitContextKeyed.java @@ -2,12 +2,12 @@ /** {@link Context} value that has its own implicit {@link ContextKey}. */ public interface ImplicitContextKeyed { - /** - * Creates a new context with this value under its chosen key. - * - * @param context the context to copy the original values from. - * @return the new context with the implicitly keyed value. - * @see Context#with(ImplicitContextKeyed) - */ - Context storeInto(Context context); + /** + * Creates a new context with this value under its chosen key. + * + * @param context the context to copy the original values from. + * @return the new context with the implicitly keyed value. + * @see Context#with(ImplicitContextKeyed) + */ + Context storeInto(Context context); } diff --git a/components/context/src/main/java/datadog/context/IndexedContext.java b/components/context/src/main/java/datadog/context/IndexedContext.java index d4590b6f859..3903f16f1d9 100644 --- a/components/context/src/main/java/datadog/context/IndexedContext.java +++ b/components/context/src/main/java/datadog/context/IndexedContext.java @@ -9,50 +9,50 @@ /** {@link Context} containing many values. */ final class IndexedContext implements SelfScopedContext { - final Object[] store; - - IndexedContext(Object[] store) { - this.store = store; - } - - @Override - @Nullable - @SuppressWarnings("unchecked") - public T get(ContextKey key) { - requireNonNull(key, "Context key cannot be null"); - int index = key.index; - return index < this.store.length ? (T) this.store[index] : null; - } - - @Override - public Context with(ContextKey key, @Nullable T value) { - requireNonNull(key, "Context key cannot be null"); - int index = key.index; - if (index < this.store.length && this.store[index] == value) { - return this; + final Object[] store; + + IndexedContext(Object[] store) { + this.store = store; + } + + @Override + @Nullable + @SuppressWarnings("unchecked") + public T get(ContextKey key) { + requireNonNull(key, "Context key cannot be null"); + int index = key.index; + return index < this.store.length ? (T) this.store[index] : null; + } + + @Override + public Context with(ContextKey key, @Nullable T value) { + requireNonNull(key, "Context key cannot be null"); + int index = key.index; + if (index < this.store.length && this.store[index] == value) { + return this; + } + Object[] newStore = copyOfRange(this.store, 0, max(this.store.length, index + 1)); + newStore[index] = value; + return new IndexedContext(newStore); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IndexedContext that = (IndexedContext) o; + return Arrays.equals(this.store, that.store); + } + + @Override + public int hashCode() { + int result = 31; + result = 31 * result + Arrays.hashCode(this.store); + return result; + } + + @Override + public String toString() { + return "IndexedContext{store=" + Arrays.toString(this.store) + '}'; } - Object[] newStore = copyOfRange(this.store, 0, max(this.store.length, index + 1)); - newStore[index] = value; - return new IndexedContext(newStore); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - IndexedContext that = (IndexedContext) o; - return Arrays.equals(this.store, that.store); - } - - @Override - public int hashCode() { - int result = 31; - result = 31 * result + Arrays.hashCode(this.store); - return result; - } - - @Override - public String toString() { - return "IndexedContext{store=" + Arrays.toString(this.store) + '}'; - } } diff --git a/components/context/src/main/java/datadog/context/NoopContextContinuation.java b/components/context/src/main/java/datadog/context/NoopContextContinuation.java index 02ede9b862d..e7d20f73e65 100644 --- a/components/context/src/main/java/datadog/context/NoopContextContinuation.java +++ b/components/context/src/main/java/datadog/context/NoopContextContinuation.java @@ -2,30 +2,30 @@ /** {@link ContextContinuation} that has no effect on execution units. */ final class NoopContextContinuation implements ContextContinuation, ContextScope { - private final Context context; + private final Context context; - NoopContextContinuation(Context context) { - this.context = context; - } + NoopContextContinuation(Context context) { + this.context = context; + } - @Override - public ContextContinuation hold() { - return this; - } + @Override + public ContextContinuation hold() { + return this; + } - @Override - public Context context() { - return context; - } + @Override + public Context context() { + return context; + } - @Override - public ContextScope resume() { - return this; // acts as no-op scope, avoiding allocation - } + @Override + public ContextScope resume() { + return this; // acts as no-op scope, avoiding allocation + } - @Override - public void release() {} + @Override + public void release() {} - @Override - public void close() {} + @Override + public void close() {} } diff --git a/components/context/src/main/java/datadog/context/NoopContextScope.java b/components/context/src/main/java/datadog/context/NoopContextScope.java index 648221036c5..266d2f490ee 100644 --- a/components/context/src/main/java/datadog/context/NoopContextScope.java +++ b/components/context/src/main/java/datadog/context/NoopContextScope.java @@ -2,17 +2,17 @@ /** {@link ContextScope} that has no effect on execution units. */ final class NoopContextScope implements ContextScope { - private final Context context; + private final Context context; - NoopContextScope(Context context) { - this.context = context; - } + NoopContextScope(Context context) { + this.context = context; + } - @Override - public Context context() { - return context; - } + @Override + public Context context() { + return context; + } - @Override - public void close() {} + @Override + public void close() {} } diff --git a/components/context/src/main/java/datadog/context/ObjectStoreContextBinder.java b/components/context/src/main/java/datadog/context/ObjectStoreContextBinder.java index d9af1947033..847e39a4c9f 100644 --- a/components/context/src/main/java/datadog/context/ObjectStoreContextBinder.java +++ b/components/context/src/main/java/datadog/context/ObjectStoreContextBinder.java @@ -7,29 +7,28 @@ /** {@link ContextBinder} that uses {@link ObjectStore} to track context carriers. */ final class ObjectStoreContextBinder implements ContextBinder { - static final ContextBinder INSTANCE = new ObjectStoreContextBinder(); + static final ContextBinder INSTANCE = new ObjectStoreContextBinder(); - private static final ObjectStore CONTEXT_STORE = - ObjectStore.of(Object.class, Context.class); + private static final ObjectStore CONTEXT_STORE = ObjectStore.of(Object.class, Context.class); - @Override - public Context from(Object carrier) { - requireNonNull(carrier, "Context carrier cannot be null"); - Context bound = CONTEXT_STORE.get(carrier); - return null != bound ? bound : root(); - } + @Override + public Context from(Object carrier) { + requireNonNull(carrier, "Context carrier cannot be null"); + Context bound = CONTEXT_STORE.get(carrier); + return null != bound ? bound : root(); + } - @Override - public void attachTo(Object carrier, Context context) { - requireNonNull(carrier, "Context carrier cannot be null"); - requireNonNull(context, "Context cannot be null. Use detachFrom() instead."); - CONTEXT_STORE.put(carrier, context); - } + @Override + public void attachTo(Object carrier, Context context) { + requireNonNull(carrier, "Context carrier cannot be null"); + requireNonNull(context, "Context cannot be null. Use detachFrom() instead."); + CONTEXT_STORE.put(carrier, context); + } - @Override - public Context detachFrom(Object carrier) { - requireNonNull(carrier, "Context key cannot be null"); - Context previous = CONTEXT_STORE.remove(carrier); - return null != previous ? previous : root(); - } + @Override + public Context detachFrom(Object carrier) { + requireNonNull(carrier, "Context key cannot be null"); + Context previous = CONTEXT_STORE.remove(carrier); + return null != previous ? previous : root(); + } } diff --git a/components/context/src/main/java/datadog/context/SelfScopedContext.java b/components/context/src/main/java/datadog/context/SelfScopedContext.java index f19dbf98b73..047d05f1ddd 100644 --- a/components/context/src/main/java/datadog/context/SelfScopedContext.java +++ b/components/context/src/main/java/datadog/context/SelfScopedContext.java @@ -2,16 +2,16 @@ /** Context that acts as its own unattached scope. */ public interface SelfScopedContext extends Context, ContextScope { - @Override - default ContextScope asScope() { - return this; // acts as no-op scope, avoiding allocation - } + @Override + default ContextScope asScope() { + return this; // acts as no-op scope, avoiding allocation + } - @Override - default Context context() { - return this; - } + @Override + default Context context() { + return this; + } - @Override - default void close() {} + @Override + default void close() {} } diff --git a/components/context/src/main/java/datadog/context/SingletonContext.java b/components/context/src/main/java/datadog/context/SingletonContext.java index 300c9247798..3d31654bc1f 100644 --- a/components/context/src/main/java/datadog/context/SingletonContext.java +++ b/components/context/src/main/java/datadog/context/SingletonContext.java @@ -8,60 +8,60 @@ /** {@link Context} containing a single value. */ final class SingletonContext implements SelfScopedContext { - final int index; - final Object value; + final int index; + final Object value; - SingletonContext(int index, Object value) { - this.index = index; - this.value = value; - } + SingletonContext(int index, Object value) { + this.index = index; + this.value = value; + } - @Override - @Nullable - @SuppressWarnings("unchecked") - public V get(ContextKey key) { - requireNonNull(key, "Context key cannot be null"); - return this.index == key.index ? (V) this.value : null; - } + @Override + @Nullable + @SuppressWarnings("unchecked") + public V get(ContextKey key) { + requireNonNull(key, "Context key cannot be null"); + return this.index == key.index ? (V) this.value : null; + } - @Override - public Context with(ContextKey secondKey, @Nullable V secondValue) { - requireNonNull(secondKey, "Context key cannot be null"); - int secondIndex = secondKey.index; - if (this.index == secondIndex) { - if (secondValue == null) { - return EmptyContext.INSTANCE; - } else if (secondValue != this.value) { - return new SingletonContext(this.index, secondValue); - } else { - return this; - } - } else { - Object[] store = new Object[max(this.index, secondIndex) + 1]; - store[this.index] = this.value; - store[secondIndex] = secondValue; - return new IndexedContext(store); + @Override + public Context with(ContextKey secondKey, @Nullable V secondValue) { + requireNonNull(secondKey, "Context key cannot be null"); + int secondIndex = secondKey.index; + if (this.index == secondIndex) { + if (secondValue == null) { + return EmptyContext.INSTANCE; + } else if (secondValue != this.value) { + return new SingletonContext(this.index, secondValue); + } else { + return this; + } + } else { + Object[] store = new Object[max(this.index, secondIndex) + 1]; + store[this.index] = this.value; + store[secondIndex] = secondValue; + return new IndexedContext(store); + } } - } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SingletonContext that = (SingletonContext) o; - return this.index == that.index && Objects.equals(this.value, that.value); - } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SingletonContext that = (SingletonContext) o; + return this.index == that.index && Objects.equals(this.value, that.value); + } - @Override - public int hashCode() { - int result = 31; - result = 31 * result + this.index; - result = 31 * result + Objects.hashCode(this.value); - return result; - } + @Override + public int hashCode() { + int result = 31; + result = 31 * result + this.index; + result = 31 * result + Objects.hashCode(this.value); + return result; + } - @Override - public String toString() { - return "SingletonContext{" + "index=" + this.index + ", value=" + this.value + '}'; - } + @Override + public String toString() { + return "SingletonContext{" + "index=" + this.index + ", value=" + this.value + '}'; + } } diff --git a/components/context/src/main/java/datadog/context/TestContextBinder.java b/components/context/src/main/java/datadog/context/TestContextBinder.java index 8717019d9cd..18774465965 100644 --- a/components/context/src/main/java/datadog/context/TestContextBinder.java +++ b/components/context/src/main/java/datadog/context/TestContextBinder.java @@ -2,38 +2,38 @@ /** Test class that always delegates to the latest registered {@link ContextBinder}. */ final class TestContextBinder implements ContextBinder { - private static final ContextBinder TEST_INSTANCE = new TestContextBinder(); + private static final ContextBinder TEST_INSTANCE = new TestContextBinder(); - private TestContextBinder() {} + private TestContextBinder() {} - static boolean register() { - // attempt to register before binder choice is locked, then check if we succeeded - ContextProviders.customBinder = TEST_INSTANCE; - return ContextProviders.binder() == TEST_INSTANCE; - } + static boolean register() { + // attempt to register before binder choice is locked, then check if we succeeded + ContextProviders.customBinder = TEST_INSTANCE; + return ContextProviders.binder() == TEST_INSTANCE; + } - @Override - public Context from(Object carrier) { - return delegate().from(carrier); - } + @Override + public Context from(Object carrier) { + return delegate().from(carrier); + } - @Override - public void attachTo(Object carrier, Context context) { - delegate().attachTo(carrier, context); - } + @Override + public void attachTo(Object carrier, Context context) { + delegate().attachTo(carrier, context); + } - @Override - public Context detachFrom(Object carrier) { - return delegate().detachFrom(carrier); - } + @Override + public Context detachFrom(Object carrier) { + return delegate().detachFrom(carrier); + } - private static ContextBinder delegate() { - ContextBinder delegate = ContextProviders.customBinder; - if (delegate == TEST_INSTANCE) { - // fall back to default context binder - return ObjectStoreContextBinder.INSTANCE; - } else { - return delegate; + private static ContextBinder delegate() { + ContextBinder delegate = ContextProviders.customBinder; + if (delegate == TEST_INSTANCE) { + // fall back to default context binder + return ObjectStoreContextBinder.INSTANCE; + } else { + return delegate; + } } - } } diff --git a/components/context/src/main/java/datadog/context/TestContextManager.java b/components/context/src/main/java/datadog/context/TestContextManager.java index a0b1302653c..f2e3c653e09 100644 --- a/components/context/src/main/java/datadog/context/TestContextManager.java +++ b/components/context/src/main/java/datadog/context/TestContextManager.java @@ -2,52 +2,52 @@ /** Test class that always delegates to the latest registered {@link ContextManager}. */ final class TestContextManager implements ContextManager { - private static final ContextManager TEST_INSTANCE = new TestContextManager(); - - private TestContextManager() {} - - static boolean register() { - // attempt to register before manager choice is locked, then check if we succeeded - ContextProviders.customManager = TEST_INSTANCE; - return ContextProviders.manager() == TEST_INSTANCE; - } - - @Override - public Context current() { - return delegate().current(); - } - - @Override - public ContextScope attach(Context context) { - return delegate().attach(context); - } - - @Override - public Context swap(Context context) { - return delegate().swap(context); - } - - @Override - public ContextContinuation capture(Context context) { - return delegate().capture(context); - } - - @Override - public void addListener(ContextListener listener) { - delegate().addListener(listener); - } - - static void clearListeners() { - ThreadLocalContextManager.INSTANCE.clearListeners(); - } - - private static ContextManager delegate() { - ContextManager delegate = ContextProviders.customManager; - if (delegate == TEST_INSTANCE) { - // fall back to default context manager - return ThreadLocalContextManager.INSTANCE; - } else { - return delegate; - } - } + private static final ContextManager TEST_INSTANCE = new TestContextManager(); + + private TestContextManager() {} + + static boolean register() { + // attempt to register before manager choice is locked, then check if we succeeded + ContextProviders.customManager = TEST_INSTANCE; + return ContextProviders.manager() == TEST_INSTANCE; + } + + @Override + public Context current() { + return delegate().current(); + } + + @Override + public ContextScope attach(Context context) { + return delegate().attach(context); + } + + @Override + public Context swap(Context context) { + return delegate().swap(context); + } + + @Override + public ContextContinuation capture(Context context) { + return delegate().capture(context); + } + + @Override + public void addListener(ContextListener listener) { + delegate().addListener(listener); + } + + static void clearListeners() { + ThreadLocalContextManager.INSTANCE.clearListeners(); + } + + private static ContextManager delegate() { + ContextManager delegate = ContextProviders.customManager; + if (delegate == TEST_INSTANCE) { + // fall back to default context manager + return ThreadLocalContextManager.INSTANCE; + } else { + return delegate; + } + } } diff --git a/components/context/src/main/java/datadog/context/ThreadLocalContextManager.java b/components/context/src/main/java/datadog/context/ThreadLocalContextManager.java index ccb3a641565..f97cdb29efe 100644 --- a/components/context/src/main/java/datadog/context/ThreadLocalContextManager.java +++ b/components/context/src/main/java/datadog/context/ThreadLocalContextManager.java @@ -6,263 +6,262 @@ /** {@link ContextManager} that uses a {@link ThreadLocal} to track context per thread. */ final class ThreadLocalContextManager implements ContextManager { - static final ThreadLocalContextManager INSTANCE = new ThreadLocalContextManager(); - - private static final ThreadLocal CONTEXT_HOLDER = - ThreadLocal.withInitial(ContextHolder::new); - - private static final NoopContextContinuation ROOT_CONTINUATION = - new NoopContextContinuation(Context.root()); - - private final Object listenersWriteLock = new Object(); - volatile ContextListener[] listeners = {}; - - @Override - public Context current() { - return CONTEXT_HOLDER.get().current; - } - - @Override - public ContextScope attach(Context context) { - return doAttach(context, null); - } - - ContextScope doAttach(Context context, @Nullable ContextContinuationImpl continuation) { - ContextHolder holder = CONTEXT_HOLDER.get(); - - Context beforeAttach = holder.current; - if (context == beforeAttach) { - if (continuation != null) { - // already attached, safe to release early to avoid resource leak - continuation.releaseOnScopeClose(); - return continuation; // acts as no-op scope, avoiding allocation - } - return context.asScope(); // convert to scope without attaching - } + static final ThreadLocalContextManager INSTANCE = new ThreadLocalContextManager(); - holder.current = context; - notifyUpdate(listeners, beforeAttach, context); - return continuation == null - ? new ContextScopeImpl(context, holder, beforeAttach) - : new ResumedScopeImpl(context, holder, beforeAttach, continuation); - } + private static final ThreadLocal CONTEXT_HOLDER = ThreadLocal.withInitial(ContextHolder::new); - @Override - public Context swap(Context context) { - ContextHolder holder = CONTEXT_HOLDER.get(); + private static final NoopContextContinuation ROOT_CONTINUATION = new NoopContextContinuation(Context.root()); - Context beforeSwap = holder.current; - if (context == beforeSwap) { - return beforeSwap; - } + private final Object listenersWriteLock = new Object(); + volatile ContextListener[] listeners = {}; - holder.current = context; - notifyUpdate(listeners, beforeSwap, context); - return beforeSwap; - } - - @Override - public ContextContinuation capture(Context context) { - return context == Context.root() ? ROOT_CONTINUATION : new ContextContinuationImpl(context); - } - - @Override - public void addListener(ContextListener listener) { - synchronized (listenersWriteLock) { - for (ContextListener l : listeners) { - if (l == listener) { - return; - } - } - int oldLength = listeners.length; - ContextListener[] update = Arrays.copyOf(listeners, oldLength + 1); - update[oldLength] = listener; - listeners = update; + @Override + public Context current() { + return CONTEXT_HOLDER.get().current; } - } - void clearListeners() { - synchronized (listenersWriteLock) { - listeners = new ContextListener[] {}; - } - } - - static void notifyUpdate(ContextListener[] listeners, Context before, Context after) { - for (ContextListener l : listeners) { - try { - l.onUpdate(before, after); - } catch (Throwable ignore) { - } - } - } - - static void notifyCapture(ContextListener[] listeners, Context context) { - // only called for non-empty continuations - for (ContextListener l : listeners) { - try { - l.onCapture(context); - } catch (Throwable ignore) { - } - } - } - - static void notifyRelease(ContextListener[] listeners, Context context) { - // only called for non-empty continuations - for (ContextListener l : listeners) { - try { - l.onRelease(context); - } catch (Throwable ignore) { - } + @Override + public ContextScope attach(Context context) { + return doAttach(context, null); } - } - - private static class ContextScopeImpl implements ContextScope { - - private final Context context; - private final ContextHolder holder; - private final Context beforeAttach; - private boolean closed; + ContextScope doAttach(Context context, @Nullable ContextContinuationImpl continuation) { + ContextHolder holder = CONTEXT_HOLDER.get(); + + Context beforeAttach = holder.current; + if (context == beforeAttach) { + if (continuation != null) { + // already attached, safe to release early to avoid resource leak + continuation.releaseOnScopeClose(); + return continuation; // acts as no-op scope, avoiding allocation + } + return context.asScope(); // convert to scope without attaching + } - ContextScopeImpl(Context context, ContextHolder holder, Context beforeAttach) { - this.context = context; - this.holder = holder; - this.beforeAttach = beforeAttach; + holder.current = context; + notifyUpdate(listeners, beforeAttach, context); + return continuation == null + ? new ContextScopeImpl(context, holder, beforeAttach) + : new ResumedScopeImpl(context, holder, beforeAttach, continuation); } @Override - public final Context context() { - return context; + public Context swap(Context context) { + ContextHolder holder = CONTEXT_HOLDER.get(); + + Context beforeSwap = holder.current; + if (context == beforeSwap) { + return beforeSwap; + } + + holder.current = context; + notifyUpdate(listeners, beforeSwap, context); + return beforeSwap; } @Override - public void close() { - // check for out-of-order close to avoid corrupting the current state - if (!closed && context == holder.current) { - holder.current = beforeAttach; - notifyUpdate(INSTANCE.listeners, context, beforeAttach); - closed = true; - } - } - } - - private static final class ResumedScopeImpl extends ContextScopeImpl { - @Nullable private ContextContinuationImpl continuation; - - ResumedScopeImpl( - Context context, - ContextHolder holder, - Context beforeAttach, - @Nullable ContextContinuationImpl continuation) { - super(context, holder, beforeAttach); - this.continuation = continuation; + public ContextContinuation capture(Context context) { + return context == Context.root() ? ROOT_CONTINUATION : new ContextContinuationImpl(context); } @Override - public void close() { - if (continuation != null) { - // release first to avoid resource leak, even on out-of-order close - continuation.releaseOnScopeClose(); - continuation = null; - } - super.close(); // proceed to try and update the current execution unit + public void addListener(ContextListener listener) { + synchronized (listenersWriteLock) { + for (ContextListener l : listeners) { + if (l == listener) { + return; + } + } + int oldLength = listeners.length; + ContextListener[] update = Arrays.copyOf(listeners, oldLength + 1); + update[oldLength] = listener; + listeners = update; + } } - } - - private static final class ContextContinuationImpl implements ContextContinuation, ContextScope { - - private static final AtomicIntegerFieldUpdater COUNT = - AtomicIntegerFieldUpdater.newUpdater(ContextContinuationImpl.class, "count"); - - // these boundaries were selected to allow for speculative counting and fuzzy checks - private static final int RELEASED = Integer.MIN_VALUE >> 1; - private static final int HELD = (Integer.MAX_VALUE >> 1) + 1; - - private final Context context; - - /** - * When positive this reflects the number of outstanding resumed scopes as well as whether there - * is an active hold on the continuation: - * - * - * - * - * - * - * - *
Value Meaning
0Not held or resumed
1..HELD-1Resumed, not held
HELDHeld, not yet resumed
HELD..MAX_INTResumed and held
- * - * where HELD is at the mid-point between 1 and MAX_INT. - * - *

A negative value of RELEASED reflects that the continuation has either been resumed and - * all associated scopes are now closed, or it has been explicitly released. This value was - * chosen to be half the size of MIN_INT to avoid speculative additions in {@link #resume()} - * from overflowing to a positive count. - */ - private volatile int count = 0; - - ContextContinuationImpl(Context context) { - this.context = context; - notifyCapture(INSTANCE.listeners, context); + + void clearListeners() { + synchronized (listenersWriteLock) { + listeners = new ContextListener[] {}; + } } - @Override - public ContextContinuation hold() { - // update initial count to record that this continuation has a hold - COUNT.compareAndSet(this, 0, HELD); - return this; + static void notifyUpdate(ContextListener[] listeners, Context before, Context after) { + for (ContextListener l : listeners) { + try { + l.onUpdate(before, after); + } catch (Throwable ignore) { + } + } } - @Override - public Context context() { - return context; + static void notifyCapture(ContextListener[] listeners, Context context) { + // only called for non-empty continuations + for (ContextListener l : listeners) { + try { + l.onCapture(context); + } catch (Throwable ignore) { + } + } } - @Override - public ContextScope resume() { - if (COUNT.incrementAndGet(this) > 0) { - // speculative update succeeded, continuation can be resumed - return INSTANCE.doAttach(context, this); - } else { - // continuation released or too many resumes; rollback count - COUNT.decrementAndGet(this); - return this; // acts as no-op scope, avoiding allocation - } + static void notifyRelease(ContextListener[] listeners, Context context) { + // only called for non-empty continuations + for (ContextListener l : listeners) { + try { + l.onRelease(context); + } catch (Throwable ignore) { + } + } } - @Override - public void release() { - int current = count; - while (current >= HELD) { - // remove the hold on this continuation by removing the offset - COUNT.compareAndSet(this, current, current - HELD); - current = count; - } - while (current == 0) { - // no outstanding resumes and hold has been removed - if (COUNT.compareAndSet(this, current, RELEASED)) { - notifyRelease(INSTANCE.listeners, context); - return; + private static class ContextScopeImpl implements ContextScope { + + private final Context context; + private final ContextHolder holder; + private final Context beforeAttach; + + private boolean closed; + + ContextScopeImpl(Context context, ContextHolder holder, Context beforeAttach) { + this.context = context; + this.holder = holder; + this.beforeAttach = beforeAttach; + } + + @Override + public final Context context() { + return context; + } + + @Override + public void close() { + // check for out-of-order close to avoid corrupting the current state + if (!closed && context == holder.current) { + holder.current = beforeAttach; + notifyUpdate(INSTANCE.listeners, context, beforeAttach); + closed = true; + } } - current = count; - } } - void releaseOnScopeClose() { - if (COUNT.compareAndSet(this, 1, RELEASED)) { - // fast path: only one resume of the continuation (no hold) - notifyRelease(INSTANCE.listeners, context); - } else if (COUNT.decrementAndGet(this) == 0) { - // slow path: multiple resumes, all scopes now closed (no hold) - release(); - } /* else there are outstanding resumes or hold is in place */ + private static final class ResumedScopeImpl extends ContextScopeImpl { + @Nullable + private ContextContinuationImpl continuation; + + ResumedScopeImpl( + Context context, + ContextHolder holder, + Context beforeAttach, + @Nullable ContextContinuationImpl continuation) { + super(context, holder, beforeAttach); + this.continuation = continuation; + } + + @Override + public void close() { + if (continuation != null) { + // release first to avoid resource leak, even on out-of-order close + continuation.releaseOnScopeClose(); + continuation = null; + } + super.close(); // proceed to try and update the current execution unit + } } - @Override - public void close() {} - } + private static final class ContextContinuationImpl implements ContextContinuation, ContextScope { + + private static final AtomicIntegerFieldUpdater COUNT = + AtomicIntegerFieldUpdater.newUpdater(ContextContinuationImpl.class, "count"); + + // these boundaries were selected to allow for speculative counting and fuzzy checks + private static final int RELEASED = Integer.MIN_VALUE >> 1; + private static final int HELD = (Integer.MAX_VALUE >> 1) + 1; + + private final Context context; + + /** + * When positive this reflects the number of outstanding resumed scopes as well as whether there + * is an active hold on the continuation: + * + * + * + * + * + * + * + *
Value Meaning
0Not held or resumed
1..HELD-1Resumed, not held
HELDHeld, not yet resumed
HELD..MAX_INTResumed and held
+ * + * where HELD is at the mid-point between 1 and MAX_INT. + * + *

A negative value of RELEASED reflects that the continuation has either been resumed and + * all associated scopes are now closed, or it has been explicitly released. This value was + * chosen to be half the size of MIN_INT to avoid speculative additions in {@link #resume()} + * from overflowing to a positive count. + */ + private volatile int count = 0; + + ContextContinuationImpl(Context context) { + this.context = context; + notifyCapture(INSTANCE.listeners, context); + } - private static final class ContextHolder { - Context current = Context.root(); - } + @Override + public ContextContinuation hold() { + // update initial count to record that this continuation has a hold + COUNT.compareAndSet(this, 0, HELD); + return this; + } + + @Override + public Context context() { + return context; + } + + @Override + public ContextScope resume() { + if (COUNT.incrementAndGet(this) > 0) { + // speculative update succeeded, continuation can be resumed + return INSTANCE.doAttach(context, this); + } else { + // continuation released or too many resumes; rollback count + COUNT.decrementAndGet(this); + return this; // acts as no-op scope, avoiding allocation + } + } + + @Override + public void release() { + int current = count; + while (current >= HELD) { + // remove the hold on this continuation by removing the offset + COUNT.compareAndSet(this, current, current - HELD); + current = count; + } + while (current == 0) { + // no outstanding resumes and hold has been removed + if (COUNT.compareAndSet(this, current, RELEASED)) { + notifyRelease(INSTANCE.listeners, context); + return; + } + current = count; + } + } + + void releaseOnScopeClose() { + if (COUNT.compareAndSet(this, 1, RELEASED)) { + // fast path: only one resume of the continuation (no hold) + notifyRelease(INSTANCE.listeners, context); + } else if (COUNT.decrementAndGet(this) == 0) { + // slow path: multiple resumes, all scopes now closed (no hold) + release(); + } /* else there are outstanding resumes or hold is in place */ + } + + @Override + public void close() {} + } + + private static final class ContextHolder { + Context current = Context.root(); + } } diff --git a/components/context/src/main/java/datadog/context/propagation/CarrierSetter.java b/components/context/src/main/java/datadog/context/propagation/CarrierSetter.java index 76a6abda835..b78f904fa91 100644 --- a/components/context/src/main/java/datadog/context/propagation/CarrierSetter.java +++ b/components/context/src/main/java/datadog/context/propagation/CarrierSetter.java @@ -2,12 +2,12 @@ @FunctionalInterface public interface CarrierSetter { - /** - * Sets a carrier key/value pair. - * - * @param carrier the carrier to store key/value into. - * @param key the key to set. - * @param value the value to set. - */ - void set(C carrier, String key, String value); + /** + * Sets a carrier key/value pair. + * + * @param carrier the carrier to store key/value into. + * @param key the key to set. + * @param value the value to set. + */ + void set(C carrier, String key, String value); } diff --git a/components/context/src/main/java/datadog/context/propagation/CarrierVisitor.java b/components/context/src/main/java/datadog/context/propagation/CarrierVisitor.java index e35183d21d5..6bd1e01589e 100644 --- a/components/context/src/main/java/datadog/context/propagation/CarrierVisitor.java +++ b/components/context/src/main/java/datadog/context/propagation/CarrierVisitor.java @@ -16,11 +16,11 @@ */ @FunctionalInterface public interface CarrierVisitor { - /** - * Iterates over the carrier content and calls the visitor callback for every key/value found. - * - * @param carrier the carrier to iterate over. - * @param visitor the callback to call for each carrier key/value pair found. - */ - void forEachKeyValue(C carrier, BiConsumer visitor); + /** + * Iterates over the carrier content and calls the visitor callback for every key/value found. + * + * @param carrier the carrier to iterate over. + * @param visitor the callback to call for each carrier key/value pair found. + */ + void forEachKeyValue(C carrier, BiConsumer visitor); } diff --git a/components/context/src/main/java/datadog/context/propagation/CompositePropagator.java b/components/context/src/main/java/datadog/context/propagation/CompositePropagator.java index 9484b2a8054..051fced0c2e 100644 --- a/components/context/src/main/java/datadog/context/propagation/CompositePropagator.java +++ b/components/context/src/main/java/datadog/context/propagation/CompositePropagator.java @@ -6,50 +6,50 @@ import java.util.function.BiConsumer; class CompositePropagator implements Propagator { - private final Propagator[] propagators; + private final Propagator[] propagators; - CompositePropagator(Propagator[] propagators) { - this.propagators = propagators; - } - - @Override - public void inject(Context context, C carrier, CarrierSetter setter) { - for (int i = this.propagators.length - 1; i >= 0; i--) { - this.propagators[i].inject(context, carrier, setter); - } - } - - @Override - public Context extract(Context context, C carrier, CarrierVisitor visitor) { - // Extract and cache carrier key/value pairs - CarrierCache carrierCache = new CarrierCache(); - visitor.forEachKeyValue(carrier, carrierCache); - // Run the multiple extractions on cache - for (Propagator propagator : this.propagators) { - context = propagator.extract(context, carrierCache, carrierCache); + CompositePropagator(Propagator[] propagators) { + this.propagators = propagators; } - return context; - } - - static class CarrierCache implements BiConsumer, CarrierVisitor { - /** Cached key/values from carrier (even indexes are keys, odd indexes are values). */ - private final List keysAndValues; - public CarrierCache() { - this.keysAndValues = new ArrayList<>(32); + @Override + public void inject(Context context, C carrier, CarrierSetter setter) { + for (int i = this.propagators.length - 1; i >= 0; i--) { + this.propagators[i].inject(context, carrier, setter); + } } @Override - public void accept(String key, String value) { - this.keysAndValues.add(key); - this.keysAndValues.add(value); + public Context extract(Context context, C carrier, CarrierVisitor visitor) { + // Extract and cache carrier key/value pairs + CarrierCache carrierCache = new CarrierCache(); + visitor.forEachKeyValue(carrier, carrierCache); + // Run the multiple extractions on cache + for (Propagator propagator : this.propagators) { + context = propagator.extract(context, carrierCache, carrierCache); + } + return context; } - @Override - public void forEachKeyValue(CarrierCache carrier, BiConsumer visitor) { - for (int i = 0; i < carrier.keysAndValues.size() - 1; i += 2) { - visitor.accept(carrier.keysAndValues.get(i), carrier.keysAndValues.get(i + 1)); - } + static class CarrierCache implements BiConsumer, CarrierVisitor { + /** Cached key/values from carrier (even indexes are keys, odd indexes are values). */ + private final List keysAndValues; + + public CarrierCache() { + this.keysAndValues = new ArrayList<>(32); + } + + @Override + public void accept(String key, String value) { + this.keysAndValues.add(key); + this.keysAndValues.add(value); + } + + @Override + public void forEachKeyValue(CarrierCache carrier, BiConsumer visitor) { + for (int i = 0; i < carrier.keysAndValues.size() - 1; i += 2) { + visitor.accept(carrier.keysAndValues.get(i), carrier.keysAndValues.get(i + 1)); + } + } } - } } diff --git a/components/context/src/main/java/datadog/context/propagation/Concern.java b/components/context/src/main/java/datadog/context/propagation/Concern.java index ed7681369ac..710fcdcee46 100644 --- a/components/context/src/main/java/datadog/context/propagation/Concern.java +++ b/components/context/src/main/java/datadog/context/propagation/Concern.java @@ -6,54 +6,54 @@ /** This class defines a cross-cutting concern to be propagated from a {@link Context}. */ public class Concern { - /** The concern default priority. */ - public static final int DEFAULT_PRIORITY = 100; - - /** The concern name, for debugging purpose only. */ - private final String name; - - /** The concern priority, lower value means higher priority. */ - private final int priority; - - /** - * Creates a concern. - * - * @param name the concern name, for debugging purpose only. - * @return The created concern. - */ - public static Concern named(String name) { - return new Concern(name, DEFAULT_PRIORITY); - } - - /** - * Creates a concern with a specific priority. - * - * @param name the concern name, for debugging purpose only. - * @param priority the concern priority (lower value means higher priority, while the default is - * {@link #DEFAULT_PRIORITY}), - * @return The created concern. - */ - public static Concern withPriority(String name, int priority) { - return new Concern(name, priority); - } - - private Concern(String name, int priority) { - requireNonNull(name, "Concern name cannot be null"); - if (priority < 0) { - throw new IllegalArgumentException("Concern priority cannot be negative"); + /** The concern default priority. */ + public static final int DEFAULT_PRIORITY = 100; + + /** The concern name, for debugging purpose only. */ + private final String name; + + /** The concern priority, lower value means higher priority. */ + private final int priority; + + /** + * Creates a concern. + * + * @param name the concern name, for debugging purpose only. + * @return The created concern. + */ + public static Concern named(String name) { + return new Concern(name, DEFAULT_PRIORITY); } - this.name = name; - this.priority = priority; - } - int priority() { - return this.priority; - } + /** + * Creates a concern with a specific priority. + * + * @param name the concern name, for debugging purpose only. + * @param priority the concern priority (lower value means higher priority, while the default is + * {@link #DEFAULT_PRIORITY}), + * @return The created concern. + */ + public static Concern withPriority(String name, int priority) { + return new Concern(name, priority); + } + + private Concern(String name, int priority) { + requireNonNull(name, "Concern name cannot be null"); + if (priority < 0) { + throw new IllegalArgumentException("Concern priority cannot be negative"); + } + this.name = name; + this.priority = priority; + } - // We want identity equality, so no need to override equals(). + int priority() { + return this.priority; + } + + // We want identity equality, so no need to override equals(). - @Override - public String toString() { - return this.name; - } + @Override + public String toString() { + return this.name; + } } diff --git a/components/context/src/main/java/datadog/context/propagation/NoopPropagator.java b/components/context/src/main/java/datadog/context/propagation/NoopPropagator.java index 88a1ab7d724..a1f5726d77a 100644 --- a/components/context/src/main/java/datadog/context/propagation/NoopPropagator.java +++ b/components/context/src/main/java/datadog/context/propagation/NoopPropagator.java @@ -3,17 +3,17 @@ import datadog.context.Context; final class NoopPropagator implements Propagator { - static final NoopPropagator INSTANCE = new NoopPropagator(); + static final NoopPropagator INSTANCE = new NoopPropagator(); - private NoopPropagator() {} + private NoopPropagator() {} - @Override - public void inject(Context context, C carrier, CarrierSetter setter) { - // noop - } + @Override + public void inject(Context context, C carrier, CarrierSetter setter) { + // noop + } - @Override - public Context extract(Context context, C carrier, CarrierVisitor visitor) { - return context; - } + @Override + public Context extract(Context context, C carrier, CarrierVisitor visitor) { + return context; + } } diff --git a/components/context/src/main/java/datadog/context/propagation/Propagator.java b/components/context/src/main/java/datadog/context/propagation/Propagator.java index cfd9b3d963a..97e86237d7f 100644 --- a/components/context/src/main/java/datadog/context/propagation/Propagator.java +++ b/components/context/src/main/java/datadog/context/propagation/Propagator.java @@ -11,25 +11,25 @@ * store and retrieve key/value pairs from carriers. */ public interface Propagator { - /** - * Injects a context into a downstream service using the given carrier. - * - * @param context the context containing the values to be injected. - * @param carrier the instance that will receive the key/value pairs to propagate. - * @param setter the callback to set key/value pairs into the carrier. - * @param the type of carrier instance. - */ - void inject(Context context, C carrier, CarrierSetter setter); + /** + * Injects a context into a downstream service using the given carrier. + * + * @param context the context containing the values to be injected. + * @param carrier the instance that will receive the key/value pairs to propagate. + * @param setter the callback to set key/value pairs into the carrier. + * @param the type of carrier instance. + */ + void inject(Context context, C carrier, CarrierSetter setter); - /** - * Extracts a context from un upstream service. - * - * @param context the base context to store the extracted values on top, use {@link - * Context#root()} for a default base context. - * @param carrier the instance to fetch the propagated key/value pairs from. - * @param visitor the callback to walk over the carrier and extract its key/value pais. - * @param the type of the carrier. - * @return A context with the extracted values on top of the given base context. - */ - Context extract(Context context, C carrier, CarrierVisitor visitor); + /** + * Extracts a context from un upstream service. + * + * @param context the base context to store the extracted values on top, use {@link + * Context#root()} for a default base context. + * @param carrier the instance to fetch the propagated key/value pairs from. + * @param visitor the callback to walk over the carrier and extract its key/value pais. + * @param the type of the carrier. + * @return A context with the extracted values on top of the given base context. + */ + Context extract(Context context, C carrier, CarrierVisitor visitor); } diff --git a/components/context/src/main/java/datadog/context/propagation/Propagators.java b/components/context/src/main/java/datadog/context/propagation/Propagators.java index cbf1bd0ae5f..8d42d3b0693 100644 --- a/components/context/src/main/java/datadog/context/propagation/Propagators.java +++ b/components/context/src/main/java/datadog/context/propagation/Propagators.java @@ -11,136 +11,133 @@ * Propagator} to use. */ public final class Propagators { - private static final Map PROPAGATORS = - synchronizedMap(new IdentityHashMap<>()); - private static final RegisteredPropagator NOOP = - RegisteredPropagator.of(NoopPropagator.INSTANCE, false); - private static volatile Propagator defaultPropagator = null; - private static volatile boolean rebuildDefaultPropagator = true; - - private Propagators() {} - - /** - * Gets the default propagator that applies all registered propagators in their priority order. - * - * @return The default propagator. - */ - public static Propagator defaultPropagator() { - if (rebuildDefaultPropagator) { - Propagator[] propagatorsByPriority = - PROPAGATORS.entrySet().stream() - .filter(entry -> entry.getValue().isUsedAsDefault()) - .sorted(comparingInt(entry -> entry.getKey().priority())) - .map(Map.Entry::getValue) - .map(RegisteredPropagator::propagator) - .toArray(Propagator[]::new); - defaultPropagator = composite(propagatorsByPriority); - rebuildDefaultPropagator = false; + private static final Map PROPAGATORS = synchronizedMap(new IdentityHashMap<>()); + private static final RegisteredPropagator NOOP = RegisteredPropagator.of(NoopPropagator.INSTANCE, false); + private static volatile Propagator defaultPropagator = null; + private static volatile boolean rebuildDefaultPropagator = true; + + private Propagators() {} + + /** + * Gets the default propagator that applies all registered propagators in their priority order. + * + * @return The default propagator. + */ + public static Propagator defaultPropagator() { + if (rebuildDefaultPropagator) { + Propagator[] propagatorsByPriority = PROPAGATORS.entrySet().stream() + .filter(entry -> entry.getValue().isUsedAsDefault()) + .sorted(comparingInt(entry -> entry.getKey().priority())) + .map(Map.Entry::getValue) + .map(RegisteredPropagator::propagator) + .toArray(Propagator[]::new); + defaultPropagator = composite(propagatorsByPriority); + rebuildDefaultPropagator = false; + } + return defaultPropagator; } - return defaultPropagator; - } - - /** - * Gets the propagator for a given concern. - * - * @param concern the concern to get propagator for. - * @return the related propagator if registered, a {@link #noop()} propagator otherwise. - */ - public static Propagator forConcern(Concern concern) { - return PROPAGATORS.getOrDefault(concern, NOOP).propagator(); - } - - /** - * Gets the propagator for the given concerns. - * - * @param concerns the concerns to get propagators for. - * @return A propagator that will apply the concern propagators if registered, in the given - * concern order. - */ - public static Propagator forConcerns(Concern... concerns) { - Propagator[] propagators = new Propagator[concerns.length]; - for (int i = 0; i < concerns.length; i++) { - propagators[i] = forConcern(concerns[i]); - } - return composite(propagators); - } - - /** - * Returns a noop propagator. - * - * @return a noop propagator. - */ - public static Propagator noop() { - return NoopPropagator.INSTANCE; - } - - /** - * Creates a composite propagator. - * - * @param propagators the elements that composes the returned propagator. - * @return the composite propagator that will apply the propagators in their given order for - * context extraction, and reverse given order for context injection. - */ - public static Propagator composite(Propagator... propagators) { - if (propagators.length == 0) { - return NoopPropagator.INSTANCE; - } else if (propagators.length == 1) { - return propagators[0]; - } else { - return new CompositePropagator(propagators); + + /** + * Gets the propagator for a given concern. + * + * @param concern the concern to get propagator for. + * @return the related propagator if registered, a {@link #noop()} propagator otherwise. + */ + public static Propagator forConcern(Concern concern) { + return PROPAGATORS.getOrDefault(concern, NOOP).propagator(); } - } - - /** - * Registers a propagator for concern. - * - * @param concern The concern to register a propagator for. - * @param propagator The propagator to register. - */ - public static void register(Concern concern, Propagator propagator) { - register(concern, propagator, true); - } - - /** - * Registers a propagator for concern. - * - * @param concern The concern to register a propagator for. - * @param propagator The propagator to register. - * @param usedAsDefault Whether the propagator should be used as default propagator. - * @see Propagators#defaultPropagator() - */ - public static void register(Concern concern, Propagator propagator, boolean usedAsDefault) { - PROPAGATORS.put(concern, RegisteredPropagator.of(propagator, usedAsDefault)); - if (usedAsDefault) { - rebuildDefaultPropagator = true; + + /** + * Gets the propagator for the given concerns. + * + * @param concerns the concerns to get propagators for. + * @return A propagator that will apply the concern propagators if registered, in the given + * concern order. + */ + public static Propagator forConcerns(Concern... concerns) { + Propagator[] propagators = new Propagator[concerns.length]; + for (int i = 0; i < concerns.length; i++) { + propagators[i] = forConcern(concerns[i]); + } + return composite(propagators); } - } - /** Clear all registered propagators. For testing purpose only. */ - static void reset() { - PROPAGATORS.clear(); - rebuildDefaultPropagator = true; - } + /** + * Returns a noop propagator. + * + * @return a noop propagator. + */ + public static Propagator noop() { + return NoopPropagator.INSTANCE; + } - static class RegisteredPropagator { - private final Propagator propagator; - private final boolean usedAsDefault; + /** + * Creates a composite propagator. + * + * @param propagators the elements that composes the returned propagator. + * @return the composite propagator that will apply the propagators in their given order for + * context extraction, and reverse given order for context injection. + */ + public static Propagator composite(Propagator... propagators) { + if (propagators.length == 0) { + return NoopPropagator.INSTANCE; + } else if (propagators.length == 1) { + return propagators[0]; + } else { + return new CompositePropagator(propagators); + } + } - private RegisteredPropagator(Propagator propagator, boolean usedAsDefault) { - this.propagator = propagator; - this.usedAsDefault = usedAsDefault; + /** + * Registers a propagator for concern. + * + * @param concern The concern to register a propagator for. + * @param propagator The propagator to register. + */ + public static void register(Concern concern, Propagator propagator) { + register(concern, propagator, true); } - static RegisteredPropagator of(Propagator propagator, boolean useAsDefault) { - return new RegisteredPropagator(propagator, useAsDefault); + /** + * Registers a propagator for concern. + * + * @param concern The concern to register a propagator for. + * @param propagator The propagator to register. + * @param usedAsDefault Whether the propagator should be used as default propagator. + * @see Propagators#defaultPropagator() + */ + public static void register(Concern concern, Propagator propagator, boolean usedAsDefault) { + PROPAGATORS.put(concern, RegisteredPropagator.of(propagator, usedAsDefault)); + if (usedAsDefault) { + rebuildDefaultPropagator = true; + } } - Propagator propagator() { - return this.propagator; + /** Clear all registered propagators. For testing purpose only. */ + static void reset() { + PROPAGATORS.clear(); + rebuildDefaultPropagator = true; } - boolean isUsedAsDefault() { - return this.usedAsDefault; + static class RegisteredPropagator { + private final Propagator propagator; + private final boolean usedAsDefault; + + private RegisteredPropagator(Propagator propagator, boolean usedAsDefault) { + this.propagator = propagator; + this.usedAsDefault = usedAsDefault; + } + + static RegisteredPropagator of(Propagator propagator, boolean useAsDefault) { + return new RegisteredPropagator(propagator, useAsDefault); + } + + Propagator propagator() { + return this.propagator; + } + + boolean isUsedAsDefault() { + return this.usedAsDefault; + } } - } } diff --git a/components/context/src/test/java/datadog/context/ContextBinderTest.java b/components/context/src/test/java/datadog/context/ContextBinderTest.java index eb2bb35563e..6da4ab8abb1 100644 --- a/components/context/src/test/java/datadog/context/ContextBinderTest.java +++ b/components/context/src/test/java/datadog/context/ContextBinderTest.java @@ -11,41 +11,41 @@ import org.junit.jupiter.api.Test; class ContextBinderTest extends ContextTestBase { - @Test - void testAttachAndDetach() { - // Setting up test - Context context = root().with(STRING_KEY, "value"); - Object carrier = new Object(); - assertEquals(root(), from(carrier), "Carrier expected to hold root context by default"); - // Attaching context - context.attachTo(carrier); - assertEquals(context, from(carrier), "Carrier expected to hold new context"); - assertEquals(root(), current(), "Current execution expected to stay root"); - // Detaching removes all context - assertEquals(context, detachFrom(carrier), "Detached context expected to new context"); - assertEquals(root(), detachFrom(carrier), "Carrier expected to hold no more context"); - assertEquals(root(), from(carrier), "Carrier expected to hold no more context"); - } + @Test + void testAttachAndDetach() { + // Setting up test + Context context = root().with(STRING_KEY, "value"); + Object carrier = new Object(); + assertEquals(root(), from(carrier), "Carrier expected to hold root context by default"); + // Attaching context + context.attachTo(carrier); + assertEquals(context, from(carrier), "Carrier expected to hold new context"); + assertEquals(root(), current(), "Current execution expected to stay root"); + // Detaching removes all context + assertEquals(context, detachFrom(carrier), "Detached context expected to new context"); + assertEquals(root(), detachFrom(carrier), "Carrier expected to hold no more context"); + assertEquals(root(), from(carrier), "Carrier expected to hold no more context"); + } - @Test - void testNullCarrier() { - assertThrows( - NullPointerException.class, - () -> assertEquals(root(), from(null), "Binder expected to return non-null context"), - "Null carrier expected to hold root context"); - assertThrows( - NullPointerException.class, - () -> assertEquals(root(), detachFrom(null), "Binder expected to return non-null context"), - "Null carrier expected to hold root context"); - } + @Test + void testNullCarrier() { + assertThrows( + NullPointerException.class, + () -> assertEquals(root(), from(null), "Binder expected to return non-null context"), + "Null carrier expected to hold root context"); + assertThrows( + NullPointerException.class, + () -> assertEquals(root(), detachFrom(null), "Binder expected to return non-null context"), + "Null carrier expected to hold root context"); + } - @Test - void testNullContext() { - ContextBinder binder = ContextProviders.binder(); - Object carrier = new Object(); - assertThrows( - NullPointerException.class, - () -> binder.attachTo(carrier, null), - "Attaching null context not expected to throw"); - } + @Test + void testNullContext() { + ContextBinder binder = ContextProviders.binder(); + Object carrier = new Object(); + assertThrows( + NullPointerException.class, + () -> binder.attachTo(carrier, null), + "Attaching null context not expected to throw"); + } } diff --git a/components/context/src/test/java/datadog/context/ContextContinuationTest.java b/components/context/src/test/java/datadog/context/ContextContinuationTest.java index 5f818332c89..981ead65cee 100644 --- a/components/context/src/test/java/datadog/context/ContextContinuationTest.java +++ b/components/context/src/test/java/datadog/context/ContextContinuationTest.java @@ -21,346 +21,334 @@ @ParametersAreNonnullByDefault class ContextContinuationTest extends ContextTestBase { - @Test - void testCaptureRootContextIsNoop() { - ContextContinuation continuation = root().capture(); - assertEquals(root(), continuation.context()); - assertSame(continuation, continuation.hold()); // hold is a no-op, returns self - try (ContextScope scope = continuation.resume()) { - assertEquals(root(), current()); // nothing changes for root + @Test + void testCaptureRootContextIsNoop() { + ContextContinuation continuation = root().capture(); + assertEquals(root(), continuation.context()); + assertSame(continuation, continuation.hold()); // hold is a no-op, returns self + try (ContextScope scope = continuation.resume()) { + assertEquals(root(), current()); // nothing changes for root + } + assertEquals(root(), current()); + continuation.release(); // no-op } - assertEquals(root(), current()); - continuation.release(); // no-op - } - @Test - void testCaptureStoresContext() { - Context context = root().with(TEST_KEY, "captured"); - try (ContextScope scope = context.attach()) { - ContextContinuation continuation = context.capture(); - assertEquals(context, continuation.context()); - continuation.release(); + @Test + void testCaptureStoresContext() { + Context context = root().with(TEST_KEY, "captured"); + try (ContextScope scope = context.attach()) { + ContextContinuation continuation = context.capture(); + assertEquals(context, continuation.context()); + continuation.release(); + } } - } - @Test - void testCaptureFiresOnCaptureEvent() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - try (ContextScope scope = context.attach()) { - ContextContinuation continuation = - context.capture(); // capture while active (recommended pattern) - listener.assertNewEvents("update:{root}->value", "capture:value"); - continuation.release(); + @Test + void testCaptureFiresOnCaptureEvent() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + try (ContextScope scope = context.attach()) { + ContextContinuation continuation = context.capture(); // capture while active (recommended pattern) + listener.assertNewEvents("update:{root}->value", "capture:value"); + continuation.release(); + } + listener.assertNewEvents("release:value", "update:value->{root}"); } - listener.assertNewEvents("release:value", "update:value->{root}"); - } - @Test - void testResumeAttachesContextAndRestoresPreviousOnClose() { - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active (recommended pattern) + @Test + void testResumeAttachesContextAndRestoresPreviousOnClose() { + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active (recommended pattern) + } + // original scope is closed; resume the continuation here (same or different thread) + try (ContextScope scope = continuation.resume()) { + assertEquals(context, current()); + assertEquals(context, scope.context()); + } + assertEquals(root(), current()); } - // original scope is closed; resume the continuation here (same or different thread) - try (ContextScope scope = continuation.resume()) { - assertEquals(context, current()); - assertEquals(context, scope.context()); - } - assertEquals(root(), current()); - } - @Test - void testResumeAndScopeCloseFiresLifecycleEvents() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active - } - listener.assertNewEvents("update:{root}->value", "capture:value", "update:value->{root}"); - try (ContextScope scope = continuation.resume()) { - listener.assertNewEvents("update:{root}->value"); + @Test + void testResumeAndScopeCloseFiresLifecycleEvents() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active + } + listener.assertNewEvents("update:{root}->value", "capture:value", "update:value->{root}"); + try (ContextScope scope = continuation.resume()) { + listener.assertNewEvents("update:{root}->value"); + } + // release fires before update (continuation is released first inside ContextScopeImpl.close) + listener.assertNewEvents("release:value", "update:value->{root}"); } - // release fires before update (continuation is released first inside ContextScopeImpl.close) - listener.assertNewEvents("release:value", "update:value->{root}"); - } - @Test - void testHoldPreventsAutoReleaseOnScopeClose() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active - continuation.hold(); - listener.assertNewEvents("update:{root}->value", "capture:value"); - } - listener.assertNewEvents("update:value->{root}"); - try (ContextScope scope = continuation.resume()) { - assertEquals(context, current()); - listener.assertNewEvents("update:{root}->value"); + @Test + void testHoldPreventsAutoReleaseOnScopeClose() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active + continuation.hold(); + listener.assertNewEvents("update:{root}->value", "capture:value"); + } + listener.assertNewEvents("update:value->{root}"); + try (ContextScope scope = continuation.resume()) { + assertEquals(context, current()); + listener.assertNewEvents("update:{root}->value"); + } + assertEquals(root(), current()); + listener.assertNewEvents("update:value->{root}"); // release should not fire while hold is active + continuation.release(); + listener.assertNewEvents("release:value"); } - assertEquals(root(), current()); - listener.assertNewEvents( - "update:value->{root}"); // release should not fire while hold is active - continuation.release(); - listener.assertNewEvents("release:value"); - } - @Test - void testExplicitReleaseWithoutResumeFiresReleaseEvent() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active + @Test + void testExplicitReleaseWithoutResumeFiresReleaseEvent() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active + } + listener.assertNewEvents("update:{root}->value", "capture:value", "update:value->{root}"); + continuation.release(); + listener.assertNewEvents("release:value"); } - listener.assertNewEvents("update:{root}->value", "capture:value", "update:value->{root}"); - continuation.release(); - listener.assertNewEvents("release:value"); - } - @Test - void testResumeAfterReleaseIsNoop() { - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active + @Test + void testResumeAfterReleaseIsNoop() { + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active + } + continuation.release(); + // Resuming a released continuation should not attach the context + try (ContextScope scope = continuation.resume()) { + assertEquals(root(), current()); + } + assertEquals(root(), current()); } - continuation.release(); - // Resuming a released continuation should not attach the context - try (ContextScope scope = continuation.resume()) { - assertEquals(root(), current()); - } - assertEquals(root(), current()); - } - @Test - void testResumeOnDifferentThread() { - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active (recommended pattern) - } - // original scope is closed; resume the context on another thread - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - Future future = - executor.submit( - () -> { + @Test + void testResumeOnDifferentThread() { + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active (recommended pattern) + } + // original scope is closed; resume the context on another thread + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future future = executor.submit(() -> { assertEquals(root(), current()); // thread starts with root context try (ContextScope scope = continuation.resume()) { - assertEquals(context, current()); + assertEquals(context, current()); } assertEquals(root(), current()); // restored after scope close - }); - assertDoesNotThrow(() -> future.get()); - } finally { - executor.shutdown(); + }); + assertDoesNotThrow(() -> future.get()); + } finally { + executor.shutdown(); + } } - } - @Test - void testMultipleResumesReleaseAfterLastScopeCloses() throws InterruptedException { - List events = synchronizedList(new ArrayList<>()); - ContextManager.register( - new ContextListener() { - @Override - public void onRelease(Context c) { - events.add("release"); - } + @Test + void testMultipleResumesReleaseAfterLastScopeCloses() throws InterruptedException { + List events = synchronizedList(new ArrayList<>()); + ContextManager.register(new ContextListener() { + @Override + public void onRelease(Context c) { + events.add("release"); + } }); - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active - } - CountDownLatch bothResumed = new CountDownLatch(2); - CountDownLatch closeFirst = new CountDownLatch(1); - CountDownLatch firstClosed = new CountDownLatch(1); - CountDownLatch closeSecond = new CountDownLatch(1); - ExecutorService executor = Executors.newFixedThreadPool(2); - try { - Future f1 = - executor.submit( - () -> { + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active + } + CountDownLatch bothResumed = new CountDownLatch(2); + CountDownLatch closeFirst = new CountDownLatch(1); + CountDownLatch firstClosed = new CountDownLatch(1); + CountDownLatch closeSecond = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future f1 = executor.submit(() -> { try (ContextScope scope = continuation.resume()) { - bothResumed.countDown(); - closeFirst.await(); + bothResumed.countDown(); + closeFirst.await(); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + Thread.currentThread().interrupt(); } finally { - firstClosed.countDown(); + firstClosed.countDown(); } - }); - Future f2 = - executor.submit( - () -> { + }); + Future f2 = executor.submit(() -> { try (ContextScope scope = continuation.resume()) { - bothResumed.countDown(); - closeSecond.await(); + bothResumed.countDown(); + closeSecond.await(); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + Thread.currentThread().interrupt(); } - }); - bothResumed.await(); - assertTrue(events.isEmpty(), "release should not fire while scopes are open"); - closeFirst.countDown(); - firstClosed.await(); // wait for f1's scope to fully close - assertTrue(events.isEmpty(), "release should not fire after first scope closes"); - closeSecond.countDown(); - assertDoesNotThrow(() -> f1.get()); - assertDoesNotThrow(() -> f2.get()); - assertEquals(singletonList("release"), events); - } finally { - executor.shutdown(); - } - } - - @Test - void testSameContextResumeReleasesImmediately() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - try (ContextScope outer = context.attach()) { - // Context is already current; resume is a noop and continuation is released immediately - ContextContinuation continuation = context.capture(); - try (ContextScope noop = continuation.resume()) { - assertEquals(context, current()); - listener.assertNewEvents( - "update:{root}->value", "capture:value", "release:value"); // released synchronously - } - assertEquals(context, current()); // outer scope still holds context + }); + bothResumed.await(); + assertTrue(events.isEmpty(), "release should not fire while scopes are open"); + closeFirst.countDown(); + firstClosed.await(); // wait for f1's scope to fully close + assertTrue(events.isEmpty(), "release should not fire after first scope closes"); + closeSecond.countDown(); + assertDoesNotThrow(() -> f1.get()); + assertDoesNotThrow(() -> f2.get()); + assertEquals(singletonList("release"), events); + } finally { + executor.shutdown(); + } } - listener.assertNewEvents("update:value->{root}"); - } - @Test - void testOutOfOrderScopeCloseReleasesImmediately() { - // Recommended pattern: attach C, capture, close original scope - Context contextC = root().with(TEST_KEY, "C"); - ContextContinuation continuation; - try (ContextScope scope = contextC.attach()) { - continuation = contextC.capture(); + @Test + void testSameContextResumeReleasesImmediately() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + try (ContextScope outer = context.attach()) { + // Context is already current; resume is a noop and continuation is released immediately + ContextContinuation continuation = context.capture(); + try (ContextScope noop = continuation.resume()) { + assertEquals(context, current()); + listener.assertNewEvents( + "update:{root}->value", "capture:value", "release:value"); // released synchronously + } + assertEquals(context, current()); // outer scope still holds context + } + listener.assertNewEvents("update:value->{root}"); } - TrackingListener listener = trackingListener(); - ContextManager.register(listener); + @Test + void testOutOfOrderScopeCloseReleasesImmediately() { + // Recommended pattern: attach C, capture, close original scope + Context contextC = root().with(TEST_KEY, "C"); + ContextContinuation continuation; + try (ContextScope scope = contextC.attach()) { + continuation = contextC.capture(); + } - Context contextD = root().with(TEST_KEY, "D"); - try (ContextScope scopeR = continuation.resume()) { - assertEquals(contextC, current()); - try (ContextScope scopeD = contextD.attach()) { // attaching D fires update:C->D - assertEquals(contextD, current()); + TrackingListener listener = trackingListener(); + ContextManager.register(listener); - // close the resume scope out-of-order while D is still nested on top; - // release fires immediately, but update:C->{root} does not (C is not current) - scopeR.close(); - listener.assertNewEvents("update:{root}->C", "update:C->D", "release:C"); - assertEquals(contextD, current()); // D is still current - } // scopeD closes here: unwind D normally, restores C - listener.assertNewEvents("update:D->C"); - } // try-with-resources closes scopeR again; no second release, C unwinds to root + Context contextD = root().with(TEST_KEY, "D"); + try (ContextScope scopeR = continuation.resume()) { + assertEquals(contextC, current()); + try (ContextScope scopeD = contextD.attach()) { // attaching D fires update:C->D + assertEquals(contextD, current()); - assertEquals(root(), current()); - listener.assertNewEvents("update:C->{root}"); - } + // close the resume scope out-of-order while D is still nested on top; + // release fires immediately, but update:C->{root} does not (C is not current) + scopeR.close(); + listener.assertNewEvents("update:{root}->C", "update:C->D", "release:C"); + assertEquals(contextD, current()); // D is still current + } // scopeD closes here: unwind D normally, restores C + listener.assertNewEvents("update:D->C"); + } // try-with-resources closes scopeR again; no second release, C unwinds to root - @Test - void testHoldWithOutOfOrderScopeCloseFiresReleaseOnExplicitRelease() { - // Regression test: hold() + out-of-order close must not corrupt the count, - // which would cause release() to silently no-op and lose the release event. - Context contextC = root().with(TEST_KEY, "C"); - ContextContinuation continuation; - try (ContextScope scope = contextC.attach()) { - continuation = contextC.capture(); - continuation.hold(); + assertEquals(root(), current()); + listener.assertNewEvents("update:C->{root}"); } - TrackingListener listener = trackingListener(); - ContextManager.register(listener); + @Test + void testHoldWithOutOfOrderScopeCloseFiresReleaseOnExplicitRelease() { + // Regression test: hold() + out-of-order close must not corrupt the count, + // which would cause release() to silently no-op and lose the release event. + Context contextC = root().with(TEST_KEY, "C"); + ContextContinuation continuation; + try (ContextScope scope = contextC.attach()) { + continuation = contextC.capture(); + continuation.hold(); + } - Context contextD = root().with(TEST_KEY, "D"); - try (ContextScope scopeR = continuation.resume()) { - assertEquals(contextC, current()); - try (ContextScope scopeD = contextD.attach()) { // attaching D fires update:C->D - assertEquals(contextD, current()); + TrackingListener listener = trackingListener(); + ContextManager.register(listener); - scopeR.close(); // out-of-order close while D is still on top; hold prevents auto-release - listener.assertNewEvents("update:{root}->C", "update:C->D"); - assertEquals(contextD, current()); - } // scopeD closes here: unwind D, restores C - } // TWR closes scopeR again (now in-order); update:C->{root}, no release yet (hold is active) + Context contextD = root().with(TEST_KEY, "D"); + try (ContextScope scopeR = continuation.resume()) { + assertEquals(contextC, current()); + try (ContextScope scopeD = contextD.attach()) { // attaching D fires update:C->D + assertEquals(contextD, current()); - assertEquals(root(), current()); - listener.assertNewEvents("update:D->C", "update:C->{root}"); + scopeR.close(); // out-of-order close while D is still on top; hold prevents auto-release + listener.assertNewEvents("update:{root}->C", "update:C->D"); + assertEquals(contextD, current()); + } // scopeD closes here: unwind D, restores C + } // TWR closes scopeR again (now in-order); update:C->{root}, no release yet (hold is active) - continuation.release(); // explicit release must fire release:C - listener.assertNewEvents("release:C"); - } + assertEquals(root(), current()); + listener.assertNewEvents("update:D->C", "update:C->{root}"); - @Test - void testMultipleHoldCallsAreIdempotent() { - // Calling hold() more than once should not require more than one explicit release(). - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); - continuation.hold(); - continuation.hold(); // second hold must be a no-op + continuation.release(); // explicit release must fire release:C + listener.assertNewEvents("release:C"); } - // One explicit release() is enough — no extra releases needed for the second hold(). - continuation.release(); - listener.assertNewEvents( - "update:{root}->value", "capture:value", "update:value->{root}", "release:value"); - continuation.release(); // still idempotent after the final release - listener.assertNoNewEvents(); - } - @Test - void testHoldAfterReleaseIsIgnored() { - // hold() on an already-released continuation must not resurrect it. - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); + @Test + void testMultipleHoldCallsAreIdempotent() { + // Calling hold() more than once should not require more than one explicit release(). + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); + continuation.hold(); + continuation.hold(); // second hold must be a no-op + } + // One explicit release() is enough — no extra releases needed for the second hold(). + continuation.release(); + listener.assertNewEvents("update:{root}->value", "capture:value", "update:value->{root}", "release:value"); + continuation.release(); // still idempotent after the final release + listener.assertNoNewEvents(); } - continuation.release(); - listener.assertNewEvents( - "update:{root}->value", "capture:value", "update:value->{root}", "release:value"); - continuation.hold(); // must be silently ignored - // resume() after release is already a noop, even with the spurious hold() - try (ContextScope scope = continuation.resume()) { - assertEquals(root(), current()); + + @Test + void testHoldAfterReleaseIsIgnored() { + // hold() on an already-released continuation must not resurrect it. + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); + } + continuation.release(); + listener.assertNewEvents("update:{root}->value", "capture:value", "update:value->{root}", "release:value"); + continuation.hold(); // must be silently ignored + // resume() after release is already a noop, even with the spurious hold() + try (ContextScope scope = continuation.resume()) { + assertEquals(root(), current()); + } + continuation.release(); // must not fire a second release event + listener.assertNoNewEvents(); } - continuation.release(); // must not fire a second release event - listener.assertNoNewEvents(); - } - @Test - void testHoldAllowsMultipleReleaseCalls() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - ContextContinuation continuation; - try (ContextScope scope = context.attach()) { - continuation = context.capture(); // capture while active - continuation.hold(); + @Test + void testHoldAllowsMultipleReleaseCalls() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + ContextContinuation continuation; + try (ContextScope scope = context.attach()) { + continuation = context.capture(); // capture while active + continuation.hold(); + } + continuation.release(); + listener.assertNewEvents("update:{root}->value", "capture:value", "update:value->{root}", "release:value"); + continuation.release(); // second release is a no-op + listener.assertNoNewEvents(); } - continuation.release(); - listener.assertNewEvents( - "update:{root}->value", "capture:value", "update:value->{root}", "release:value"); - continuation.release(); // second release is a no-op - listener.assertNoNewEvents(); - } } diff --git a/components/context/src/test/java/datadog/context/ContextHelpersTest.java b/components/context/src/test/java/datadog/context/ContextHelpersTest.java index de2f1610a87..dc4de233707 100644 --- a/components/context/src/test/java/datadog/context/ContextHelpersTest.java +++ b/components/context/src/test/java/datadog/context/ContextHelpersTest.java @@ -34,206 +34,194 @@ import org.junit.jupiter.params.provider.MethodSource; class ContextHelpersTest { - private static final Object CARRIER_1 = new Object(); - private static final Object CARRIER_2 = new Object(); - private static final Object UNSET_CARRIER = new Object(); - private static final Object NON_CARRIER = new Object(); - private static final String VALUE_1 = "value1"; - private static final String VALUE_2 = "value2"; - - @BeforeAll - static void init() { - Context context1 = root().with(STRING_KEY, VALUE_1); - context1.attachTo(CARRIER_1); - - Context context2 = root().with(STRING_KEY, VALUE_2); - context2.attachTo(CARRIER_2); - - root().attachTo(UNSET_CARRIER); - } - - @ParameterizedTest - @MethodSource("findFirstArguments") - void testFindFirst(Object[] carriers, String expected) { - assertEquals(expected, findFirst(STRING_KEY, carriers), "Cannot find first value"); - } - - static Stream findFirstArguments() { - return Stream.of( - arguments(emptyArray(), null), - arguments(arrayOf(NON_CARRIER), null), - arguments(arrayOf(UNSET_CARRIER), null), - arguments(arrayOf(CARRIER_1), VALUE_1), - arguments(arrayOf(CARRIER_1, CARRIER_2), VALUE_1), - arguments(arrayOf(NON_CARRIER, CARRIER_1), VALUE_1), - arguments(arrayOf(UNSET_CARRIER, CARRIER_1), VALUE_1), - arguments(arrayOf(CARRIER_1, NON_CARRIER), VALUE_1), - arguments(arrayOf(CARRIER_1, UNSET_CARRIER), VALUE_1)); - } - - @ParameterizedTest - @MethodSource("findAllArguments") - void testFindAll(Object[] carriers, Iterable expected) { - assertIterableEquals(expected, findAll(STRING_KEY, carriers), "Cannot find all values"); - } - - static Stream findAllArguments() { - return Stream.of( - arguments(emptyArray(), emptyList()), - arguments(arrayOf(CARRIER_1), singleton(VALUE_1)), - arguments(arrayOf(CARRIER_1, CARRIER_2), asList(VALUE_1, VALUE_2)), - arguments(arrayOf(NON_CARRIER, CARRIER_1), singleton(VALUE_1)), - arguments(arrayOf(UNSET_CARRIER, CARRIER_1), singleton(VALUE_1)), - arguments(arrayOf(CARRIER_1, NON_CARRIER), singleton(VALUE_1)), - arguments(arrayOf(CARRIER_1, UNSET_CARRIER), singleton(VALUE_1))); - } - - @Test - void testNullCarriers() { - assertThrows( - NullPointerException.class, () -> findFirst(null, CARRIER_1), "Should fail on null key"); - assertThrows( - NullPointerException.class, - () -> findFirst(STRING_KEY, (Object) null), - "Should fail on null context"); - assertThrows( - NullPointerException.class, - () -> findFirst(STRING_KEY, null, CARRIER_1), - "Should fail on null context"); - assertThrows( - NullPointerException.class, () -> findAll(null, CARRIER_1), "Should fail on null key"); - assertThrows( - NullPointerException.class, - () -> findAll(STRING_KEY, (Object) null), - "Should fail on null context"); - assertThrows( - NullPointerException.class, - () -> findAll(STRING_KEY, null, CARRIER_1), - "Should fail on null context"); - } - - @Test - void testCurrent() { - assertEquals(root(), current(), "Current context is already set"); - Context context = root().with(STRING_KEY, VALUE_1); - try (ContextScope ignored = context.attach()) { - assertEquals( - VALUE_1, findFirst(STRING_KEY, CURRENT), "Failed to get value from current context"); - assertIterableEquals( - singleton(VALUE_1), - findAll(STRING_KEY, CURRENT), - "Failed to get value from current context"); + private static final Object CARRIER_1 = new Object(); + private static final Object CARRIER_2 = new Object(); + private static final Object UNSET_CARRIER = new Object(); + private static final Object NON_CARRIER = new Object(); + private static final String VALUE_1 = "value1"; + private static final String VALUE_2 = "value2"; + + @BeforeAll + static void init() { + Context context1 = root().with(STRING_KEY, VALUE_1); + context1.attachTo(CARRIER_1); + + Context context2 = root().with(STRING_KEY, VALUE_2); + context2.attachTo(CARRIER_2); + + root().attachTo(UNSET_CARRIER); } - assertEquals(root(), current(), "Current context stayed attached"); - } - - @Test - void testCombine() { - // Test general case - Context context1 = root().with(STRING_KEY, VALUE_1).with(BOOLEAN_KEY, true); - Context context2 = root().with(STRING_KEY, VALUE_2).with(FLOAT_KEY, 3.14F); - Context context3 = root(); - Context context4 = root().with(FLOAT_KEY, 567F); - - Context combined = combine(context1, context2, context3, context4); - assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); - assertEquals(true, combined.get(BOOLEAN_KEY), "Values from first context should be kept"); - assertEquals(3.14F, combined.get(FLOAT_KEY), "Values from second context should be kept"); - - // Test SingletonContext optimization - context1 = root().with(STRING_KEY, VALUE_1); - context2 = root().with(STRING_KEY, VALUE_2); - combined = combine(context1, context2); - assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); - - // Test IndexedContext optimization where later context has more elements - context1 = root().with(STRING_KEY, VALUE_1).with(FLOAT_KEY, 3.14F); - context2 = root().with(STRING_KEY, VALUE_2).with(LONG_KEY, 567L); - combined = combine(context1, context2); - assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); - assertEquals(3.14F, combined.get(FLOAT_KEY), "Values from first context should be kept"); - assertEquals(567L, combined.get(LONG_KEY), "Values from first context should be kept"); - - // Test IndexedContext optimization where context has same size but only later context has value - context1 = root().with(LONG_KEY, 567L).with(STRING_KEY, VALUE_1); - context2 = root().with(LONG_KEY, 789L).with(FLOAT_KEY, 3.14F); - combined = combine(context1, context2); - assertEquals(567L, combined.get(LONG_KEY), "First duplicate value should be kept"); - assertEquals(VALUE_1, combined.get(STRING_KEY), "Values from first context should be kept"); - assertEquals(3.14F, combined.get(FLOAT_KEY), "Values from first context should be kept"); - - // Test IndexedContext optimization with same size context and no new values - context1 = root().with(STRING_KEY, VALUE_1).with(LONG_KEY, 567L); - context2 = root().with(STRING_KEY, VALUE_2).with(LONG_KEY, 789L); - combined = combine(context1, context2); - assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); - assertEquals(567L, combined.get(LONG_KEY), "First duplicate value should be kept"); - } - - @Test - void testCombiner() { - ContextKey errorKey = ContextKey.named("error"); - Context context1 = root().with(errorKey, ErrorStats.from(INFO, 12)).with(STRING_KEY, VALUE_1); - Context context2 = root().with(errorKey, ErrorStats.from(SEVERE, 1)).with(FLOAT_KEY, 3.14F); - Context context3 = root().with(errorKey, ErrorStats.from(WARNING, 6)).with(BOOLEAN_KEY, true); - - BinaryOperator errorStatsMerger = - (left, right) -> { - ErrorStats mergedStats = ErrorStats.merge(left.get(errorKey), right.get(errorKey)); - return left.with(errorKey, mergedStats); + + @ParameterizedTest + @MethodSource("findFirstArguments") + void testFindFirst(Object[] carriers, String expected) { + assertEquals(expected, findFirst(STRING_KEY, carriers), "Cannot find first value"); + } + + static Stream findFirstArguments() { + return Stream.of( + arguments(emptyArray(), null), + arguments(arrayOf(NON_CARRIER), null), + arguments(arrayOf(UNSET_CARRIER), null), + arguments(arrayOf(CARRIER_1), VALUE_1), + arguments(arrayOf(CARRIER_1, CARRIER_2), VALUE_1), + arguments(arrayOf(NON_CARRIER, CARRIER_1), VALUE_1), + arguments(arrayOf(UNSET_CARRIER, CARRIER_1), VALUE_1), + arguments(arrayOf(CARRIER_1, NON_CARRIER), VALUE_1), + arguments(arrayOf(CARRIER_1, UNSET_CARRIER), VALUE_1)); + } + + @ParameterizedTest + @MethodSource("findAllArguments") + void testFindAll(Object[] carriers, Iterable expected) { + assertIterableEquals(expected, findAll(STRING_KEY, carriers), "Cannot find all values"); + } + + static Stream findAllArguments() { + return Stream.of( + arguments(emptyArray(), emptyList()), + arguments(arrayOf(CARRIER_1), singleton(VALUE_1)), + arguments(arrayOf(CARRIER_1, CARRIER_2), asList(VALUE_1, VALUE_2)), + arguments(arrayOf(NON_CARRIER, CARRIER_1), singleton(VALUE_1)), + arguments(arrayOf(UNSET_CARRIER, CARRIER_1), singleton(VALUE_1)), + arguments(arrayOf(CARRIER_1, NON_CARRIER), singleton(VALUE_1)), + arguments(arrayOf(CARRIER_1, UNSET_CARRIER), singleton(VALUE_1))); + } + + @Test + void testNullCarriers() { + assertThrows(NullPointerException.class, () -> findFirst(null, CARRIER_1), "Should fail on null key"); + assertThrows( + NullPointerException.class, () -> findFirst(STRING_KEY, (Object) null), "Should fail on null context"); + assertThrows( + NullPointerException.class, + () -> findFirst(STRING_KEY, null, CARRIER_1), + "Should fail on null context"); + assertThrows(NullPointerException.class, () -> findAll(null, CARRIER_1), "Should fail on null key"); + assertThrows( + NullPointerException.class, () -> findAll(STRING_KEY, (Object) null), "Should fail on null context"); + assertThrows( + NullPointerException.class, () -> findAll(STRING_KEY, null, CARRIER_1), "Should fail on null context"); + } + + @Test + void testCurrent() { + assertEquals(root(), current(), "Current context is already set"); + Context context = root().with(STRING_KEY, VALUE_1); + try (ContextScope ignored = context.attach()) { + assertEquals(VALUE_1, findFirst(STRING_KEY, CURRENT), "Failed to get value from current context"); + assertIterableEquals( + singleton(VALUE_1), findAll(STRING_KEY, CURRENT), "Failed to get value from current context"); + } + assertEquals(root(), current(), "Current context stayed attached"); + } + + @Test + void testCombine() { + // Test general case + Context context1 = root().with(STRING_KEY, VALUE_1).with(BOOLEAN_KEY, true); + Context context2 = root().with(STRING_KEY, VALUE_2).with(FLOAT_KEY, 3.14F); + Context context3 = root(); + Context context4 = root().with(FLOAT_KEY, 567F); + + Context combined = combine(context1, context2, context3, context4); + assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); + assertEquals(true, combined.get(BOOLEAN_KEY), "Values from first context should be kept"); + assertEquals(3.14F, combined.get(FLOAT_KEY), "Values from second context should be kept"); + + // Test SingletonContext optimization + context1 = root().with(STRING_KEY, VALUE_1); + context2 = root().with(STRING_KEY, VALUE_2); + combined = combine(context1, context2); + assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); + + // Test IndexedContext optimization where later context has more elements + context1 = root().with(STRING_KEY, VALUE_1).with(FLOAT_KEY, 3.14F); + context2 = root().with(STRING_KEY, VALUE_2).with(LONG_KEY, 567L); + combined = combine(context1, context2); + assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); + assertEquals(3.14F, combined.get(FLOAT_KEY), "Values from first context should be kept"); + assertEquals(567L, combined.get(LONG_KEY), "Values from first context should be kept"); + + // Test IndexedContext optimization where context has same size but only later context has value + context1 = root().with(LONG_KEY, 567L).with(STRING_KEY, VALUE_1); + context2 = root().with(LONG_KEY, 789L).with(FLOAT_KEY, 3.14F); + combined = combine(context1, context2); + assertEquals(567L, combined.get(LONG_KEY), "First duplicate value should be kept"); + assertEquals(VALUE_1, combined.get(STRING_KEY), "Values from first context should be kept"); + assertEquals(3.14F, combined.get(FLOAT_KEY), "Values from first context should be kept"); + + // Test IndexedContext optimization with same size context and no new values + context1 = root().with(STRING_KEY, VALUE_1).with(LONG_KEY, 567L); + context2 = root().with(STRING_KEY, VALUE_2).with(LONG_KEY, 789L); + combined = combine(context1, context2); + assertEquals(VALUE_1, combined.get(STRING_KEY), "First duplicate value should be kept"); + assertEquals(567L, combined.get(LONG_KEY), "First duplicate value should be kept"); + } + + @Test + void testCombiner() { + ContextKey errorKey = ContextKey.named("error"); + Context context1 = root().with(errorKey, ErrorStats.from(INFO, 12)).with(STRING_KEY, VALUE_1); + Context context2 = root().with(errorKey, ErrorStats.from(SEVERE, 1)).with(FLOAT_KEY, 3.14F); + Context context3 = root().with(errorKey, ErrorStats.from(WARNING, 6)).with(BOOLEAN_KEY, true); + + BinaryOperator errorStatsMerger = (left, right) -> { + ErrorStats mergedStats = ErrorStats.merge(left.get(errorKey), right.get(errorKey)); + return left.with(errorKey, mergedStats); }; - Context combined = combine(errorStatsMerger, context1, context2, context3); - ErrorStats combinedStats = combined.get(errorKey); - assertNotNull(combinedStats, "Failed to combined error stats"); - assertEquals(19, combinedStats.errorCount, "Failed to combine error stats"); - assertEquals(SEVERE, combinedStats.maxLevel, "Failed to combine error stats"); - assertNull(combined.get(STRING_KEY), "Combiner should drop any other context values"); - assertNull(combined.get(FLOAT_KEY), "Combiner should drop any other context values"); - assertNull(combined.get(BOOLEAN_KEY), "Combiner should drop any other context values"); - } - - @Test - void testNullCombine() { - assertThrows( - NullPointerException.class, - () -> combine((BinaryOperator) null, root()), - "Should fail on null combiner"); - assertThrows( - NullPointerException.class, - () -> combine((left, right) -> left, (Context) null), - "Should fail on null context"); - } - - private static class ErrorStats { - int errorCount; - Level maxLevel; - - public ErrorStats() { - this.errorCount = 0; - this.maxLevel = ALL; + Context combined = combine(errorStatsMerger, context1, context2, context3); + ErrorStats combinedStats = combined.get(errorKey); + assertNotNull(combinedStats, "Failed to combined error stats"); + assertEquals(19, combinedStats.errorCount, "Failed to combine error stats"); + assertEquals(SEVERE, combinedStats.maxLevel, "Failed to combine error stats"); + assertNull(combined.get(STRING_KEY), "Combiner should drop any other context values"); + assertNull(combined.get(FLOAT_KEY), "Combiner should drop any other context values"); + assertNull(combined.get(BOOLEAN_KEY), "Combiner should drop any other context values"); } - public static ErrorStats from(Level logLevel, int count) { - ErrorStats stats = new ErrorStats(); - stats.errorCount = count; - stats.maxLevel = logLevel; - return stats; + @Test + void testNullCombine() { + assertThrows( + NullPointerException.class, + () -> combine((BinaryOperator) null, root()), + "Should fail on null combiner"); + assertThrows( + NullPointerException.class, + () -> combine((left, right) -> left, (Context) null), + "Should fail on null context"); } - public static ErrorStats merge(ErrorStats a, ErrorStats b) { - if (a == null) { - return b; - } - Level maxLevel = a.maxLevel.intValue() > b.maxLevel.intValue() ? a.maxLevel : b.maxLevel; - return from(maxLevel, a.errorCount + b.errorCount); + private static class ErrorStats { + int errorCount; + Level maxLevel; + + public ErrorStats() { + this.errorCount = 0; + this.maxLevel = ALL; + } + + public static ErrorStats from(Level logLevel, int count) { + ErrorStats stats = new ErrorStats(); + stats.errorCount = count; + stats.maxLevel = logLevel; + return stats; + } + + public static ErrorStats merge(ErrorStats a, ErrorStats b) { + if (a == null) { + return b; + } + Level maxLevel = a.maxLevel.intValue() > b.maxLevel.intValue() ? a.maxLevel : b.maxLevel; + return from(maxLevel, a.errorCount + b.errorCount); + } } - } - private static Object[] emptyArray() { - return new Object[0]; - } + private static Object[] emptyArray() { + return new Object[0]; + } - private static Object[] arrayOf(Object... objects) { - return objects; - } + private static Object[] arrayOf(Object... objects) { + return objects; + } } diff --git a/components/context/src/test/java/datadog/context/ContextKeyTest.java b/components/context/src/test/java/datadog/context/ContextKeyTest.java index 3ff674145da..75f81ee3216 100644 --- a/components/context/src/test/java/datadog/context/ContextKeyTest.java +++ b/components/context/src/test/java/datadog/context/ContextKeyTest.java @@ -13,48 +13,41 @@ import org.junit.jupiter.params.provider.ValueSource; class ContextKeyTest { - @ParameterizedTest - @NullAndEmptySource - @ValueSource(strings = {"key"}) - void testConstructor(String name) { - ContextKey key = ContextKey.named(name); - assertNotNull(key, "created key should not be null"); - assertEquals(name, key.toString(), name + " label should be supported"); - } + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"key"}) + void testConstructor(String name) { + ContextKey key = ContextKey.named(name); + assertNotNull(key, "created key should not be null"); + assertEquals(name, key.toString(), name + " label should be supported"); + } - @Test - void testKeyNameCollision() { - ContextKey key1 = ContextKey.named("same-name"); - ContextKey key2 = ContextKey.named("same-name"); - assertNotEquals(key1, key2, "distinct keys should not be equal"); - String value = "value"; - Context context = Context.root().with(key1, value); - assertEquals(value, context.get(key1), "the original key should be able to retrieve the value"); - assertNull(context.get(key2), "the distinct keys should not be able to retrieve the value"); - } + @Test + void testKeyNameCollision() { + ContextKey key1 = ContextKey.named("same-name"); + ContextKey key2 = ContextKey.named("same-name"); + assertNotEquals(key1, key2, "distinct keys should not be equal"); + String value = "value"; + Context context = Context.root().with(key1, value); + assertEquals(value, context.get(key1), "the original key should be able to retrieve the value"); + assertNull(context.get(key2), "the distinct keys should not be able to retrieve the value"); + } - @SuppressWarnings({ - "EqualsWithItself", - "SimplifiableAssertion", - "ConstantValue", - "EqualsBetweenInconvertibleTypes" - }) - @Test - void testEqualsAndHashCode() { - ContextKey key1 = ContextKey.named("some-name"); - ContextKey key2 = ContextKey.named("some-name"); - // Test equals on self - assertTrue(key1.equals(key1), "Key should be equal with itself"); - assertEquals(key1.hashCode(), key1.hashCode(), "key hash should be constant"); - // Test equals on null - assertFalse(key1.equals(null), "key should not be equal to null value"); - // Test equals on different object type - assertFalse(key1.equals("value"), "key should not be equal to a different type"); - // Test equals on different keys with the same name - assertFalse(key1.equals(key2), "different keys with the same name should not be equal"); - assertNotEquals( - key1.hashCode(), - key2.hashCode(), - "different keys with the same name should have the same hash"); - } + @SuppressWarnings({"EqualsWithItself", "SimplifiableAssertion", "ConstantValue", "EqualsBetweenInconvertibleTypes"}) + @Test + void testEqualsAndHashCode() { + ContextKey key1 = ContextKey.named("some-name"); + ContextKey key2 = ContextKey.named("some-name"); + // Test equals on self + assertTrue(key1.equals(key1), "Key should be equal with itself"); + assertEquals(key1.hashCode(), key1.hashCode(), "key hash should be constant"); + // Test equals on null + assertFalse(key1.equals(null), "key should not be equal to null value"); + // Test equals on different object type + assertFalse(key1.equals("value"), "key should not be equal to a different type"); + // Test equals on different keys with the same name + assertFalse(key1.equals(key2), "different keys with the same name should not be equal"); + assertNotEquals( + key1.hashCode(), key2.hashCode(), "different keys with the same name should have the same hash"); + } } diff --git a/components/context/src/test/java/datadog/context/ContextListenerEventTest.java b/components/context/src/test/java/datadog/context/ContextListenerEventTest.java index db71cc8438f..54500896a93 100644 --- a/components/context/src/test/java/datadog/context/ContextListenerEventTest.java +++ b/components/context/src/test/java/datadog/context/ContextListenerEventTest.java @@ -8,91 +8,91 @@ import org.junit.jupiter.api.Test; class ContextListenerEventTest extends ContextTestBase { - @Test - void testListenersNotifiedOnAttachAndDetach() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - try (ContextScope scope = context.attach()) { - listener.assertNewEvents("update:{root}->value"); + @Test + void testListenersNotifiedOnAttachAndDetach() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + try (ContextScope scope = context.attach()) { + listener.assertNewEvents("update:{root}->value"); + } + listener.assertNewEvents("update:value->{root}"); } - listener.assertNewEvents("update:value->{root}"); - } - @Test - void testListenersNotNotifiedForSameContextAttachOrSwap() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - root().attach(); // current is already root, no events - listener.assertNoEvents(); - root().swap(); // current is already root, no events - listener.assertNoEvents(); - Context context = root().with(TEST_KEY, "value"); - try (ContextScope scope = context.attach()) { - listener.assertNewEvents("update:{root}->value"); + @Test + void testListenersNotNotifiedForSameContextAttachOrSwap() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + root().attach(); // current is already root, no events + listener.assertNoEvents(); + root().swap(); // current is already root, no events + listener.assertNoEvents(); + Context context = root().with(TEST_KEY, "value"); + try (ContextScope scope = context.attach()) { + listener.assertNewEvents("update:{root}->value"); + } + listener.assertNewEvents("update:value->{root}"); } - listener.assertNewEvents("update:value->{root}"); - } - @Test - void testListenersNotNotifiedOnSameContextAttach() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "same"); - try (ContextScope outer = context.attach()) { - listener.assertNewEvents("update:{root}->same"); - try (ContextScope noop = context.attach()) { - assertEquals(context, current()); - listener.assertNoNewEvents(); // no new events on same-context attach - } - listener.assertNoNewEvents(); // noop close fires no events either + @Test + void testListenersNotNotifiedOnSameContextAttach() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "same"); + try (ContextScope outer = context.attach()) { + listener.assertNewEvents("update:{root}->same"); + try (ContextScope noop = context.attach()) { + assertEquals(context, current()); + listener.assertNoNewEvents(); // no new events on same-context attach + } + listener.assertNoNewEvents(); // noop close fires no events either + } + listener.assertNewEvents("update:same->{root}"); } - listener.assertNewEvents("update:same->{root}"); - } - @Test - void testListenersNotNotifiedOnSameContextSwap() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "same"); - context.swap(); - listener.assertNewEvents("update:{root}->same"); - context.swap(); // same context again, no events - listener.assertNoNewEvents(); - root().swap(); - listener.assertNewEvents("update:same->{root}"); - } + @Test + void testListenersNotNotifiedOnSameContextSwap() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "same"); + context.swap(); + listener.assertNewEvents("update:{root}->same"); + context.swap(); // same context again, no events + listener.assertNoNewEvents(); + root().swap(); + listener.assertNewEvents("update:same->{root}"); + } - @Test - void testDuplicateListenerIgnored() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - ContextManager.register(listener); // should be ignored - try (ContextScope scope = root().with(TEST_KEY, "value").attach()) {} - listener.assertEvents("update:{root}->value", "update:value->{root}"); - } + @Test + void testDuplicateListenerIgnored() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + ContextManager.register(listener); // should be ignored + try (ContextScope scope = root().with(TEST_KEY, "value").attach()) {} + listener.assertEvents("update:{root}->value", "update:value->{root}"); + } - @Test - void testMultipleListenersAllNotified() { - TrackingListener listener1 = trackingListener(); - TrackingListener listener2 = trackingListener(); - ContextManager.register(listener1); - ContextManager.register(listener2); - try (ContextScope scope = root().with(TEST_KEY, "value").attach()) {} - listener1.assertEvents("update:{root}->value", "update:value->{root}"); - listener2.assertEvents("update:{root}->value", "update:value->{root}"); - } + @Test + void testMultipleListenersAllNotified() { + TrackingListener listener1 = trackingListener(); + TrackingListener listener2 = trackingListener(); + ContextManager.register(listener1); + ContextManager.register(listener2); + try (ContextScope scope = root().with(TEST_KEY, "value").attach()) {} + listener1.assertEvents("update:{root}->value", "update:value->{root}"); + listener2.assertEvents("update:{root}->value", "update:value->{root}"); + } - @Test - void testSwapNotifiesListeners() { - TrackingListener listener = trackingListener(); - ContextManager.register(listener); - Context context = root().with(TEST_KEY, "value"); - Context previous = context.swap(); - assertSame(root(), previous); - listener.assertNewEvents("update:{root}->value"); - previous = root().swap(); - assertSame(context, previous); - listener.assertNewEvents("update:value->{root}"); - } + @Test + void testSwapNotifiesListeners() { + TrackingListener listener = trackingListener(); + ContextManager.register(listener); + Context context = root().with(TEST_KEY, "value"); + Context previous = context.swap(); + assertSame(root(), previous); + listener.assertNewEvents("update:{root}->value"); + previous = root().swap(); + assertSame(context, previous); + listener.assertNewEvents("update:value->{root}"); + } } diff --git a/components/context/src/test/java/datadog/context/ContextListenerExceptionTest.java b/components/context/src/test/java/datadog/context/ContextListenerExceptionTest.java index 160873e7103..ddd113f5087 100644 --- a/components/context/src/test/java/datadog/context/ContextListenerExceptionTest.java +++ b/components/context/src/test/java/datadog/context/ContextListenerExceptionTest.java @@ -11,58 +11,53 @@ @ParametersAreNonnullByDefault class ContextListenerExceptionTest extends ContextTestBase { - @Test - void testListenerExceptionSwallowed() { - ContextManager.register( - new ContextListener() { - @Override - public void onUpdate(Context before, Context after) { - throw new RuntimeException("listener failure"); - } + @Test + void testListenerExceptionSwallowed() { + ContextManager.register(new ContextListener() { + @Override + public void onUpdate(Context before, Context after) { + throw new RuntimeException("listener failure"); + } }); - Context context = root().with(TEST_KEY, "value"); - assertDoesNotThrow( - () -> { - try (ContextScope scope = context.attach()) { - assertEquals(context, current()); - } + Context context = root().with(TEST_KEY, "value"); + assertDoesNotThrow(() -> { + try (ContextScope scope = context.attach()) { + assertEquals(context, current()); + } }); - } + } - @Test - void testListenerExceptionSwallowedOnCapture() { - ContextManager.register( - new ContextListener() { - @Override - public void onCapture(Context c) { - throw new RuntimeException("listener failure on capture"); - } + @Test + void testListenerExceptionSwallowedOnCapture() { + ContextManager.register(new ContextListener() { + @Override + public void onCapture(Context c) { + throw new RuntimeException("listener failure on capture"); + } }); - Context context = root().with(TEST_KEY, "value"); - try (ContextScope scope = context.attach()) { - assertDoesNotThrow( - () -> { - ContextContinuation continuation = context.capture(); - assertNotNull(continuation); - assertEquals(context, continuation.context()); - continuation.release(); - }); + Context context = root().with(TEST_KEY, "value"); + try (ContextScope scope = context.attach()) { + assertDoesNotThrow(() -> { + ContextContinuation continuation = context.capture(); + assertNotNull(continuation); + assertEquals(context, continuation.context()); + continuation.release(); + }); + } } - } - @Test - void testListenerExceptionSwallowedOnRelease() { - ContextManager.register( - new ContextListener() { - @Override - public void onRelease(Context c) { - throw new RuntimeException("listener failure on release"); - } + @Test + void testListenerExceptionSwallowedOnRelease() { + ContextManager.register(new ContextListener() { + @Override + public void onRelease(Context c) { + throw new RuntimeException("listener failure on release"); + } }); - Context context = root().with(TEST_KEY, "value"); - try (ContextScope scope = context.attach()) { - ContextContinuation continuation = context.capture(); - assertDoesNotThrow(continuation::release); + Context context = root().with(TEST_KEY, "value"); + try (ContextScope scope = context.attach()) { + ContextContinuation continuation = context.capture(); + assertDoesNotThrow(continuation::release); + } } - } } diff --git a/components/context/src/test/java/datadog/context/ContextManagerTest.java b/components/context/src/test/java/datadog/context/ContextManagerTest.java index 789f6fc6eaf..4dc011e8185 100644 --- a/components/context/src/test/java/datadog/context/ContextManagerTest.java +++ b/components/context/src/test/java/datadog/context/ContextManagerTest.java @@ -13,176 +13,172 @@ import org.junit.jupiter.api.Test; class ContextManagerTest extends ContextTestBase { - @Test - void testContextAttachment() { - Context context1 = root().with(STRING_KEY, "value1"); - try (ContextScope scope1 = context1.attach()) { - // Test context1 is attached - assertEquals(context1, current()); - assertEquals(context1, scope1.context()); - Context context2 = context1.with(STRING_KEY, "value2"); - try (ContextScope scope2 = context2.attach()) { + @Test + void testContextAttachment() { + Context context1 = root().with(STRING_KEY, "value1"); + try (ContextScope scope1 = context1.attach()) { + // Test context1 is attached + assertEquals(context1, current()); + assertEquals(context1, scope1.context()); + Context context2 = context1.with(STRING_KEY, "value2"); + try (ContextScope scope2 = context2.attach()) { + // Test context2 is attached + assertEquals(context2, current()); + assertEquals(context2, scope2.context()); + // Can still access context1 from its scope + assertEquals(context1, scope1.context()); + } + // Test context1 is restored + assertEquals(context1, current()); + } + } + + @Test + void testContextSwapping() { + Context context1 = root().with(STRING_KEY, "value1"); + assertEquals(root(), current()); + assertEquals(root(), context1.swap()); + // Test context1 is attached + Context context2 = context1.with(STRING_KEY, "value2"); + assertEquals(context1, current()); + assertEquals(context1, context2.swap()); // Test context2 is attached assertEquals(context2, current()); - assertEquals(context2, scope2.context()); - // Can still access context1 from its scope - assertEquals(context1, scope1.context()); - } - // Test context1 is restored - assertEquals(context1, current()); + assertEquals(context2, root().swap()); + // Test we're now context-less + assertEquals(root(), current()); } - } - - @Test - void testContextSwapping() { - Context context1 = root().with(STRING_KEY, "value1"); - assertEquals(root(), current()); - assertEquals(root(), context1.swap()); - // Test context1 is attached - Context context2 = context1.with(STRING_KEY, "value2"); - assertEquals(context1, current()); - assertEquals(context1, context2.swap()); - // Test context2 is attached - assertEquals(context2, current()); - assertEquals(context2, root().swap()); - // Test we're now context-less - assertEquals(root(), current()); - } - @Test - void testNoopScopeContextReturnsAttachedContext() { - Context context = root().with(STRING_KEY, "value"); - try (ContextScope outer = context.attach()) { - // second attach returns a noop scope; verify context() reflects the attached context - try (ContextScope noop = context.attach()) { - assertEquals(context, noop.context()); - } + @Test + void testNoopScopeContextReturnsAttachedContext() { + Context context = root().with(STRING_KEY, "value"); + try (ContextScope outer = context.attach()) { + // second attach returns a noop scope; verify context() reflects the attached context + try (ContextScope noop = context.attach()) { + assertEquals(context, noop.context()); + } + } } - } - @Test - void testNoopScopeReturnsCorrectContext() { - Context context = root().with(STRING_KEY, "value"); - try (ContextScope outer = context.attach()) { - try (ContextScope noop1 = context.attach(); - ContextScope noop2 = context.attach()) { - assertEquals(context, noop1.context()); - assertEquals(context, noop2.context()); - } + @Test + void testNoopScopeReturnsCorrectContext() { + Context context = root().with(STRING_KEY, "value"); + try (ContextScope outer = context.attach()) { + try (ContextScope noop1 = context.attach(); + ContextScope noop2 = context.attach()) { + assertEquals(context, noop1.context()); + assertEquals(context, noop2.context()); + } + } } - } - @Test - void testNoopScopeCorrectContextAcrossManyContexts() { - for (int i = 0; i < 200; i++) { - Context ctx = root().with(STRING_KEY, "ctx-" + i); - try (ContextScope outer = ctx.attach()) { - try (ContextScope noop = ctx.attach()) { - assertEquals(ctx, noop.context()); + @Test + void testNoopScopeCorrectContextAcrossManyContexts() { + for (int i = 0; i < 200; i++) { + Context ctx = root().with(STRING_KEY, "ctx-" + i); + try (ContextScope outer = ctx.attach()) { + try (ContextScope noop = ctx.attach()) { + assertEquals(ctx, noop.context()); + } + } } - } } - } - @Test - void testAttachSameContextMultipleTimes() { - Context context = root().with(STRING_KEY, "value1"); - try (ContextScope scope1 = context.attach()) { - assertEquals(context, current()); - // re-attaching an already-active context returns a noop scope - try (ContextScope noop2 = context.attach()) { - assertEquals(context, noop2.context()); - try (ContextScope noop3 = context.attach()) { - assertEquals(context, noop3.context()); + @Test + void testAttachSameContextMultipleTimes() { + Context context = root().with(STRING_KEY, "value1"); + try (ContextScope scope1 = context.attach()) { + assertEquals(context, current()); + // re-attaching an already-active context returns a noop scope + try (ContextScope noop2 = context.attach()) { + assertEquals(context, noop2.context()); + try (ContextScope noop3 = context.attach()) { + assertEquals(context, noop3.context()); + } + assertEquals(context, current()); // noop close: context remains active + } + assertEquals(context, current()); // still active after all noop closes } - assertEquals(context, current()); // noop close: context remains active - } - assertEquals(context, current()); // still active after all noop closes + assertEquals(root(), current()); // only the original scope deactivates on close } - assertEquals(root(), current()); // only the original scope deactivates on close - } - @Test - void testOnlyCurrentScopeCanBeClosed() { - Context context1 = root().with(STRING_KEY, "value1"); - try (ContextScope scope1 = context1.attach()) { - Context context2 = context1.with(STRING_KEY, "value2"); - try (ContextScope ignored = context2.attach()) { - // Try closing the non-current scope - scope1.close(); - // Test context2 is still attached - assertEquals(context2, current()); - } - // Test context1 is restored - assertEquals(context1, current()); + @Test + void testOnlyCurrentScopeCanBeClosed() { + Context context1 = root().with(STRING_KEY, "value1"); + try (ContextScope scope1 = context1.attach()) { + Context context2 = context1.with(STRING_KEY, "value2"); + try (ContextScope ignored = context2.attach()) { + // Try closing the non-current scope + scope1.close(); + // Test context2 is still attached + assertEquals(context2, current()); + } + // Test context1 is restored + assertEquals(context1, current()); + } } - } - @Test - void testClosingMultipleTimes() { - Context context1 = root().with(STRING_KEY, "value1"); - try (ContextScope ignored = context1.attach()) { - Context context2 = context1.with(STRING_KEY, "value2"); - try (ContextScope scope = context2.attach()) { - // Test current context - assertEquals(context2, current()); - // Test current context deactivation - scope.close(); - assertEquals(context1, current()); - // Test multiple context deactivations don’t change current context - scope.close(); - assertEquals(context1, current()); - } + @Test + void testClosingMultipleTimes() { + Context context1 = root().with(STRING_KEY, "value1"); + try (ContextScope ignored = context1.attach()) { + Context context2 = context1.with(STRING_KEY, "value2"); + try (ContextScope scope = context2.attach()) { + // Test current context + assertEquals(context2, current()); + // Test current context deactivation + scope.close(); + assertEquals(context1, current()); + // Test multiple context deactivations don’t change current context + scope.close(); + assertEquals(context1, current()); + } + } } - } - @Test - void testThreadIndependence() { - /* - * This test has 2 executors in addition to the main thread. - * They are synchronized using a Phaser, and arrived before each assert phase. - * If an assert fails in of one the executor, the executor is "deregister" to unblock the test, - * and the exception is restored at the end of the test using "Future.get()". - */ - ExecutorService executor = Executors.newFixedThreadPool(2); - Phaser phaser = new Phaser(3); - /* - * Create first executor. - */ - Future future1 = - executor.submit( - () -> { - try { + @Test + void testThreadIndependence() { + /* + * This test has 2 executors in addition to the main thread. + * They are synchronized using a Phaser, and arrived before each assert phase. + * If an assert fails in of one the executor, the executor is "deregister" to unblock the test, + * and the exception is restored at the end of the test using "Future.get()". + */ + ExecutorService executor = Executors.newFixedThreadPool(2); + Phaser phaser = new Phaser(3); + /* + * Create first executor. + */ + Future future1 = executor.submit(() -> { + try { // Fist step: check empty context phaser.arriveAndAwaitAdvance(); assertEquals(root(), current()); // Second step: set context on first executor Context context1 = root().with(STRING_KEY, "executor1"); try (ContextScope ignored1 = context1.attach()) { - phaser.arriveAndAwaitAdvance(); - assertEquals(context1, current()); - // Third step: set context on second executor - phaser.arriveAndAwaitAdvance(); - assertEquals(context1, current()); - // Fourth step: set child context on first executor - Context context11 = context1.with(STRING_KEY, "executor1.1"); - try (ContextScope ignored11 = context11.attach()) { phaser.arriveAndAwaitAdvance(); - assertEquals(context11, current()); - } + assertEquals(context1, current()); + // Third step: set context on second executor + phaser.arriveAndAwaitAdvance(); + assertEquals(context1, current()); + // Fourth step: set child context on first executor + Context context11 = context1.with(STRING_KEY, "executor1.1"); + try (ContextScope ignored11 = context11.attach()) { + phaser.arriveAndAwaitAdvance(); + assertEquals(context11, current()); + } } - } finally { + } finally { // Complete the execution phaser.arriveAndDeregister(); - } - }); - /* - * Create second executor. - */ - Future future2 = - executor.submit( - () -> { - try { + } + }); + /* + * Create second executor. + */ + Future future2 = executor.submit(() -> { + try { // First step: check empty context phaser.arriveAndAwaitAdvance(); assertEquals(root(), current()); @@ -192,48 +188,48 @@ void testThreadIndependence() { // Third step: set context on second executor Context context2 = root().with(STRING_KEY, "executor2"); try (ContextScope ignored2 = context2.attach()) { - phaser.arriveAndAwaitAdvance(); - assertEquals(context2, current()); - // Fourth step: set child context on first executor - phaser.arriveAndAwaitAdvance(); - assertEquals(context2, current()); + phaser.arriveAndAwaitAdvance(); + assertEquals(context2, current()); + // Fourth step: set child context on first executor + phaser.arriveAndAwaitAdvance(); + assertEquals(context2, current()); } - } finally { + } finally { // Complete the execution phaser.arriveAndDeregister(); - } - }); - /* - * Run main thread. - */ - // First step: check empty context - phaser.arriveAndAwaitAdvance(); - assertEquals(root(), current()); - // Second step: set context on first executor - phaser.arriveAndAwaitAdvance(); - assertEquals(root(), current()); - // Third step: set context on second executor - phaser.arriveAndAwaitAdvance(); - assertEquals(root(), current()); - // Fourth step: set child context on first executor - phaser.arriveAndAwaitAdvance(); - assertEquals(root(), current()); - // Complete execution and wait for the others - phaser.arriveAndAwaitAdvance(); - executor.shutdown(); - // Check any test error in executors - assertDoesNotThrow(() -> future1.get()); - assertDoesNotThrow(() -> future2.get()); - } + } + }); + /* + * Run main thread. + */ + // First step: check empty context + phaser.arriveAndAwaitAdvance(); + assertEquals(root(), current()); + // Second step: set context on first executor + phaser.arriveAndAwaitAdvance(); + assertEquals(root(), current()); + // Third step: set context on second executor + phaser.arriveAndAwaitAdvance(); + assertEquals(root(), current()); + // Fourth step: set child context on first executor + phaser.arriveAndAwaitAdvance(); + assertEquals(root(), current()); + // Complete execution and wait for the others + phaser.arriveAndAwaitAdvance(); + executor.shutdown(); + // Check any test error in executors + assertDoesNotThrow(() -> future1.get()); + assertDoesNotThrow(() -> future2.get()); + } - @Test - void testNonThreadInheritance() { - Context context = root().with(STRING_KEY, "value"); - try (ContextScope ignored = context.attach()) { - // Check new thread don't inherit from current context - ExecutorService executor = Executors.newSingleThreadExecutor(); - Future future = executor.submit(() -> assertEquals(root(), current())); - assertDoesNotThrow(() -> future.get()); + @Test + void testNonThreadInheritance() { + Context context = root().with(STRING_KEY, "value"); + try (ContextScope ignored = context.attach()) { + // Check new thread don't inherit from current context + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future future = executor.submit(() -> assertEquals(root(), current())); + assertDoesNotThrow(() -> future.get()); + } } - } } diff --git a/components/context/src/test/java/datadog/context/ContextProvidersForkedTest.java b/components/context/src/test/java/datadog/context/ContextProvidersForkedTest.java index 73c55a28c6f..7b7a825411e 100644 --- a/components/context/src/test/java/datadog/context/ContextProvidersForkedTest.java +++ b/components/context/src/test/java/datadog/context/ContextProvidersForkedTest.java @@ -11,116 +11,114 @@ import org.junit.jupiter.api.Test; class ContextProvidersForkedTest { - @Test - void testCustomBinder() { - assertTrue(ContextBinder.allowTesting()); - - Context context = root().with(STRING_KEY, "value"); - assertNotEquals(root(), context); - - Object carrier = new Object(); - - // should delegate to the default binder - context.attachTo(carrier); - assertSame(context, Context.from(carrier)); - assertSame(context, Context.detachFrom(carrier)); - assertSame(root(), Context.from(carrier)); - - // now register a NOOP context binder - ContextBinder.register( - new ContextBinder() { - @Override - public Context from(@Nonnull Object carrier) { - return root(); - } - - @Override - public void attachTo(@Nonnull Object carrier, @Nonnull Context context) { - // no-op - } - - @Override - public Context detachFrom(@Nonnull Object carrier) { - return root(); - } + @Test + void testCustomBinder() { + assertTrue(ContextBinder.allowTesting()); + + Context context = root().with(STRING_KEY, "value"); + assertNotEquals(root(), context); + + Object carrier = new Object(); + + // should delegate to the default binder + context.attachTo(carrier); + assertSame(context, Context.from(carrier)); + assertSame(context, Context.detachFrom(carrier)); + assertSame(root(), Context.from(carrier)); + + // now register a NOOP context binder + ContextBinder.register(new ContextBinder() { + @Override + public Context from(@Nonnull Object carrier) { + return root(); + } + + @Override + public void attachTo(@Nonnull Object carrier, @Nonnull Context context) { + // no-op + } + + @Override + public Context detachFrom(@Nonnull Object carrier) { + return root(); + } }); - // NOOP binder, context will always be root - context.attachTo(carrier); - assertSame(root(), Context.from(carrier)); - assertSame(root(), Context.detachFrom(carrier)); - assertSame(root(), Context.from(carrier)); - } - - @Test - void testCustomManager() { - assertTrue(ContextManager.allowTesting()); - - Context context = root().with(STRING_KEY, "value"); - assertNotEquals(root(), context); - - // should delegate to the default manager - try (ContextScope scope = context.attach()) { - assertSame(context, scope.context()); - assertSame(context, Context.current()); - ContextContinuation cont = context.capture(); - assertSame(context, cont.context()); - cont.release(); + // NOOP binder, context will always be root + context.attachTo(carrier); + assertSame(root(), Context.from(carrier)); + assertSame(root(), Context.detachFrom(carrier)); + assertSame(root(), Context.from(carrier)); } - Context swapped = context.swap(); - assertSame(root(), swapped); - assertSame(context, Context.current()); - assertSame(context, swapped.swap()); - assertSame(root(), Context.current()); - - // now register a NOOP context manager - ContextManager.register( - new ContextManager() { - @Override - public Context current() { - return root(); - } - - @Override - public ContextScope attach(@Nonnull Context context) { - return new NoopContextScope(root()); - } - - @Override - public Context swap(@Nonnull Context context) { - return root(); - } - - @Override - public ContextContinuation capture(@Nonnull Context context) { - return new NoopContextContinuation(root()); - } - - @Override - public void addListener(@Nonnull ContextListener listener) {} + @Test + void testCustomManager() { + assertTrue(ContextManager.allowTesting()); + + Context context = root().with(STRING_KEY, "value"); + assertNotEquals(root(), context); + + // should delegate to the default manager + try (ContextScope scope = context.attach()) { + assertSame(context, scope.context()); + assertSame(context, Context.current()); + ContextContinuation cont = context.capture(); + assertSame(context, cont.context()); + cont.release(); + } + + Context swapped = context.swap(); + assertSame(root(), swapped); + assertSame(context, Context.current()); + assertSame(context, swapped.swap()); + assertSame(root(), Context.current()); + + // now register a NOOP context manager + ContextManager.register(new ContextManager() { + @Override + public Context current() { + return root(); + } + + @Override + public ContextScope attach(@Nonnull Context context) { + return new NoopContextScope(root()); + } + + @Override + public Context swap(@Nonnull Context context) { + return root(); + } + + @Override + public ContextContinuation capture(@Nonnull Context context) { + return new NoopContextContinuation(root()); + } + + @Override + public void addListener(@Nonnull ContextListener listener) {} }); - ContextTestBase.TrackingListener listener = trackingListener(); - ContextManager.register(listener); - - // NOOP manager, context will always be root - try (ContextScope scope = context.attach()) { - assertSame(root(), scope.context()); - assertSame(root(), Context.current()); - ContextContinuation cont = context.capture(); - assertSame(root(), cont.context()); - cont.release(); + ContextTestBase.TrackingListener listener = trackingListener(); + ContextManager.register(listener); + + // NOOP manager, context will always be root + try (ContextScope scope = context.attach()) { + assertSame(root(), scope.context()); + assertSame(root(), Context.current()); + ContextContinuation cont = context.capture(); + assertSame(root(), cont.context()); + cont.release(); + } + + // NOOP manager, context will always be root + swapped = context.swap(); + assertSame(root(), swapped); + assertSame(root(), Context.current()); + assertSame(root(), swapped.swap()); + assertSame(root(), Context.current()); + + // NOOP manager, no events emitted + listener.assertNoEvents(); } - - // NOOP manager, context will always be root - swapped = context.swap(); - assertSame(root(), swapped); - assertSame(root(), Context.current()); - assertSame(root(), swapped.swap()); - assertSame(root(), Context.current()); - - // NOOP manager, no events emitted - listener.assertNoEvents(); - } } diff --git a/components/context/src/test/java/datadog/context/ContextProvidersTest.java b/components/context/src/test/java/datadog/context/ContextProvidersTest.java index 31cff5d3254..037b0ae2901 100644 --- a/components/context/src/test/java/datadog/context/ContextProvidersTest.java +++ b/components/context/src/test/java/datadog/context/ContextProvidersTest.java @@ -8,27 +8,27 @@ import org.junit.jupiter.api.Test; class ContextProvidersTest { - @Test - void testCannotChangeBinderAfterUse() { - Context context = root().with(STRING_KEY, "value"); - Object carrier = new Object(); + @Test + void testCannotChangeBinderAfterUse() { + Context context = root().with(STRING_KEY, "value"); + Object carrier = new Object(); - context.attachTo(carrier); - Context.detachFrom(carrier); + context.attachTo(carrier); + Context.detachFrom(carrier); - // cannot change binder at this late stage - assertFalse(ContextBinder.allowTesting()); - } + // cannot change binder at this late stage + assertFalse(ContextBinder.allowTesting()); + } - @Test - void testCannotChangeManagerAfterUse() { - Context context = root().with(STRING_KEY, "value"); + @Test + void testCannotChangeManagerAfterUse() { + Context context = root().with(STRING_KEY, "value"); - try (ContextScope ignored = context.attach()) { - assertNotEquals(root(), Context.current()); - } + try (ContextScope ignored = context.attach()) { + assertNotEquals(root(), Context.current()); + } - // cannot change manager at this late stage - assertFalse(ContextManager.allowTesting()); - } + // cannot change manager at this late stage + assertFalse(ContextManager.allowTesting()); + } } diff --git a/components/context/src/test/java/datadog/context/ContextTest.java b/components/context/src/test/java/datadog/context/ContextTest.java index d8f50a00df3..692f8df62e2 100644 --- a/components/context/src/test/java/datadog/context/ContextTest.java +++ b/components/context/src/test/java/datadog/context/ContextTest.java @@ -17,202 +17,192 @@ import org.junit.jupiter.params.provider.MethodSource; class ContextTest { - static final ContextKey STRING_KEY = ContextKey.named("string-key"); - static final ContextKey BOOLEAN_KEY = ContextKey.named("boolean-key"); - static final ContextKey FLOAT_KEY = ContextKey.named("float-key"); - static final ContextKey LONG_KEY = ContextKey.named("long-key"); - - @Test - void testRoot() { - // Test root is always the same - Context root = root(); - assertEquals(root, root(), "Root context should be consistent"); - // Test root is not mutated - String stringValue = "value"; - root.with(STRING_KEY, stringValue); - assertEquals(root, root(), "Root context should be immutable"); - } - - static Stream contextImplementations() { - SingletonContext singletonContext = new SingletonContext(ContextKey.named("test").index, true); - IndexedContext indexedContext = new IndexedContext(new Object[0]); - return Stream.of(Context.root(), singletonContext, indexedContext); - } - - @ParameterizedTest - @MethodSource("contextImplementations") - void testWith(Context context) { - // Test accessing non-set value - assertNull(context.get(STRING_KEY)); - // Test retrieving value - String stringValue = "value"; - Context context1 = context.with(STRING_KEY, stringValue); - assertEquals(stringValue, context1.get(STRING_KEY)); - // Test overriding value - String stringValue2 = "value2"; - Context context2 = context1.with(STRING_KEY, stringValue2); - assertEquals(stringValue2, context2.get(STRING_KEY)); - // Test clearing value - Context context3 = context2.with(STRING_KEY, null); - assertNull(context3.get(STRING_KEY)); - // Test null key handling - assertThrows( - NullPointerException.class, () -> context.with(null, "test"), "Context forbids null keys"); - // Test null value handling - assertDoesNotThrow( - () -> context.with(BOOLEAN_KEY, null), "Null value should not throw exception"); - // Test null implicitly keyed value handling - should preserve existing context, not discard it - Context withNull = context1.with((ImplicitContextKeyed) null); - assertEquals( - context1, withNull, "Null implicitly keyed value should preserve existing context"); - } - - @ParameterizedTest - @MethodSource("contextImplementations") - void testWithPair(Context context) { - // Test retrieving value - String stringValue = "value"; - Context context1 = context.with(BOOLEAN_KEY, false, STRING_KEY, stringValue); - assertEquals(stringValue, context1.get(STRING_KEY)); - assertEquals(false, context1.get(BOOLEAN_KEY)); - // Test overriding value - String stringValue2 = "value2"; - Context context2 = context1.with(STRING_KEY, stringValue2, BOOLEAN_KEY, true); - assertEquals(stringValue2, context2.get(STRING_KEY)); - assertEquals(true, context2.get(BOOLEAN_KEY)); - // Test clearing value - Context context3 = context2.with(BOOLEAN_KEY, null, STRING_KEY, null); - assertNull(context3.get(STRING_KEY)); - assertNull(context3.get(BOOLEAN_KEY)); - // Test null key handling - assertThrows( - NullPointerException.class, - () -> context.with(null, "test", STRING_KEY, "test"), - "Context forbids null keys"); - assertThrows( - NullPointerException.class, - () -> context.with(STRING_KEY, "test", null, "test"), - "Context forbids null keys"); - // Test null value handling - assertDoesNotThrow( - () -> context.with(BOOLEAN_KEY, null, STRING_KEY, "test"), - "Null value should not throw exception"); - assertDoesNotThrow( - () -> context.with(STRING_KEY, "test", BOOLEAN_KEY, null), - "Null value should not throw exception"); - } - - @ParameterizedTest - @MethodSource("contextImplementations") - void testGet(Context original) { - // Setup context - String value = "value"; - Context context = original.with(STRING_KEY, value); - // Test null key handling - assertThrows(NullPointerException.class, () -> context.get(null), "Context forbids null keys"); - // Test unset key - assertNull(context.get(BOOLEAN_KEY), "Missing key expected to return null"); - // Test set key - assertEquals(value, context.get(STRING_KEY), "Value expected to be retrieved"); - } - - @SuppressWarnings({ - "EqualsWithItself", - "SimplifiableAssertion", - "ConstantValue", - "EqualsBetweenInconvertibleTypes" - }) - @Test - void testEqualsAndHashCode() { - // Setup contexts - Context root = root(); - Context context1 = root.with(STRING_KEY, "value"); - Context context2 = root.with(STRING_KEY, "value "); - Context context3 = root.with(STRING_KEY, "value ".trim()); - Context context4 = root.with(STRING_KEY, "value").with(BOOLEAN_KEY, true); - // Test equals on self - assertTrue(root.equals(root)); - assertTrue(context1.equals(context1)); - assertTrue(context4.equals(context4)); - // Test equals on null - assertFalse(context1.equals(null)); - assertFalse(context4.equals(null)); - // Test equals on different object type - assertFalse(context1.equals("value")); - assertFalse(context4.equals("value")); - // Test equals on different contexts with the same values - assertTrue(context1.equals(context3)); - assertEquals(context1.hashCode(), context3.hashCode()); - // Test equals on different contexts - assertFalse(context1.equals(root)); - assertNotEquals(context1.hashCode(), root.hashCode()); - assertFalse(context1.equals(context2)); - assertNotEquals(context1.hashCode(), context2.hashCode()); - assertFalse(context1.equals(context4)); - assertNotEquals(context1.hashCode(), context4.hashCode()); - assertFalse(root.equals(context1)); - assertNotEquals(root.hashCode(), context1.hashCode()); - assertFalse(context2.equals(context1)); - assertNotEquals(context2.hashCode(), context1.hashCode()); - assertFalse(context4.equals(context1)); - assertNotEquals(context4.hashCode(), context1.hashCode()); - } - - @ParameterizedTest - @MethodSource("contextImplementations") - void testToString(Context context) { - String debugString = context.toString(); - assertNotNull(debugString, "Context string representation should not be null"); - assertTrue( - debugString.contains(context.getClass().getSimpleName()), - "Context string representation should contain implementation name"); - } - - @ParameterizedTest - @MethodSource("contextImplementations") - void testWithSameValueReturnsThis(Context context) { - String value = "value"; - Context context1 = context.with(STRING_KEY, value); - // storing the same value again should return the same instance - assertSame(context1, context1.with(STRING_KEY, value)); - // storing a different value should return a new instance - assertNotEquals(context1, context1.with(STRING_KEY, "other")); - } - - @SuppressWarnings({"SimplifiableAssertion"}) - @Test - void testInflation() { - Context empty = root(); - - Context one = empty.with(STRING_KEY, "unset").with(STRING_KEY, "one"); - Context two = one.with(BOOLEAN_KEY, false).with(BOOLEAN_KEY, true); - Context three = two.with(FLOAT_KEY, 0.0f).with(FLOAT_KEY, 3.3f); - - assertNull(empty.get(STRING_KEY)); - assertNull(empty.get(BOOLEAN_KEY)); - assertNull(empty.get(FLOAT_KEY)); - assertNull(empty.get(LONG_KEY)); - - assertEquals("one", one.get(STRING_KEY)); - assertNull(one.get(BOOLEAN_KEY)); - assertNull(one.get(FLOAT_KEY)); - assertNull(one.get(LONG_KEY)); - - assertEquals("one", two.get(STRING_KEY)); - assertEquals(true, two.get(BOOLEAN_KEY)); - assertNull(two.get(FLOAT_KEY)); - assertNull(two.get(LONG_KEY)); - - assertEquals("one", three.get(STRING_KEY)); - assertEquals(true, three.get(BOOLEAN_KEY)); - assertEquals(3.3f, three.get(FLOAT_KEY)); - assertNull(three.get(LONG_KEY)); - - assertFalse(empty.equals(one)); - assertFalse(one.equals(two)); - assertFalse(two.equals(three)); - assertNotEquals(one.hashCode(), empty.hashCode()); - assertNotEquals(two.hashCode(), one.hashCode()); - assertNotEquals(three.hashCode(), two.hashCode()); - } + static final ContextKey STRING_KEY = ContextKey.named("string-key"); + static final ContextKey BOOLEAN_KEY = ContextKey.named("boolean-key"); + static final ContextKey FLOAT_KEY = ContextKey.named("float-key"); + static final ContextKey LONG_KEY = ContextKey.named("long-key"); + + @Test + void testRoot() { + // Test root is always the same + Context root = root(); + assertEquals(root, root(), "Root context should be consistent"); + // Test root is not mutated + String stringValue = "value"; + root.with(STRING_KEY, stringValue); + assertEquals(root, root(), "Root context should be immutable"); + } + + static Stream contextImplementations() { + SingletonContext singletonContext = new SingletonContext(ContextKey.named("test").index, true); + IndexedContext indexedContext = new IndexedContext(new Object[0]); + return Stream.of(Context.root(), singletonContext, indexedContext); + } + + @ParameterizedTest + @MethodSource("contextImplementations") + void testWith(Context context) { + // Test accessing non-set value + assertNull(context.get(STRING_KEY)); + // Test retrieving value + String stringValue = "value"; + Context context1 = context.with(STRING_KEY, stringValue); + assertEquals(stringValue, context1.get(STRING_KEY)); + // Test overriding value + String stringValue2 = "value2"; + Context context2 = context1.with(STRING_KEY, stringValue2); + assertEquals(stringValue2, context2.get(STRING_KEY)); + // Test clearing value + Context context3 = context2.with(STRING_KEY, null); + assertNull(context3.get(STRING_KEY)); + // Test null key handling + assertThrows(NullPointerException.class, () -> context.with(null, "test"), "Context forbids null keys"); + // Test null value handling + assertDoesNotThrow(() -> context.with(BOOLEAN_KEY, null), "Null value should not throw exception"); + // Test null implicitly keyed value handling - should preserve existing context, not discard it + Context withNull = context1.with((ImplicitContextKeyed) null); + assertEquals(context1, withNull, "Null implicitly keyed value should preserve existing context"); + } + + @ParameterizedTest + @MethodSource("contextImplementations") + void testWithPair(Context context) { + // Test retrieving value + String stringValue = "value"; + Context context1 = context.with(BOOLEAN_KEY, false, STRING_KEY, stringValue); + assertEquals(stringValue, context1.get(STRING_KEY)); + assertEquals(false, context1.get(BOOLEAN_KEY)); + // Test overriding value + String stringValue2 = "value2"; + Context context2 = context1.with(STRING_KEY, stringValue2, BOOLEAN_KEY, true); + assertEquals(stringValue2, context2.get(STRING_KEY)); + assertEquals(true, context2.get(BOOLEAN_KEY)); + // Test clearing value + Context context3 = context2.with(BOOLEAN_KEY, null, STRING_KEY, null); + assertNull(context3.get(STRING_KEY)); + assertNull(context3.get(BOOLEAN_KEY)); + // Test null key handling + assertThrows( + NullPointerException.class, + () -> context.with(null, "test", STRING_KEY, "test"), + "Context forbids null keys"); + assertThrows( + NullPointerException.class, + () -> context.with(STRING_KEY, "test", null, "test"), + "Context forbids null keys"); + // Test null value handling + assertDoesNotThrow( + () -> context.with(BOOLEAN_KEY, null, STRING_KEY, "test"), "Null value should not throw exception"); + assertDoesNotThrow( + () -> context.with(STRING_KEY, "test", BOOLEAN_KEY, null), "Null value should not throw exception"); + } + + @ParameterizedTest + @MethodSource("contextImplementations") + void testGet(Context original) { + // Setup context + String value = "value"; + Context context = original.with(STRING_KEY, value); + // Test null key handling + assertThrows(NullPointerException.class, () -> context.get(null), "Context forbids null keys"); + // Test unset key + assertNull(context.get(BOOLEAN_KEY), "Missing key expected to return null"); + // Test set key + assertEquals(value, context.get(STRING_KEY), "Value expected to be retrieved"); + } + + @SuppressWarnings({"EqualsWithItself", "SimplifiableAssertion", "ConstantValue", "EqualsBetweenInconvertibleTypes"}) + @Test + void testEqualsAndHashCode() { + // Setup contexts + Context root = root(); + Context context1 = root.with(STRING_KEY, "value"); + Context context2 = root.with(STRING_KEY, "value "); + Context context3 = root.with(STRING_KEY, "value ".trim()); + Context context4 = root.with(STRING_KEY, "value").with(BOOLEAN_KEY, true); + // Test equals on self + assertTrue(root.equals(root)); + assertTrue(context1.equals(context1)); + assertTrue(context4.equals(context4)); + // Test equals on null + assertFalse(context1.equals(null)); + assertFalse(context4.equals(null)); + // Test equals on different object type + assertFalse(context1.equals("value")); + assertFalse(context4.equals("value")); + // Test equals on different contexts with the same values + assertTrue(context1.equals(context3)); + assertEquals(context1.hashCode(), context3.hashCode()); + // Test equals on different contexts + assertFalse(context1.equals(root)); + assertNotEquals(context1.hashCode(), root.hashCode()); + assertFalse(context1.equals(context2)); + assertNotEquals(context1.hashCode(), context2.hashCode()); + assertFalse(context1.equals(context4)); + assertNotEquals(context1.hashCode(), context4.hashCode()); + assertFalse(root.equals(context1)); + assertNotEquals(root.hashCode(), context1.hashCode()); + assertFalse(context2.equals(context1)); + assertNotEquals(context2.hashCode(), context1.hashCode()); + assertFalse(context4.equals(context1)); + assertNotEquals(context4.hashCode(), context1.hashCode()); + } + + @ParameterizedTest + @MethodSource("contextImplementations") + void testToString(Context context) { + String debugString = context.toString(); + assertNotNull(debugString, "Context string representation should not be null"); + assertTrue( + debugString.contains(context.getClass().getSimpleName()), + "Context string representation should contain implementation name"); + } + + @ParameterizedTest + @MethodSource("contextImplementations") + void testWithSameValueReturnsThis(Context context) { + String value = "value"; + Context context1 = context.with(STRING_KEY, value); + // storing the same value again should return the same instance + assertSame(context1, context1.with(STRING_KEY, value)); + // storing a different value should return a new instance + assertNotEquals(context1, context1.with(STRING_KEY, "other")); + } + + @SuppressWarnings({"SimplifiableAssertion"}) + @Test + void testInflation() { + Context empty = root(); + + Context one = empty.with(STRING_KEY, "unset").with(STRING_KEY, "one"); + Context two = one.with(BOOLEAN_KEY, false).with(BOOLEAN_KEY, true); + Context three = two.with(FLOAT_KEY, 0.0f).with(FLOAT_KEY, 3.3f); + + assertNull(empty.get(STRING_KEY)); + assertNull(empty.get(BOOLEAN_KEY)); + assertNull(empty.get(FLOAT_KEY)); + assertNull(empty.get(LONG_KEY)); + + assertEquals("one", one.get(STRING_KEY)); + assertNull(one.get(BOOLEAN_KEY)); + assertNull(one.get(FLOAT_KEY)); + assertNull(one.get(LONG_KEY)); + + assertEquals("one", two.get(STRING_KEY)); + assertEquals(true, two.get(BOOLEAN_KEY)); + assertNull(two.get(FLOAT_KEY)); + assertNull(two.get(LONG_KEY)); + + assertEquals("one", three.get(STRING_KEY)); + assertEquals(true, three.get(BOOLEAN_KEY)); + assertEquals(3.3f, three.get(FLOAT_KEY)); + assertNull(three.get(LONG_KEY)); + + assertFalse(empty.equals(one)); + assertFalse(one.equals(two)); + assertFalse(two.equals(three)); + assertNotEquals(one.hashCode(), empty.hashCode()); + assertNotEquals(two.hashCode(), one.hashCode()); + assertNotEquals(three.hashCode(), two.hashCode()); + } } diff --git a/components/context/src/test/java/datadog/context/ContextTestBase.java b/components/context/src/test/java/datadog/context/ContextTestBase.java index ac4c76f2482..774ae0ff91e 100644 --- a/components/context/src/test/java/datadog/context/ContextTestBase.java +++ b/components/context/src/test/java/datadog/context/ContextTestBase.java @@ -13,78 +13,78 @@ @ParametersAreNonnullByDefault abstract class ContextTestBase { - static final ContextKey TEST_KEY = ContextKey.named("test-key"); + static final ContextKey TEST_KEY = ContextKey.named("test-key"); - @BeforeEach - void verifyNoContextBefore() { - assertEquals(root(), current()); - } + @BeforeEach + void verifyNoContextBefore() { + assertEquals(root(), current()); + } - @AfterEach - void verifyNoContextAfter() { - TestContextManager.clearListeners(); - assertEquals(root(), current()); - } + @AfterEach + void verifyNoContextAfter() { + TestContextManager.clearListeners(); + assertEquals(root(), current()); + } - static TrackingListener trackingListener() { - return new TrackingListener(); - } + static TrackingListener trackingListener() { + return new TrackingListener(); + } - /** - * A {@link ContextListener} that records events suffixed with {@link #TEST_KEY} context values. - * Uses {@code {root}} when the key is absent from the context. - */ - static final class TrackingListener implements ContextListener { - private final List events = new ArrayList<>(); - private int checkpoint; + /** + * A {@link ContextListener} that records events suffixed with {@link #TEST_KEY} context values. + * Uses {@code {root}} when the key is absent from the context. + */ + static final class TrackingListener implements ContextListener { + private final List events = new ArrayList<>(); + private int checkpoint; - @Override - public void onUpdate(Context before, Context after) { - this.events.add("update:" + label(before) + "->" + label(after)); - } + @Override + public void onUpdate(Context before, Context after) { + this.events.add("update:" + label(before) + "->" + label(after)); + } - @Override - public void onCapture(Context context) { - this.events.add("capture:" + label(context)); - } + @Override + public void onCapture(Context context) { + this.events.add("capture:" + label(context)); + } - @Override - public void onRelease(Context context) { - this.events.add("release:" + label(context)); - } + @Override + public void onRelease(Context context) { + this.events.add("release:" + label(context)); + } - private static String label(Context context) { - String value = context.get(TEST_KEY); - return value != null ? value : "{root}"; - } + private static String label(Context context) { + String value = context.get(TEST_KEY); + return value != null ? value : "{root}"; + } - /** Asserts the full sequence of recorded events equals {@code expected}. */ - void assertEvents(String... expected) { - assertEquals(asList(expected), new ArrayList<>(this.events)); - } + /** Asserts the full sequence of recorded events equals {@code expected}. */ + void assertEvents(String... expected) { + assertEquals(asList(expected), new ArrayList<>(this.events)); + } - /** Asserts that no events have been recorded at all. */ - void assertNoEvents() { - assertEvents(); - } + /** Asserts that no events have been recorded at all. */ + void assertNoEvents() { + assertEvents(); + } - /** - * Asserts the events recorded since the previous {@link #assertNewEvents} call equal {@code - * expected}, then advances the checkpoint past them. - */ - void assertNewEvents(String... expected) { - List snapshot = new ArrayList<>(this.events); - List newEvents = new ArrayList<>(snapshot.subList(this.checkpoint, snapshot.size())); - assertEquals(asList(expected), newEvents); - this.checkpoint = snapshot.size(); - } + /** + * Asserts the events recorded since the previous {@link #assertNewEvents} call equal {@code + * expected}, then advances the checkpoint past them. + */ + void assertNewEvents(String... expected) { + List snapshot = new ArrayList<>(this.events); + List newEvents = new ArrayList<>(snapshot.subList(this.checkpoint, snapshot.size())); + assertEquals(asList(expected), newEvents); + this.checkpoint = snapshot.size(); + } - /** - * Asserts that no events have been recorded since the previous {@link #assertNewEvents} or - * {@link #assertNoNewEvents} call. - */ - void assertNoNewEvents() { - assertNewEvents(); + /** + * Asserts that no events have been recorded since the previous {@link #assertNewEvents} or + * {@link #assertNoNewEvents} call. + */ + void assertNoNewEvents() { + assertNewEvents(); + } } - } } diff --git a/components/context/src/test/java/datadog/context/ImplicitContextKeyedTest.java b/components/context/src/test/java/datadog/context/ImplicitContextKeyedTest.java index 33e149bbb95..4944f04cd93 100644 --- a/components/context/src/test/java/datadog/context/ImplicitContextKeyedTest.java +++ b/components/context/src/test/java/datadog/context/ImplicitContextKeyedTest.java @@ -9,30 +9,29 @@ import org.junit.jupiter.api.Test; class ImplicitContextKeyedTest { - /** This class demonstrate how values can hide their context keys. */ - static class ValueWithKey implements ImplicitContextKeyed { - /** The private key used to store and retrieve context value. */ - private static final ContextKey HIDDEN_KEY = ContextKey.named("hidden-key"); + /** This class demonstrate how values can hide their context keys. */ + static class ValueWithKey implements ImplicitContextKeyed { + /** The private key used to store and retrieve context value. */ + private static final ContextKey HIDDEN_KEY = ContextKey.named("hidden-key"); - @Override - public Context storeInto(@Nonnull Context context) { - return context.with(HIDDEN_KEY, this); - } + @Override + public Context storeInto(@Nonnull Context context) { + return context.with(HIDDEN_KEY, this); + } - @Nullable - public static ValueWithKey from(@Nonnull Context context) { - return context.get(HIDDEN_KEY); + @Nullable + public static ValueWithKey from(@Nonnull Context context) { + return context.get(HIDDEN_KEY); + } } - } - @Test - void testImplicitKey() { - // Setup context - Context root = root(); - ValueWithKey valueWithKey = new ValueWithKey(); - Context context = root.with(valueWithKey); - assertNull(ValueWithKey.from(root), "No value expected to be extracted from root context"); - assertEquals( - valueWithKey, ValueWithKey.from(context), "Expected to retrieve the implicit keyed value"); - } + @Test + void testImplicitKey() { + // Setup context + Context root = root(); + ValueWithKey valueWithKey = new ValueWithKey(); + Context context = root.with(valueWithKey); + assertNull(ValueWithKey.from(root), "No value expected to be extracted from root context"); + assertEquals(valueWithKey, ValueWithKey.from(context), "Expected to retrieve the implicit keyed value"); + } } diff --git a/components/context/src/test/java/datadog/context/propagation/ConcernTest.java b/components/context/src/test/java/datadog/context/propagation/ConcernTest.java index d4779b1130c..f5dbde4f945 100644 --- a/components/context/src/test/java/datadog/context/propagation/ConcernTest.java +++ b/components/context/src/test/java/datadog/context/propagation/ConcernTest.java @@ -8,33 +8,30 @@ import org.junit.jupiter.api.Test; class ConcernTest { - @Test - void testNamed() { - assertThrows( - NullPointerException.class, - () -> Concern.named(null), - "Should not create null named concern"); - assertNotNull(Concern.named("name")); - } + @Test + void testNamed() { + assertThrows(NullPointerException.class, () -> Concern.named(null), "Should not create null named concern"); + assertNotNull(Concern.named("name")); + } - @Test - void testWithPriority() { - assertThrows( - NullPointerException.class, - () -> Concern.withPriority(null, DEFAULT_PRIORITY), - "Should not create null named concern"); - assertThrows( - IllegalArgumentException.class, - () -> Concern.withPriority("name", -1), - "Should not create negative priority concern"); - assertNotNull(Concern.withPriority("high-priority", DEFAULT_PRIORITY - 10)); - assertNotNull(Concern.withPriority("low-priority", DEFAULT_PRIORITY + 10)); - } + @Test + void testWithPriority() { + assertThrows( + NullPointerException.class, + () -> Concern.withPriority(null, DEFAULT_PRIORITY), + "Should not create null named concern"); + assertThrows( + IllegalArgumentException.class, + () -> Concern.withPriority("name", -1), + "Should not create negative priority concern"); + assertNotNull(Concern.withPriority("high-priority", DEFAULT_PRIORITY - 10)); + assertNotNull(Concern.withPriority("low-priority", DEFAULT_PRIORITY + 10)); + } - @Test - void testName() { - String debugName = "name"; - Concern concern = Concern.named(debugName); - assertEquals(debugName, concern.toString(), "Concern name mismatch"); - } + @Test + void testName() { + String debugName = "name"; + Concern concern = Concern.named(debugName); + assertEquals(debugName, concern.toString(), "Concern name mismatch"); + } } diff --git a/components/context/src/test/java/datadog/context/propagation/PropagatorsTest.java b/components/context/src/test/java/datadog/context/propagation/PropagatorsTest.java index a8611014ede..b786ff22fb8 100644 --- a/components/context/src/test/java/datadog/context/propagation/PropagatorsTest.java +++ b/components/context/src/test/java/datadog/context/propagation/PropagatorsTest.java @@ -20,215 +20,199 @@ import org.junit.jupiter.api.Test; class PropagatorsTest { - static final MapCarrierAccessor ACCESSOR = new MapCarrierAccessor(); - - static final Concern TRACING = Concern.named("tracing"); - static final ContextKey TRACING_KEY = ContextKey.named("tracing"); - static final Propagator TRACING_PROPAGATOR = new BasicPropagator(TRACING_KEY, "tracing"); - - static final Concern IAST = Concern.named("iast"); - static final ContextKey IAST_KEY = ContextKey.named("iast"); - static final Propagator IAST_PROPAGATOR = new BasicPropagator(IAST_KEY, "iast"); - - static final Concern DEBUGGER = Concern.withPriority("debugger", DEFAULT_PRIORITY - 10); - static final ContextKey DEBUGGER_KEY = ContextKey.named("debugger"); - static final DependentPropagator DEBUGGER_PROPAGATOR = - new DependentPropagator(DEBUGGER_KEY, "debugger", TRACING_KEY); - - static final Concern PROFILING = Concern.withPriority("profiling", DEFAULT_PRIORITY + 10); - static final ContextKey PROFILING_KEY = ContextKey.named("profiling"); - static final DependentPropagator PROFILING_PROPAGATOR = - new DependentPropagator(PROFILING_KEY, "profiling", TRACING_KEY); - - static final Context CONTEXT = - root() - .with(TRACING_KEY, "sampled") - .with(IAST_KEY, "standalone") - .with(DEBUGGER_KEY, "debug") - .with(PROFILING_KEY, "profile"); - - @ParametersAreNonnullByDefault - static class MapCarrierAccessor - implements CarrierSetter>, CarrierVisitor> { - @Override - public void set(Map carrier, String key, String value) { - if (carrier != null && key != null && value != null) { - carrier.put(key, value); - } - } + static final MapCarrierAccessor ACCESSOR = new MapCarrierAccessor(); + + static final Concern TRACING = Concern.named("tracing"); + static final ContextKey TRACING_KEY = ContextKey.named("tracing"); + static final Propagator TRACING_PROPAGATOR = new BasicPropagator(TRACING_KEY, "tracing"); + + static final Concern IAST = Concern.named("iast"); + static final ContextKey IAST_KEY = ContextKey.named("iast"); + static final Propagator IAST_PROPAGATOR = new BasicPropagator(IAST_KEY, "iast"); + + static final Concern DEBUGGER = Concern.withPriority("debugger", DEFAULT_PRIORITY - 10); + static final ContextKey DEBUGGER_KEY = ContextKey.named("debugger"); + static final DependentPropagator DEBUGGER_PROPAGATOR = + new DependentPropagator(DEBUGGER_KEY, "debugger", TRACING_KEY); + + static final Concern PROFILING = Concern.withPriority("profiling", DEFAULT_PRIORITY + 10); + static final ContextKey PROFILING_KEY = ContextKey.named("profiling"); + static final DependentPropagator PROFILING_PROPAGATOR = + new DependentPropagator(PROFILING_KEY, "profiling", TRACING_KEY); + + static final Context CONTEXT = root().with(TRACING_KEY, "sampled") + .with(IAST_KEY, "standalone") + .with(DEBUGGER_KEY, "debug") + .with(PROFILING_KEY, "profile"); + + @ParametersAreNonnullByDefault + static class MapCarrierAccessor implements CarrierSetter>, CarrierVisitor> { + @Override + public void set(Map carrier, String key, String value) { + if (carrier != null && key != null && value != null) { + carrier.put(key, value); + } + } - @Override - public void forEachKeyValue(Map carrier, BiConsumer visitor) { - carrier.forEach(visitor); + @Override + public void forEachKeyValue(Map carrier, BiConsumer visitor) { + carrier.forEach(visitor); + } } - } - static class BasicPropagator implements Propagator { - private final ContextKey contextKey; - private final String carrierKey; + static class BasicPropagator implements Propagator { + private final ContextKey contextKey; + private final String carrierKey; + + public BasicPropagator(ContextKey contextKey, String carrierKey) { + this.contextKey = requireNonNull(contextKey); + this.carrierKey = requireNonNull(carrierKey); + } - public BasicPropagator(ContextKey contextKey, String carrierKey) { - this.contextKey = requireNonNull(contextKey); - this.carrierKey = requireNonNull(carrierKey); + @Override + public void inject(Context context, C carrier, CarrierSetter setter) { + String value = context.get(this.contextKey); + if (value != null) { + setter.set(carrier, this.carrierKey, value); + } + } + + @Override + public Context extract(Context context, C carrier, CarrierVisitor visitor) { + String[] valueRef = new String[1]; + visitor.forEachKeyValue(carrier, (key, value) -> { + if (this.carrierKey.equals(key)) { + valueRef[0] = value; + } + }); + if (valueRef[0] != null) { + context = context.with(this.contextKey, valueRef[0]); + } + return context; + } } - @Override - public void inject(Context context, C carrier, CarrierSetter setter) { - String value = context.get(this.contextKey); - if (value != null) { - setter.set(carrier, this.carrierKey, value); - } + static class DependentPropagator extends BasicPropagator implements Propagator { + private final ContextKey requiredContextKey; + private boolean keyFound; + + public DependentPropagator( + ContextKey contextKey, String carrierKey, ContextKey requiredContextKey) { + super(contextKey, carrierKey); + this.requiredContextKey = requiredContextKey; + this.keyFound = false; + } + + @Override + public Context extract(Context context, C carrier, CarrierVisitor visitor) { + this.keyFound = context.get(this.requiredContextKey) != null; + return super.extract(context, carrier, visitor); + } + + public void reset() { + this.keyFound = false; + } } - @Override - public Context extract(Context context, C carrier, CarrierVisitor visitor) { - String[] valueRef = new String[1]; - visitor.forEachKeyValue( - carrier, - (key, value) -> { - if (this.carrierKey.equals(key)) { - valueRef[0] = value; - } - }); - if (valueRef[0] != null) { - context = context.with(this.contextKey, valueRef[0]); - } - return context; + @BeforeEach + @AfterEach + void resetPropagators() { + Propagators.reset(); + DEBUGGER_PROPAGATOR.reset(); + PROFILING_PROPAGATOR.reset(); } - } - static class DependentPropagator extends BasicPropagator implements Propagator { - private final ContextKey requiredContextKey; - private boolean keyFound; + @Test + void testDefaultPropagator() { + Propagator noopPropagator = Propagators.defaultPropagator(); + assertNotNull(noopPropagator, "Default propagator should not be null when no propagator is registered"); + assertInjectExtractContext(CONTEXT, noopPropagator); + + Propagators.register(TRACING, TRACING_PROPAGATOR); + Propagator single = Propagators.defaultPropagator(); + assertInjectExtractContext(CONTEXT, single, TRACING_KEY); + + Propagators.register(IAST, IAST_PROPAGATOR, false); + Propagators.register(DEBUGGER, DEBUGGER_PROPAGATOR); + Propagators.register(PROFILING, PROFILING_PROPAGATOR); + Propagator composite = Propagators.defaultPropagator(); + assertDoNotInjectExtractContext(CONTEXT, composite, IAST_KEY); + assertInjectExtractContext(CONTEXT, composite, TRACING_KEY, DEBUGGER_KEY, PROFILING_KEY); + assertFalse(DEBUGGER_PROPAGATOR.keyFound, "Debugger propagator should have run before tracing propagator"); + assertTrue(PROFILING_PROPAGATOR.keyFound, "Profiling propagator should have run after tracing propagator"); + + Propagator cached = Propagators.defaultPropagator(); + assertEquals(composite, cached, "default propagator should be cached"); + } - public DependentPropagator( - ContextKey contextKey, String carrierKey, ContextKey requiredContextKey) { - super(contextKey, carrierKey); - this.requiredContextKey = requiredContextKey; - this.keyFound = false; + @Test + void testForConcern() { + // Test when not registered + Propagator propagator = Propagators.forConcern(TRACING); + assertNotNull(propagator, "Propagator should not be null when no propagator is registered"); + assertNoopPropagator(propagator); + // Test when registered + Propagators.register(TRACING, TRACING_PROPAGATOR); + propagator = Propagators.forConcern(TRACING); + assertNotNull(propagator, "Propagator should not be null when registered"); + assertInjectExtractContext(CONTEXT, propagator, TRACING_KEY); } - @Override - public Context extract(Context context, C carrier, CarrierVisitor visitor) { - this.keyFound = context.get(this.requiredContextKey) != null; - return super.extract(context, carrier, visitor); + @Test + void testForConcerns() { + // Test when none registered + Propagator propagator = Propagators.forConcerns(TRACING, IAST); + assertNotNull(propagator, "Propagator should not be null when no propagator is registered"); + assertNoopPropagator(propagator); + // Test when only one is registered + Propagators.register(TRACING, TRACING_PROPAGATOR); + propagator = Propagators.forConcerns(TRACING, IAST); + assertNotNull(propagator, "Propagator should not be null when one is registered"); + assertInjectExtractContext(CONTEXT, propagator, TRACING_KEY); + // Test when all registered + Propagators.register(IAST, IAST_PROPAGATOR); + propagator = Propagators.forConcerns(TRACING, IAST); + assertNotNull(propagator, "Propagator should not be null when all are registered"); + assertInjectExtractContext(CONTEXT, propagator, TRACING_KEY, IAST_KEY); + // Test propagator order follow the given concerns order despite concern priority + Propagators.register(DEBUGGER, DEBUGGER_PROPAGATOR); + Propagators.register(PROFILING, PROFILING_PROPAGATOR); + propagator = Propagators.forConcerns(PROFILING, TRACING, DEBUGGER); + assertInjectExtractContext(CONTEXT, propagator, PROFILING_KEY, TRACING_KEY, DEBUGGER_KEY); + assertFalse(PROFILING_PROPAGATOR.keyFound, "Profiling propagator should have run before tracing propagator"); + assertTrue(DEBUGGER_PROPAGATOR.keyFound, "Debugger propagator should have run before tracing propagator"); } - public void reset() { - this.keyFound = false; + @Test + void testNoopPropagator() { + Propagator noopPropagator = Propagators.noop(); + assertNotNull(noopPropagator, "noop propagator should not be null"); + assertNoopPropagator(noopPropagator); } - } - - @BeforeEach - @AfterEach - void resetPropagators() { - Propagators.reset(); - DEBUGGER_PROPAGATOR.reset(); - PROFILING_PROPAGATOR.reset(); - } - - @Test - void testDefaultPropagator() { - Propagator noopPropagator = Propagators.defaultPropagator(); - assertNotNull( - noopPropagator, "Default propagator should not be null when no propagator is registered"); - assertInjectExtractContext(CONTEXT, noopPropagator); - - Propagators.register(TRACING, TRACING_PROPAGATOR); - Propagator single = Propagators.defaultPropagator(); - assertInjectExtractContext(CONTEXT, single, TRACING_KEY); - - Propagators.register(IAST, IAST_PROPAGATOR, false); - Propagators.register(DEBUGGER, DEBUGGER_PROPAGATOR); - Propagators.register(PROFILING, PROFILING_PROPAGATOR); - Propagator composite = Propagators.defaultPropagator(); - assertDoNotInjectExtractContext(CONTEXT, composite, IAST_KEY); - assertInjectExtractContext(CONTEXT, composite, TRACING_KEY, DEBUGGER_KEY, PROFILING_KEY); - assertFalse( - DEBUGGER_PROPAGATOR.keyFound, - "Debugger propagator should have run before tracing propagator"); - assertTrue( - PROFILING_PROPAGATOR.keyFound, - "Profiling propagator should have run after tracing propagator"); - - Propagator cached = Propagators.defaultPropagator(); - assertEquals(composite, cached, "default propagator should be cached"); - } - - @Test - void testForConcern() { - // Test when not registered - Propagator propagator = Propagators.forConcern(TRACING); - assertNotNull(propagator, "Propagator should not be null when no propagator is registered"); - assertNoopPropagator(propagator); - // Test when registered - Propagators.register(TRACING, TRACING_PROPAGATOR); - propagator = Propagators.forConcern(TRACING); - assertNotNull(propagator, "Propagator should not be null when registered"); - assertInjectExtractContext(CONTEXT, propagator, TRACING_KEY); - } - - @Test - void testForConcerns() { - // Test when none registered - Propagator propagator = Propagators.forConcerns(TRACING, IAST); - assertNotNull(propagator, "Propagator should not be null when no propagator is registered"); - assertNoopPropagator(propagator); - // Test when only one is registered - Propagators.register(TRACING, TRACING_PROPAGATOR); - propagator = Propagators.forConcerns(TRACING, IAST); - assertNotNull(propagator, "Propagator should not be null when one is registered"); - assertInjectExtractContext(CONTEXT, propagator, TRACING_KEY); - // Test when all registered - Propagators.register(IAST, IAST_PROPAGATOR); - propagator = Propagators.forConcerns(TRACING, IAST); - assertNotNull(propagator, "Propagator should not be null when all are registered"); - assertInjectExtractContext(CONTEXT, propagator, TRACING_KEY, IAST_KEY); - // Test propagator order follow the given concerns order despite concern priority - Propagators.register(DEBUGGER, DEBUGGER_PROPAGATOR); - Propagators.register(PROFILING, PROFILING_PROPAGATOR); - propagator = Propagators.forConcerns(PROFILING, TRACING, DEBUGGER); - assertInjectExtractContext(CONTEXT, propagator, PROFILING_KEY, TRACING_KEY, DEBUGGER_KEY); - assertFalse( - PROFILING_PROPAGATOR.keyFound, - "Profiling propagator should have run before tracing propagator"); - assertTrue( - DEBUGGER_PROPAGATOR.keyFound, - "Debugger propagator should have run before tracing propagator"); - } - - @Test - void testNoopPropagator() { - Propagator noopPropagator = Propagators.noop(); - assertNotNull(noopPropagator, "noop propagator should not be null"); - assertNoopPropagator(noopPropagator); - } - - void assertNoopPropagator(Propagator noopPropagator) { - Map carrier = new HashMap<>(); - noopPropagator.inject(CONTEXT, carrier, ACCESSOR); - assertTrue(carrier.isEmpty(), "carrier should be empty"); - Context extracted = noopPropagator.extract(root(), carrier, ACCESSOR); - assertEquals(root(), extracted, "extracted context should be empty"); - } - - void assertInjectExtractContext(Context context, Propagator propagator, ContextKey... keys) { - Map carrier = new HashMap<>(); - propagator.inject(context, carrier, ACCESSOR); - Context extracted = propagator.extract(root(), carrier, ACCESSOR); - for (ContextKey key : keys) { - assertEquals( - context.get(key), extracted.get(key), "Key " + key + " not injected nor extracted"); + + void assertNoopPropagator(Propagator noopPropagator) { + Map carrier = new HashMap<>(); + noopPropagator.inject(CONTEXT, carrier, ACCESSOR); + assertTrue(carrier.isEmpty(), "carrier should be empty"); + Context extracted = noopPropagator.extract(root(), carrier, ACCESSOR); + assertEquals(root(), extracted, "extracted context should be empty"); } - } - - private void assertDoNotInjectExtractContext( - Context context, Propagator propagator, ContextKey... keys) { - Map carrier = new HashMap<>(); - propagator.inject(context, carrier, ACCESSOR); - Context extracted = propagator.extract(root(), carrier, ACCESSOR); - for (ContextKey key : keys) { - assertNull(extracted.get(key), "Key " + key + " was injected"); + + void assertInjectExtractContext(Context context, Propagator propagator, ContextKey... keys) { + Map carrier = new HashMap<>(); + propagator.inject(context, carrier, ACCESSOR); + Context extracted = propagator.extract(root(), carrier, ACCESSOR); + for (ContextKey key : keys) { + assertEquals(context.get(key), extracted.get(key), "Key " + key + " not injected nor extracted"); + } + } + + private void assertDoNotInjectExtractContext(Context context, Propagator propagator, ContextKey... keys) { + Map carrier = new HashMap<>(); + propagator.inject(context, carrier, ACCESSOR); + Context extracted = propagator.extract(root(), carrier, ACCESSOR); + for (ContextKey key : keys) { + assertNull(extracted.get(key), "Key " + key + " was injected"); + } } - } } diff --git a/components/environment/src/main/java/datadog/environment/CommandLine.java b/components/environment/src/main/java/datadog/environment/CommandLine.java index 79f78bef2af..f75ede76a7b 100644 --- a/components/environment/src/main/java/datadog/environment/CommandLine.java +++ b/components/environment/src/main/java/datadog/environment/CommandLine.java @@ -23,26 +23,27 @@ * become available. */ class CommandLine { - private static final String SUN_JAVA_COMMAND_PROPERTY = "sun.java.command"; - private final List fullCommand = findFullCommand(); - final String name = getCommandName(); - final List arguments = getCommandArguments(); + private static final String SUN_JAVA_COMMAND_PROPERTY = "sun.java.command"; + private final List fullCommand = findFullCommand(); + final String name = getCommandName(); + final List arguments = getCommandArguments(); - @SuppressForbidden // split on single-character uses a fast path - private List findFullCommand() { - String command = SystemProperties.getOrDefault(SUN_JAVA_COMMAND_PROPERTY, "").trim(); - return command.isEmpty() ? emptyList() : Arrays.asList(command.split(" ")); - } + @SuppressForbidden // split on single-character uses a fast path + private List findFullCommand() { + String command = + SystemProperties.getOrDefault(SUN_JAVA_COMMAND_PROPERTY, "").trim(); + return command.isEmpty() ? emptyList() : Arrays.asList(command.split(" ")); + } - private String getCommandName() { - return this.fullCommand.isEmpty() ? null : this.fullCommand.get(0); - } + private String getCommandName() { + return this.fullCommand.isEmpty() ? null : this.fullCommand.get(0); + } - private List getCommandArguments() { - if (this.fullCommand.isEmpty()) { - return this.fullCommand; - } else { - return this.fullCommand.subList(1, this.fullCommand.size()); + private List getCommandArguments() { + if (this.fullCommand.isEmpty()) { + return this.fullCommand; + } else { + return this.fullCommand.subList(1, this.fullCommand.size()); + } } - } } diff --git a/components/environment/src/main/java/datadog/environment/EnvironmentVariables.java b/components/environment/src/main/java/datadog/environment/EnvironmentVariables.java index c27c13e6f12..4ddfcb4f1f2 100644 --- a/components/environment/src/main/java/datadog/environment/EnvironmentVariables.java +++ b/components/environment/src/main/java/datadog/environment/EnvironmentVariables.java @@ -15,69 +15,69 @@ * Manager */ public final class EnvironmentVariables { - private EnvironmentVariables() {} + private EnvironmentVariables() {} - public static class EnvironmentVariablesProvider { + public static class EnvironmentVariablesProvider { - // Environment Component has SecurityException handling, so it is safe to call System.getenv - // here - @SuppressForbidden - public String get(String name) { - return System.getenv(name); - } + // Environment Component has SecurityException handling, so it is safe to call System.getenv + // here + @SuppressForbidden + public String get(String name) { + return System.getenv(name); + } - @SuppressForbidden - public Map getAll() { - return System.getenv(); + @SuppressForbidden + public Map getAll() { + return System.getenv(); + } } - } - - // Make it accessible from tests. - public static EnvironmentVariablesProvider provider = new EnvironmentVariablesProvider(); - /** - * Gets an environment variable value. - * - * @param name The environment variable name. - * @return The environment variable value, {@code null} if missing, can't be retrieved, or the - * environment variable name is {@code null}. - */ - public static @Nullable String get(String name) { - return getOrDefault(name, null); - } + // Make it accessible from tests. + public static EnvironmentVariablesProvider provider = new EnvironmentVariablesProvider(); - /** - * Gets an environment variable value, or default value if missing or can't be retrieved. - * - * @param name The environment variable name. - * @param defaultValue The default value to return if the environment variable is missing or can't - * be retrieved. - * @return The environment variable value, {@code defaultValue} if missing, can't be retrieved or - * the environment variable name is {@code null}. - */ - public static String getOrDefault(String name, String defaultValue) { - if (name == null) { - return defaultValue; + /** + * Gets an environment variable value. + * + * @param name The environment variable name. + * @return The environment variable value, {@code null} if missing, can't be retrieved, or the + * environment variable name is {@code null}. + */ + public static @Nullable String get(String name) { + return getOrDefault(name, null); } - try { - String value = provider.get(name); - return value == null ? defaultValue : value; - } catch (SecurityException e) { - return defaultValue; + + /** + * Gets an environment variable value, or default value if missing or can't be retrieved. + * + * @param name The environment variable name. + * @param defaultValue The default value to return if the environment variable is missing or can't + * be retrieved. + * @return The environment variable value, {@code defaultValue} if missing, can't be retrieved or + * the environment variable name is {@code null}. + */ + public static String getOrDefault(String name, String defaultValue) { + if (name == null) { + return defaultValue; + } + try { + String value = provider.get(name); + return value == null ? defaultValue : value; + } catch (SecurityException e) { + return defaultValue; + } } - } - /** - * Gets all environment variables. - * - * @return All environment variables captured in an unmodifiable {@link Map}, or an empty {@link - * Map} if they can't be retrieved. - */ - public static Map getAll() { - try { - return unmodifiableMap(new HashMap<>(provider.getAll())); - } catch (SecurityException e) { - return emptyMap(); + /** + * Gets all environment variables. + * + * @return All environment variables captured in an unmodifiable {@link Map}, or an empty {@link + * Map} if they can't be retrieved. + */ + public static Map getAll() { + try { + return unmodifiableMap(new HashMap<>(provider.getAll())); + } catch (SecurityException e) { + return emptyMap(); + } } - } } diff --git a/components/environment/src/main/java/datadog/environment/JavaVersion.java b/components/environment/src/main/java/datadog/environment/JavaVersion.java index e880adfa158..5b808468762 100644 --- a/components/environment/src/main/java/datadog/environment/JavaVersion.java +++ b/components/environment/src/main/java/datadog/environment/JavaVersion.java @@ -10,102 +10,101 @@ * Naming Convention */ final class JavaVersion { - final int major; - final int minor; - final int update; - - JavaVersion(int major, int minor, int update) { - this.major = major; - this.minor = minor; - this.update = update; - } - - static JavaVersion getRuntimeVersion() { - return parseJavaVersion(SystemProperties.getOrDefault("java.version", "")); - } - - static JavaVersion parseJavaVersion(String javaVersion) { - // Remove pre-release part, usually -ea - final int indexOfDash = javaVersion.indexOf('-'); - if (indexOfDash >= 0) { - javaVersion = javaVersion.substring(0, indexOfDash); + final int major; + final int minor; + final int update; + + JavaVersion(int major, int minor, int update) { + this.major = major; + this.minor = minor; + this.update = update; } - int major = 0; - int minor = 0; - int update = 0; - - try { - List nums = splitDigits(javaVersion); - major = nums.get(0); - - // for java 1.6/1.7/1.8 - if (major == 1) { - major = nums.get(1); - minor = nums.get(2); - update = nums.get(3); - } else { - minor = nums.get(1); - update = nums.get(2); - } - } catch (NumberFormatException | IndexOutOfBoundsException e) { - // unable to parse version string - do nothing + static JavaVersion getRuntimeVersion() { + return parseJavaVersion(SystemProperties.getOrDefault("java.version", "")); } - return new JavaVersion(major, minor, update); - } - - /* The method splits java version string by digits. Delimiters are: dot, underscore and plus */ - private static List splitDigits(String str) { - List results = new ArrayList<>(); - - int len = str.length(); - int value = 0; - for (int i = 0; i < len; i++) { - char ch = str.charAt(i); - if (ch >= '0' && ch <= '9') { - value = value * 10 + (ch - '0'); - } else if (ch == '.' || ch == '_' || ch == '+') { + + static JavaVersion parseJavaVersion(String javaVersion) { + // Remove pre-release part, usually -ea + final int indexOfDash = javaVersion.indexOf('-'); + if (indexOfDash >= 0) { + javaVersion = javaVersion.substring(0, indexOfDash); + } + + int major = 0; + int minor = 0; + int update = 0; + + try { + List nums = splitDigits(javaVersion); + major = nums.get(0); + + // for java 1.6/1.7/1.8 + if (major == 1) { + major = nums.get(1); + minor = nums.get(2); + update = nums.get(3); + } else { + minor = nums.get(1); + update = nums.get(2); + } + } catch (NumberFormatException | IndexOutOfBoundsException e) { + // unable to parse version string - do nothing + } + return new JavaVersion(major, minor, update); + } + + /* The method splits java version string by digits. Delimiters are: dot, underscore and plus */ + private static List splitDigits(String str) { + List results = new ArrayList<>(); + + int len = str.length(); + int value = 0; + for (int i = 0; i < len; i++) { + char ch = str.charAt(i); + if (ch >= '0' && ch <= '9') { + value = value * 10 + (ch - '0'); + } else if (ch == '.' || ch == '_' || ch == '+') { + results.add(value); + value = 0; + } else { + throw new NumberFormatException(); + } + } results.add(value); - value = 0; - } else { - throw new NumberFormatException(); - } + return results; + } + + public boolean is(int major) { + return this.major == major; + } + + public boolean is(int major, int minor) { + return this.major == major && this.minor == minor; + } + + public boolean is(int major, int minor, int update) { + return this.major == major && this.minor == minor && this.update == update; + } + + public boolean isAtLeast(int major) { + return isAtLeast(major, 0, 0); + } + + public boolean isAtLeast(int major, int minor, int update) { + return isAtLeast(this.major, this.minor, this.update, major, minor, update); + } + + public boolean isBetween(int fromMajor, int fromMinor, int fromUpdate, int toMajor, int toMinor, int toUpdate) { + return isAtLeast(toMajor, toMinor, toUpdate, fromMajor, fromMinor, fromUpdate) + && isAtLeast(fromMajor, fromMinor, fromUpdate) + && !isAtLeast(toMajor, toMinor, toUpdate); + } + + private static boolean isAtLeast( + int major, int minor, int update, int atLeastMajor, int atLeastMinor, int atLeastUpdate) { + return (major > atLeastMajor) + || (major == atLeastMajor && minor > atLeastMinor) + || (major == atLeastMajor && minor == atLeastMinor && update >= atLeastUpdate); } - results.add(value); - return results; - } - - public boolean is(int major) { - return this.major == major; - } - - public boolean is(int major, int minor) { - return this.major == major && this.minor == minor; - } - - public boolean is(int major, int minor, int update) { - return this.major == major && this.minor == minor && this.update == update; - } - - public boolean isAtLeast(int major) { - return isAtLeast(major, 0, 0); - } - - public boolean isAtLeast(int major, int minor, int update) { - return isAtLeast(this.major, this.minor, this.update, major, minor, update); - } - - public boolean isBetween( - int fromMajor, int fromMinor, int fromUpdate, int toMajor, int toMinor, int toUpdate) { - return isAtLeast(toMajor, toMinor, toUpdate, fromMajor, fromMinor, fromUpdate) - && isAtLeast(fromMajor, fromMinor, fromUpdate) - && !isAtLeast(toMajor, toMinor, toUpdate); - } - - private static boolean isAtLeast( - int major, int minor, int update, int atLeastMajor, int atLeastMinor, int atLeastUpdate) { - return (major > atLeastMajor) - || (major == atLeastMajor && minor > atLeastMinor) - || (major == atLeastMajor && minor == atLeastMinor && update >= atLeastUpdate); - } } diff --git a/components/environment/src/main/java/datadog/environment/JavaVirtualMachine.java b/components/environment/src/main/java/datadog/environment/JavaVirtualMachine.java index d6fe120c98b..e8ce243c1e5 100644 --- a/components/environment/src/main/java/datadog/environment/JavaVirtualMachine.java +++ b/components/environment/src/main/java/datadog/environment/JavaVirtualMachine.java @@ -6,223 +6,220 @@ import javax.annotation.Nullable; public final class JavaVirtualMachine { - private static final CommandLine commandLine = new CommandLine(); - private static final JavaVersion javaVersion = JavaVersion.getRuntimeVersion(); - private static final Runtime runtime = new Runtime(); - - private JavaVirtualMachine() {} - - public static boolean isJavaVersion(int major) { - return javaVersion.is(major); - } - - public static boolean isJavaVersion(int major, int minor) { - return javaVersion.is(major, minor); - } - - public static boolean isJavaVersion(int major, int minor, int update) { - return javaVersion.is(major, minor, update); - } - - public static boolean isJavaVersionAtLeast(int major) { - return isJavaVersionAtLeast(major, 0, 0); - } - - public static boolean isJavaVersionAtLeast(int major, int minor) { - return isJavaVersionAtLeast(major, minor, 0); - } - - public static boolean isJavaVersionAtLeast(int major, int minor, int update) { - return javaVersion.isAtLeast(major, minor, update); - } - - /** - * Checks if the Java version is between {@code fromMajor} (inclusive) and {@code toMajor} - * (exclusive). - * - * @param fromMajor major from version (inclusive) - * @param toMajor major to version (exclusive) - * @return if the current java version is between the from version (inclusive) and the to version - * exclusive - */ - public static boolean isJavaVersionBetween(int fromMajor, int toMajor) { - return isJavaVersionBetween(fromMajor, 0, toMajor, 0); - } - - /** - * Checks if the Java version is between {@code fromMajor.fromMinor} (inclusive) and {@code - * toMajor.toMinor} (exclusive). - * - * @param fromMajor major from version (inclusive) - * @param fromMinor minor from version (inclusive) - * @param toMajor major to version (exclusive) - * @param toMinor minor to version (exclusive) - * @return if the current java version is between the from version (inclusive) and the to version - * exclusive - */ - public static boolean isJavaVersionBetween( - int fromMajor, int fromMinor, int toMajor, int toMinor) { - return isJavaVersionBetween(fromMajor, fromMinor, 0, toMajor, toMinor, 0); - } - - /** - * Checks if the Java version is between {@code fromMajor.fromMinor.fromUpdate} (inclusive) and - * {@code toMajor.toMinor.toUpdate} (exclusive). - * - * @param fromMajor major from version (inclusive) - * @param fromMinor minor from version (inclusive) - * @param fromUpdate update from version (inclusive) - * @param toMajor major to version (exclusive) - * @param toMinor minor to version (exclusive) - * @param toUpdate update to version (exclusive) - * @return if the current java version is between the from version (inclusive) and the to version - * exclusive - */ - public static boolean isJavaVersionBetween( - int fromMajor, int fromMinor, int fromUpdate, int toMajor, int toMinor, int toUpdate) { - return javaVersion.isBetween(fromMajor, fromMinor, fromUpdate, toMajor, toMinor, toUpdate); - } - - /** - * Checks whether the current JVM is an Oracle JDK 8. - * - * @return {@code true} if the current JVM is an Oracle JDK 8, {@code false} otherwise. - */ - public static boolean isOracleJDK8() { - return isJavaVersion(8) - && runtime.vendor.contains("Oracle") - && !runtime.name.contains("OpenJDK"); - } - - public static boolean isHotspot() { - String prop = SystemProperties.getOrDefault("java.vm.name", ""); - if (prop.isEmpty()) { - return false; - } - return prop.contains("OpenJDK") - || prop.contains("HotSpot") - || prop.contains("GraalVM") - || prop.contains("Dynamic Code Evolution"); - } - - public static boolean isJ9() { - return SystemProperties.getOrDefault("java.vm.name", "").contains("J9"); - } - - public static boolean isIbm() { - return runtime.vendor.contains("IBM"); - } - - public static boolean isIbm8() { - return isIbm() && isJavaVersion(8); - } - - public static boolean isZulu8() { - return runtime.vendor.contains("Azul") && isJavaVersion(8); - } - - public static boolean isGraalVM() { - return runtime.vendorVersion.toLowerCase().contains("graalvm"); - } - - public static String getLangVersion() { - return String.valueOf(javaVersion.major); - } - - public static String getRuntimeVendor() { - return runtime.vendor; - } - - public static String getRuntimeVersion() { - return runtime.version; - } - - public static String getRuntimePatches() { - return runtime.patches; - } - - /** - * Gets the JVM options. - * - * @return The JVM options, an empty collection if they can't be retrieved. - */ - public static List getVmOptions() { - return JvmOptionsHolder.JVM_OPTIONS.VM_OPTIONS; - } - - /** - * Gets the command arguments. - * - * @return The command arguments, an empty collection if missing or can't be retrieved. - */ - public static List getCommandArguments() { - return commandLine.arguments; - } - - /** - * Gets the JVM runtime main class name. - * - * @return The JVM runtime main class name, {@code null} if using JAR file instead or can't be - * retrieved. - */ - public static @Nullable String getMainClass() { - return commandLine.name != null && !isJarName(commandLine.name) ? commandLine.name : null; - } - - /** - * Gets the JVM runtime jar file. - * - * @return The JVM runtime jar file, {@code null} if using main class instead or can't be - * retrieved. - */ - public static @Nullable String getJarFile() { - return commandLine.name != null && isJarName(commandLine.name) ? commandLine.name : null; - } - - private static boolean isJarName(String argument) { - return argument.toLowerCase(Locale.ROOT).endsWith(".jar"); - } - - private static class JvmOptionsHolder { - private static final JvmOptions JVM_OPTIONS = new JvmOptions(); - } - - static final class Runtime { - /* - * Example: - * jvm -> "AdoptOpenJDK 1.8.0_265-b01" + private static final CommandLine commandLine = new CommandLine(); + private static final JavaVersion javaVersion = JavaVersion.getRuntimeVersion(); + private static final Runtime runtime = new Runtime(); + + private JavaVirtualMachine() {} + + public static boolean isJavaVersion(int major) { + return javaVersion.is(major); + } + + public static boolean isJavaVersion(int major, int minor) { + return javaVersion.is(major, minor); + } + + public static boolean isJavaVersion(int major, int minor, int update) { + return javaVersion.is(major, minor, update); + } + + public static boolean isJavaVersionAtLeast(int major) { + return isJavaVersionAtLeast(major, 0, 0); + } + + public static boolean isJavaVersionAtLeast(int major, int minor) { + return isJavaVersionAtLeast(major, minor, 0); + } + + public static boolean isJavaVersionAtLeast(int major, int minor, int update) { + return javaVersion.isAtLeast(major, minor, update); + } + + /** + * Checks if the Java version is between {@code fromMajor} (inclusive) and {@code toMajor} + * (exclusive). + * + * @param fromMajor major from version (inclusive) + * @param toMajor major to version (exclusive) + * @return if the current java version is between the from version (inclusive) and the to version + * exclusive + */ + public static boolean isJavaVersionBetween(int fromMajor, int toMajor) { + return isJavaVersionBetween(fromMajor, 0, toMajor, 0); + } + + /** + * Checks if the Java version is between {@code fromMajor.fromMinor} (inclusive) and {@code + * toMajor.toMinor} (exclusive). * - * name -> "OpenJDK" - * vendor -> "AdoptOpenJDK" - * version -> "1.8.0_265" - * patches -> "b01" + * @param fromMajor major from version (inclusive) + * @param fromMinor minor from version (inclusive) + * @param toMajor major to version (exclusive) + * @param toMinor minor to version (exclusive) + * @return if the current java version is between the from version (inclusive) and the to version + * exclusive */ - public final String name; - - public final String vendor; - public final String version; - public final String vendorVersion; - public final String patches; - - public Runtime() { - this( - SystemProperties.get("java.version"), - SystemProperties.get("java.runtime.version"), - SystemProperties.get("java.runtime.name"), - SystemProperties.get("java.vm.vendor"), - SystemProperties.get("java.vendor.version")); - } - - @VisibleForTesting - Runtime(String javaVer, String rtVer, String name, String vendor, String vendorVersion) { - this.name = name == null ? "" : name; - this.vendor = vendor == null ? "" : vendor; - javaVer = javaVer == null ? "" : javaVer; - this.version = javaVer; - this.vendorVersion = vendorVersion == null ? "" : vendorVersion; - rtVer = javaVer.isEmpty() || rtVer == null ? javaVer : rtVer; - int patchStart = javaVer.length() + 1; - this.patches = (patchStart >= rtVer.length()) ? "" : rtVer.substring(javaVer.length() + 1); - } - } + public static boolean isJavaVersionBetween(int fromMajor, int fromMinor, int toMajor, int toMinor) { + return isJavaVersionBetween(fromMajor, fromMinor, 0, toMajor, toMinor, 0); + } + + /** + * Checks if the Java version is between {@code fromMajor.fromMinor.fromUpdate} (inclusive) and + * {@code toMajor.toMinor.toUpdate} (exclusive). + * + * @param fromMajor major from version (inclusive) + * @param fromMinor minor from version (inclusive) + * @param fromUpdate update from version (inclusive) + * @param toMajor major to version (exclusive) + * @param toMinor minor to version (exclusive) + * @param toUpdate update to version (exclusive) + * @return if the current java version is between the from version (inclusive) and the to version + * exclusive + */ + public static boolean isJavaVersionBetween( + int fromMajor, int fromMinor, int fromUpdate, int toMajor, int toMinor, int toUpdate) { + return javaVersion.isBetween(fromMajor, fromMinor, fromUpdate, toMajor, toMinor, toUpdate); + } + + /** + * Checks whether the current JVM is an Oracle JDK 8. + * + * @return {@code true} if the current JVM is an Oracle JDK 8, {@code false} otherwise. + */ + public static boolean isOracleJDK8() { + return isJavaVersion(8) && runtime.vendor.contains("Oracle") && !runtime.name.contains("OpenJDK"); + } + + public static boolean isHotspot() { + String prop = SystemProperties.getOrDefault("java.vm.name", ""); + if (prop.isEmpty()) { + return false; + } + return prop.contains("OpenJDK") + || prop.contains("HotSpot") + || prop.contains("GraalVM") + || prop.contains("Dynamic Code Evolution"); + } + + public static boolean isJ9() { + return SystemProperties.getOrDefault("java.vm.name", "").contains("J9"); + } + + public static boolean isIbm() { + return runtime.vendor.contains("IBM"); + } + + public static boolean isIbm8() { + return isIbm() && isJavaVersion(8); + } + + public static boolean isZulu8() { + return runtime.vendor.contains("Azul") && isJavaVersion(8); + } + + public static boolean isGraalVM() { + return runtime.vendorVersion.toLowerCase().contains("graalvm"); + } + + public static String getLangVersion() { + return String.valueOf(javaVersion.major); + } + + public static String getRuntimeVendor() { + return runtime.vendor; + } + + public static String getRuntimeVersion() { + return runtime.version; + } + + public static String getRuntimePatches() { + return runtime.patches; + } + + /** + * Gets the JVM options. + * + * @return The JVM options, an empty collection if they can't be retrieved. + */ + public static List getVmOptions() { + return JvmOptionsHolder.JVM_OPTIONS.VM_OPTIONS; + } + + /** + * Gets the command arguments. + * + * @return The command arguments, an empty collection if missing or can't be retrieved. + */ + public static List getCommandArguments() { + return commandLine.arguments; + } + + /** + * Gets the JVM runtime main class name. + * + * @return The JVM runtime main class name, {@code null} if using JAR file instead or can't be + * retrieved. + */ + public static @Nullable String getMainClass() { + return commandLine.name != null && !isJarName(commandLine.name) ? commandLine.name : null; + } + + /** + * Gets the JVM runtime jar file. + * + * @return The JVM runtime jar file, {@code null} if using main class instead or can't be + * retrieved. + */ + public static @Nullable String getJarFile() { + return commandLine.name != null && isJarName(commandLine.name) ? commandLine.name : null; + } + + private static boolean isJarName(String argument) { + return argument.toLowerCase(Locale.ROOT).endsWith(".jar"); + } + + private static class JvmOptionsHolder { + private static final JvmOptions JVM_OPTIONS = new JvmOptions(); + } + + static final class Runtime { + /* + * Example: + * jvm -> "AdoptOpenJDK 1.8.0_265-b01" + * + * name -> "OpenJDK" + * vendor -> "AdoptOpenJDK" + * version -> "1.8.0_265" + * patches -> "b01" + */ + public final String name; + + public final String vendor; + public final String version; + public final String vendorVersion; + public final String patches; + + public Runtime() { + this( + SystemProperties.get("java.version"), + SystemProperties.get("java.runtime.version"), + SystemProperties.get("java.runtime.name"), + SystemProperties.get("java.vm.vendor"), + SystemProperties.get("java.vendor.version")); + } + + @VisibleForTesting + Runtime(String javaVer, String rtVer, String name, String vendor, String vendorVersion) { + this.name = name == null ? "" : name; + this.vendor = vendor == null ? "" : vendor; + javaVer = javaVer == null ? "" : javaVer; + this.version = javaVer; + this.vendorVersion = vendorVersion == null ? "" : vendorVersion; + rtVer = javaVer.isEmpty() || rtVer == null ? javaVer : rtVer; + int patchStart = javaVer.length() + 1; + this.patches = (patchStart >= rtVer.length()) ? "" : rtVer.substring(javaVer.length() + 1); + } + } } diff --git a/components/environment/src/main/java/datadog/environment/JvmOptions.java b/components/environment/src/main/java/datadog/environment/JvmOptions.java index 7dd77e29ff9..f09ae7e6279 100644 --- a/components/environment/src/main/java/datadog/environment/JvmOptions.java +++ b/components/environment/src/main/java/datadog/environment/JvmOptions.java @@ -20,192 +20,192 @@ /** Fetches and captures the JVM options. */ class JvmOptions { - static final String JAVA_TOOL_OPTIONS = "JAVA_TOOL_OPTIONS"; - static final String JDK_JAVA_OPTIONS = "JDK_JAVA_OPTIONS"; - final String[] PROCFS_CMDLINE = readProcFsCmdLine(); - final List VM_OPTIONS = findVmOptions(); + static final String JAVA_TOOL_OPTIONS = "JAVA_TOOL_OPTIONS"; + static final String JDK_JAVA_OPTIONS = "JDK_JAVA_OPTIONS"; + final String[] PROCFS_CMDLINE = readProcFsCmdLine(); + final List VM_OPTIONS = findVmOptions(); - @SuppressForbidden // split on single-character uses a fast path - private String[] readProcFsCmdLine() { - if (isLinux()) { - try { - Path cmdlinePath = Paths.get("/proc/self/cmdline"); - if (Files.exists(cmdlinePath) && Files.isReadable(cmdlinePath)) { - try (BufferedReader in = Files.newBufferedReader(cmdlinePath)) { - return in.readLine().split("\0"); - } + @SuppressForbidden // split on single-character uses a fast path + private String[] readProcFsCmdLine() { + if (isLinux()) { + try { + Path cmdlinePath = Paths.get("/proc/self/cmdline"); + if (Files.exists(cmdlinePath) && Files.isReadable(cmdlinePath)) { + try (BufferedReader in = Files.newBufferedReader(cmdlinePath)) { + return in.readLine().split("\0"); + } + } + } catch (Throwable ignored) { + } } - } catch (Throwable ignored) { - } + return null; } - return null; - } - @SuppressForbidden // Class.forName() as backup - private List findVmOptions() { - // Try ProcFS on Linux - if (PROCFS_CMDLINE != null) { - return findVmOptionsFromProcFs(PROCFS_CMDLINE); - } + @SuppressForbidden // Class.forName() as backup + private List findVmOptions() { + // Try ProcFS on Linux + if (PROCFS_CMDLINE != null) { + return findVmOptionsFromProcFs(PROCFS_CMDLINE); + } - // Try Oracle-based - // IBM Semeru Runtime 1.8.0_345-b01 will throw UnsatisfiedLinkError here. - try { - final Class managementFactoryHelperClass = - Class.forName("sun.management.ManagementFactoryHelper"); - final Class vmManagementClass = Class.forName("sun.management.VMManagement"); + // Try Oracle-based + // IBM Semeru Runtime 1.8.0_345-b01 will throw UnsatisfiedLinkError here. + try { + final Class managementFactoryHelperClass = Class.forName("sun.management.ManagementFactoryHelper"); + final Class vmManagementClass = Class.forName("sun.management.VMManagement"); - Object vmManagement; - try { - vmManagement = - managementFactoryHelperClass.getDeclaredMethod("getVMManagement").invoke(null); - } catch (final Throwable e) { - // Older vm before getVMManagement() existed - final Field field = managementFactoryHelperClass.getDeclaredField("jvm"); - field.setAccessible(true); - vmManagement = field.get(null); - field.setAccessible(false); - } - //noinspection unchecked - return (List) vmManagementClass.getMethod("getVmArguments").invoke(vmManagement); - } catch (final Throwable ignored) { - // Ignored exception - } + Object vmManagement; + try { + vmManagement = managementFactoryHelperClass + .getDeclaredMethod("getVMManagement") + .invoke(null); + } catch (final Throwable e) { + // Older vm before getVMManagement() existed + final Field field = managementFactoryHelperClass.getDeclaredField("jvm"); + field.setAccessible(true); + vmManagement = field.get(null); + field.setAccessible(false); + } + //noinspection unchecked + return (List) vmManagementClass.getMethod("getVmArguments").invoke(vmManagement); + } catch (final Throwable ignored) { + // Ignored exception + } - // Try IBM-based. - try { - final Class VMClass = Class.forName("com.ibm.oti.vm.VM"); - final String[] argArray = (String[]) VMClass.getMethod("getVMArgs").invoke(null); - return asList(argArray); - } catch (final Throwable ignored) { - // Ignored exception - } + // Try IBM-based. + try { + final Class VMClass = Class.forName("com.ibm.oti.vm.VM"); + final String[] argArray = (String[]) VMClass.getMethod("getVMArgs").invoke(null); + return asList(argArray); + } catch (final Throwable ignored) { + // Ignored exception + } - // Fallback to default - try { - return ManagementFactory.getRuntimeMXBean().getInputArguments(); - } catch (final Throwable t) { - // Throws InvocationTargetException on modularized applications - // with a non-opened java.management module - System.err.println("WARNING: Unable to get VM args using managed beans"); + // Fallback to default + try { + return ManagementFactory.getRuntimeMXBean().getInputArguments(); + } catch (final Throwable t) { + // Throws InvocationTargetException on modularized applications + // with a non-opened java.management module + System.err.println("WARNING: Unable to get VM args using managed beans"); + } + return emptyList(); } - return emptyList(); - } - // Be aware that when running a native image, the command line in /proc/self/cmdline is just the - // executable - @VisibleForTesting - List findVmOptionsFromProcFs(String[] procfsCmdline) { - // Create the list of VM options - List vmOptions = new ArrayList<>(); - // Look for the first self-standing argument that is not prefixed with "-" or end of VM options - // while simultaneously, collect all arguments in the VM options - // Starts from 1 as 0 is the java command itself (or native-image) - for (int index = 1; index < procfsCmdline.length; index++) { - String argument = procfsCmdline[index]; - // Inflate arg files - if (argument.startsWith("@")) { - vmOptions.addAll(getArgumentsFromFile(argument)); - } - // Skip classpath argument (not part of VM options) - else if ("-cp".equals(argument)) { - index++; - } - // Check "-jar" or class name argument as the end of the VM options - else if ("-jar".equals(argument) || !argument.startsWith("-")) { - // End of VM options - break; - } - // Otherwise add as a VM option - else { - vmOptions.add(argument); - } - } - // Insert JDK_JAVA_OPTIONS at the start if present and supported - List jdkJavaOptions = getJdkJavaOptions(); - if (!jdkJavaOptions.isEmpty()) { - vmOptions.addAll(0, jdkJavaOptions); - } - // Insert JAVA_TOOL_OPTIONS at the start if present - List javaToolOptions = getJavaToolOptions(); - if (!javaToolOptions.isEmpty()) { - vmOptions.addAll(0, javaToolOptions); + // Be aware that when running a native image, the command line in /proc/self/cmdline is just the + // executable + @VisibleForTesting + List findVmOptionsFromProcFs(String[] procfsCmdline) { + // Create the list of VM options + List vmOptions = new ArrayList<>(); + // Look for the first self-standing argument that is not prefixed with "-" or end of VM options + // while simultaneously, collect all arguments in the VM options + // Starts from 1 as 0 is the java command itself (or native-image) + for (int index = 1; index < procfsCmdline.length; index++) { + String argument = procfsCmdline[index]; + // Inflate arg files + if (argument.startsWith("@")) { + vmOptions.addAll(getArgumentsFromFile(argument)); + } + // Skip classpath argument (not part of VM options) + else if ("-cp".equals(argument)) { + index++; + } + // Check "-jar" or class name argument as the end of the VM options + else if ("-jar".equals(argument) || !argument.startsWith("-")) { + // End of VM options + break; + } + // Otherwise add as a VM option + else { + vmOptions.add(argument); + } + } + // Insert JDK_JAVA_OPTIONS at the start if present and supported + List jdkJavaOptions = getJdkJavaOptions(); + if (!jdkJavaOptions.isEmpty()) { + vmOptions.addAll(0, jdkJavaOptions); + } + // Insert JAVA_TOOL_OPTIONS at the start if present + List javaToolOptions = getJavaToolOptions(); + if (!javaToolOptions.isEmpty()) { + vmOptions.addAll(0, javaToolOptions); + } + return vmOptions; } - return vmOptions; - } - private static List getArgumentsFromFile(String argFile) { - String filename = argFile.substring(1); - Path path = Paths.get(filename); - if (!Files.exists(path) || !Files.isReadable(path)) { - return singletonList(argFile); - } - List args = new ArrayList<>(); - try { - for (String line : Files.readAllLines(path)) { - // Use default delimiters that match argfiles separator specification - StringTokenizer tokenizer = new StringTokenizer(line); - while (tokenizer.hasMoreTokens()) { - args.add(tokenizer.nextToken()); + private static List getArgumentsFromFile(String argFile) { + String filename = argFile.substring(1); + Path path = Paths.get(filename); + if (!Files.exists(path) || !Files.isReadable(path)) { + return singletonList(argFile); + } + List args = new ArrayList<>(); + try { + for (String line : Files.readAllLines(path)) { + // Use default delimiters that match argfiles separator specification + StringTokenizer tokenizer = new StringTokenizer(line); + while (tokenizer.hasMoreTokens()) { + args.add(tokenizer.nextToken()); + } + } + return args; + } catch (IOException e) { + return singletonList(argFile); } - } - return args; - } catch (IOException e) { - return singletonList(argFile); } - } - private static List getJavaToolOptions() { - String javaToolOptions = EnvironmentVariables.getOrDefault(JAVA_TOOL_OPTIONS, ""); - return javaToolOptions.isEmpty() ? emptyList() : parseOptions(javaToolOptions); - } + private static List getJavaToolOptions() { + String javaToolOptions = EnvironmentVariables.getOrDefault(JAVA_TOOL_OPTIONS, ""); + return javaToolOptions.isEmpty() ? emptyList() : parseOptions(javaToolOptions); + } - private static List getJdkJavaOptions() { - if (!JavaVirtualMachine.isJavaVersionAtLeast(9)) { - return emptyList(); + private static List getJdkJavaOptions() { + if (!JavaVirtualMachine.isJavaVersionAtLeast(9)) { + return emptyList(); + } + String sdkToolOptions = EnvironmentVariables.getOrDefault(JDK_JAVA_OPTIONS, ""); + return sdkToolOptions.isEmpty() ? emptyList() : parseOptions(sdkToolOptions); } - String sdkToolOptions = EnvironmentVariables.getOrDefault(JDK_JAVA_OPTIONS, ""); - return sdkToolOptions.isEmpty() ? emptyList() : parseOptions(sdkToolOptions); - } - /** - * Parse the JAVA_TOOL_OPTIONS environment variable according the JVMTI specifications. - * - * @param javaToolOptions The JAVA_TOOL_OPTIONS environment variable. - * @return The parsed JVM options. - * @see JVMTI - * specifications - */ - static List parseOptions(String javaToolOptions) { - List options = new ArrayList<>(); - StringBuilder option = new StringBuilder(); - boolean inQuotes = false; - char quoteChar = 0; + /** + * Parse the JAVA_TOOL_OPTIONS environment variable according the JVMTI specifications. + * + * @param javaToolOptions The JAVA_TOOL_OPTIONS environment variable. + * @return The parsed JVM options. + * @see JVMTI + * specifications + */ + static List parseOptions(String javaToolOptions) { + List options = new ArrayList<>(); + StringBuilder option = new StringBuilder(); + boolean inQuotes = false; + char quoteChar = 0; - for (int i = 0; i < javaToolOptions.length(); i++) { - char c = javaToolOptions.charAt(i); - if (inQuotes) { - if (quoteChar == c) { - inQuotes = false; - } else { - option.append(c); + for (int i = 0; i < javaToolOptions.length(); i++) { + char c = javaToolOptions.charAt(i); + if (inQuotes) { + if (quoteChar == c) { + inQuotes = false; + } else { + option.append(c); + } + } else if (c == '"' || c == '\'') { + inQuotes = true; + quoteChar = c; + } else if (Character.isWhitespace(c)) { + if (option.length() > 0) { + options.add(option.toString()); + option.setLength(0); + } + } else { + option.append(c); + } } - } else if (c == '"' || c == '\'') { - inQuotes = true; - quoteChar = c; - } else if (Character.isWhitespace(c)) { if (option.length() > 0) { - options.add(option.toString()); - option.setLength(0); + options.add(option.toString()); } - } else { - option.append(c); - } - } - if (option.length() > 0) { - options.add(option.toString()); + return options; } - return options; - } } diff --git a/components/environment/src/main/java/datadog/environment/OperatingSystem.java b/components/environment/src/main/java/datadog/environment/OperatingSystem.java index 7e64e6bdb33..875f72e76e2 100644 --- a/components/environment/src/main/java/datadog/environment/OperatingSystem.java +++ b/components/environment/src/main/java/datadog/environment/OperatingSystem.java @@ -18,209 +18,211 @@ /** Detects operating systems and libc library. */ public final class OperatingSystem { - private static final String OS_NAME_PROPERTY = "os.name"; - private static final String OS_ARCH_PROPERTY = "os.arch"; - private static final Type TYPE = Type.current(); - private static final Architecture ARCHITECTURE = Architecture.current(); - - private OperatingSystem() {} - - /** - * Checks whether the operating system is Linux based. - * - * @return @{@code true} if operating system is Linux based, {@code false} otherwise. - */ - public static boolean isLinux() { - return TYPE == LINUX; - } - - /** - * Checks whether the operating system is Windows. - * - * @return @{@code true} if operating system is Windows, {@code false} otherwise. - */ - public static boolean isWindows() { - return TYPE == WINDOWS; - } - - /** - * Checks whether the operating system is macOS. - * - * @return @{@code true} if operating system is macOS, {@code false} otherwise. - */ - public static boolean isMacOs() { - return TYPE == MACOS; - } - - /** - * Gets the operating system type. - * - * @return The operating system type, {@link Type#UNKNOWN} if not properly detected or supported. - */ - public static Type type() { - return TYPE; - } - - /** Gets the operating system architecture . */ - public static Architecture architecture() { - return ARCHITECTURE; - } - - /** - * Checks whether the libc is MUSL. - * - * @return {@code true} if the libc is MUSL, {@code false} otherwise. - */ - public static boolean isMusl() { - if (!isLinux()) { - return false; + private static final String OS_NAME_PROPERTY = "os.name"; + private static final String OS_ARCH_PROPERTY = "os.arch"; + private static final Type TYPE = Type.current(); + private static final Architecture ARCHITECTURE = Architecture.current(); + + private OperatingSystem() {} + + /** + * Checks whether the operating system is Linux based. + * + * @return @{@code true} if operating system is Linux based, {@code false} otherwise. + */ + public static boolean isLinux() { + return TYPE == LINUX; } - // check the Java exe then fall back to proc/self maps - try { - return isMuslJavaExecutable(); - } catch (IOException e) { - try { - return isMuslProcSelfMaps(); - } catch (IOException ignore) { - return false; - } + + /** + * Checks whether the operating system is Windows. + * + * @return @{@code true} if operating system is Windows, {@code false} otherwise. + */ + public static boolean isWindows() { + return TYPE == WINDOWS; } - } - - private static boolean isMuslProcSelfMaps() throws IOException { - try (BufferedReader reader = new BufferedReader(new FileReader("/proc/self/maps"))) { - String line; - while ((line = reader.readLine()) != null) { - if (line.contains("-musl-")) { - return true; + + /** + * Checks whether the operating system is macOS. + * + * @return @{@code true} if operating system is macOS, {@code false} otherwise. + */ + public static boolean isMacOs() { + return TYPE == MACOS; + } + + /** + * Gets the operating system type. + * + * @return The operating system type, {@link Type#UNKNOWN} if not properly detected or supported. + */ + public static Type type() { + return TYPE; + } + + /** Gets the operating system architecture . */ + public static Architecture architecture() { + return ARCHITECTURE; + } + + /** + * Checks whether the libc is MUSL. + * + * @return {@code true} if the libc is MUSL, {@code false} otherwise. + */ + public static boolean isMusl() { + if (!isLinux()) { + return false; } - if (line.contains("/libc.")) { - return false; + // check the Java exe then fall back to proc/self maps + try { + return isMuslJavaExecutable(); + } catch (IOException e) { + try { + return isMuslProcSelfMaps(); + } catch (IOException ignore) { + return false; + } } - } } - return false; - } - - /** - * There is information about the linking in the ELF file. Since properly parsing ELF is not - * trivial this code will attempt a brute-force approach and will scan the first 4096 bytes of the - * 'java' program image for anything prefixed with `/ld-` - in practice this will contain - * `/ld-musl` for musl systems and probably something else for non-musl systems (e.g. - * `/ld-linux-...`). However, if such string is missing should indicate that the system is not a - * musl one. - */ - private static boolean isMuslJavaExecutable() throws IOException { - byte[] magic = new byte[] {(byte) 0x7f, (byte) 'E', (byte) 'L', (byte) 'F'}; - byte[] prefix = new byte[] {(byte) '/', (byte) 'l', (byte) 'd', (byte) '-'}; // '/ld-*' - byte[] musl = new byte[] {(byte) 'm', (byte) 'u', (byte) 's', (byte) 'l'}; // 'musl' - - Path binary = Paths.get(SystemProperties.getOrDefault("java.home", ""), "bin", "java"); - byte[] buffer = new byte[4096]; - - try (InputStream is = Files.newInputStream(binary)) { - int read = is.read(buffer, 0, 4); - if (read != 4 || !containsArray(buffer, 0, magic)) { - throw new IOException(Arrays.toString(buffer)); - } - read = is.read(buffer); - if (read <= 0) { - throw new IOException(); - } - int prefixPos = 0; - for (int i = 0; i < read; i++) { - if (buffer[i] == prefix[prefixPos]) { - if (++prefixPos == prefix.length) { - return containsArray(buffer, i + 1, musl); - } - } else { - prefixPos = 0; + + private static boolean isMuslProcSelfMaps() throws IOException { + try (BufferedReader reader = new BufferedReader(new FileReader("/proc/self/maps"))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("-musl-")) { + return true; + } + if (line.contains("/libc.")) { + return false; + } + } } - } + return false; } - return false; - } - private static boolean containsArray(byte[] container, int offset, byte[] contained) { - for (int i = 0; i < contained.length; i++) { - int leftPos = offset + i; - if (leftPos >= container.length) { - return false; - } - if (container[leftPos] != contained[i]) { + /** + * There is information about the linking in the ELF file. Since properly parsing ELF is not + * trivial this code will attempt a brute-force approach and will scan the first 4096 bytes of the + * 'java' program image for anything prefixed with `/ld-` - in practice this will contain + * `/ld-musl` for musl systems and probably something else for non-musl systems (e.g. + * `/ld-linux-...`). However, if such string is missing should indicate that the system is not a + * musl one. + */ + private static boolean isMuslJavaExecutable() throws IOException { + byte[] magic = new byte[] {(byte) 0x7f, (byte) 'E', (byte) 'L', (byte) 'F'}; + byte[] prefix = new byte[] {(byte) '/', (byte) 'l', (byte) 'd', (byte) '-'}; // '/ld-*' + byte[] musl = new byte[] {(byte) 'm', (byte) 'u', (byte) 's', (byte) 'l'}; // 'musl' + + Path binary = Paths.get(SystemProperties.getOrDefault("java.home", ""), "bin", "java"); + byte[] buffer = new byte[4096]; + + try (InputStream is = Files.newInputStream(binary)) { + int read = is.read(buffer, 0, 4); + if (read != 4 || !containsArray(buffer, 0, magic)) { + throw new IOException(Arrays.toString(buffer)); + } + read = is.read(buffer); + if (read <= 0) { + throw new IOException(); + } + int prefixPos = 0; + for (int i = 0; i < read; i++) { + if (buffer[i] == prefix[prefixPos]) { + if (++prefixPos == prefix.length) { + return containsArray(buffer, i + 1, musl); + } + } else { + prefixPos = 0; + } + } + } return false; - } } - return true; - } - public enum Type { - WINDOWS("Windows"), - MACOS("MacOS"), - LINUX("Linux"), - UNKNOWN("unknown"); + private static boolean containsArray(byte[] container, int offset, byte[] contained) { + for (int i = 0; i < contained.length; i++) { + int leftPos = offset + i; + if (leftPos >= container.length) { + return false; + } + if (container[leftPos] != contained[i]) { + return false; + } + } + return true; + } - private final String name; + public enum Type { + WINDOWS("Windows"), + MACOS("MacOS"), + LINUX("Linux"), + UNKNOWN("unknown"); - Type(String name) { - this.name = name; - } + private final String name; - static Type current() { - String property = SystemProperties.getOrDefault(OS_NAME_PROPERTY, "").toLowerCase(ROOT); - // https://mkyong.com/java/how-to-detect-os-in-java-systemgetpropertyosname/ - if (property.contains("linux")) { - return LINUX; - } else if (property.contains("win")) { - return WINDOWS; - } else if (property.contains("mac")) { - return MACOS; - } else { - return UNKNOWN; - } - } + Type(String name) { + this.name = name; + } - @Override - public String toString() { - return this.name; + static Type current() { + String property = + SystemProperties.getOrDefault(OS_NAME_PROPERTY, "").toLowerCase(ROOT); + // https://mkyong.com/java/how-to-detect-os-in-java-systemgetpropertyosname/ + if (property.contains("linux")) { + return LINUX; + } else if (property.contains("win")) { + return WINDOWS; + } else if (property.contains("mac")) { + return MACOS; + } else { + return UNKNOWN; + } + } + + @Override + public String toString() { + return this.name; + } } - } - /** Detects the operating system architecture. */ - public enum Architecture { - X64("x86_64", "amd64", "k8"), - X86("x86", "i386", "i486", "i586", "i686"), - ARM("arm", "aarch32"), - ARM64("arm64", "aarch64"), - UNKNOWN(); + /** Detects the operating system architecture. */ + public enum Architecture { + X64("x86_64", "amd64", "k8"), + X86("x86", "i386", "i486", "i586", "i686"), + ARM("arm", "aarch32"), + ARM64("arm64", "aarch64"), + UNKNOWN(); - private final Set identifiers; + private final Set identifiers; - Architecture(String... identifiers) { - this.identifiers = new HashSet<>(Arrays.asList(identifiers)); - } + Architecture(String... identifiers) { + this.identifiers = new HashSet<>(Arrays.asList(identifiers)); + } - static Architecture of(String identifier) { - for (Architecture architecture : Architecture.values()) { - if (architecture.identifiers.contains(identifier)) { - return architecture; + static Architecture of(String identifier) { + for (Architecture architecture : Architecture.values()) { + if (architecture.identifiers.contains(identifier)) { + return architecture; + } + } + return UNKNOWN; } - } - return UNKNOWN; - } - static Architecture current() { - String property = SystemProperties.getOrDefault(OS_ARCH_PROPERTY, "").toLowerCase(ROOT); - return Architecture.of(property); - } + static Architecture current() { + String property = + SystemProperties.getOrDefault(OS_ARCH_PROPERTY, "").toLowerCase(ROOT); + return Architecture.of(property); + } - /** - * Checks whether the architecture is arm64. - * - * @return {@code true} if architecture is arm64, {@code false} otherwise. - */ - public boolean isArm64() { - return this == ARM64; + /** + * Checks whether the architecture is arm64. + * + * @return {@code true} if architecture is arm64, {@code false} otherwise. + */ + public boolean isArm64() { + return this == ARM64; + } } - } } diff --git a/components/environment/src/main/java/datadog/environment/SystemProperties.java b/components/environment/src/main/java/datadog/environment/SystemProperties.java index dde075fbf4d..10969407685 100644 --- a/components/environment/src/main/java/datadog/environment/SystemProperties.java +++ b/components/environment/src/main/java/datadog/environment/SystemProperties.java @@ -14,91 +14,91 @@ * Manager */ public final class SystemProperties { - private SystemProperties() {} + private SystemProperties() {} - /** - * Gets a system property value. - * - * @param property The system property name. - * @return The system property value, {@code null} if missing, can't be retrieved, or the system - * property name is {@code null}. - */ - public static @Nullable String get(String property) { - return getOrDefault(property, null); - } - - /** - * Gets a system property value, or default value if missing or can't be retrieved. - * - * @param property The system property name. - * @param defaultValue The default value to return if the system property is missing or can't be - * retrieved. - * @return The system property value, {@code defaultValue} if missing, can't be retrieved, or the - * system property name is {@code null}. - */ - public static String getOrDefault(String property, String defaultValue) { - if (property == null) { - return defaultValue; - } - try { - return System.getProperty(property, defaultValue); - } catch (SecurityException ignored) { - return defaultValue; + /** + * Gets a system property value. + * + * @param property The system property name. + * @return The system property value, {@code null} if missing, can't be retrieved, or the system + * property name is {@code null}. + */ + public static @Nullable String get(String property) { + return getOrDefault(property, null); } - } - /** - * Convert system properties to an unmodifiable {@link Map}. - * - * @return All system properties captured in an unmodifiable {@link Map}, or an empty {@link Map} - * if they can't be retrieved. - */ - public static Map asStringMap() { - try { - Map map = new HashMap<>(); - for (String propertyName : System.getProperties().stringPropertyNames()) { - map.put(propertyName, System.getProperty(propertyName)); - } - return unmodifiableMap(map); - } catch (SecurityException ignored) { - return emptyMap(); + /** + * Gets a system property value, or default value if missing or can't be retrieved. + * + * @param property The system property name. + * @param defaultValue The default value to return if the system property is missing or can't be + * retrieved. + * @return The system property value, {@code defaultValue} if missing, can't be retrieved, or the + * system property name is {@code null}. + */ + public static String getOrDefault(String property, String defaultValue) { + if (property == null) { + return defaultValue; + } + try { + return System.getProperty(property, defaultValue); + } catch (SecurityException ignored) { + return defaultValue; + } } - } - /** - * Sets a system property value. - * - * @param property The system property name. - * @param value The system property value to set. - * @return {@code true} if the system property was successfully set, {@code} false otherwise. - */ - public static boolean set(String property, String value) { - if (property == null || value == null) { - return false; + /** + * Convert system properties to an unmodifiable {@link Map}. + * + * @return All system properties captured in an unmodifiable {@link Map}, or an empty {@link Map} + * if they can't be retrieved. + */ + public static Map asStringMap() { + try { + Map map = new HashMap<>(); + for (String propertyName : System.getProperties().stringPropertyNames()) { + map.put(propertyName, System.getProperty(propertyName)); + } + return unmodifiableMap(map); + } catch (SecurityException ignored) { + return emptyMap(); + } } - try { - System.setProperty(property, value); - return true; - } catch (SecurityException ignored) { - return false; - } - } - /** - * Clears a system property. - * - * @param property The system property name to clear. - * @return The previous value of the system property, {@code null} if there was no prior property - * and property can't be cleared. - */ - public static @Nullable String clear(String property) { - if (property == null) { - return null; + /** + * Sets a system property value. + * + * @param property The system property name. + * @param value The system property value to set. + * @return {@code true} if the system property was successfully set, {@code} false otherwise. + */ + public static boolean set(String property, String value) { + if (property == null || value == null) { + return false; + } + try { + System.setProperty(property, value); + return true; + } catch (SecurityException ignored) { + return false; + } } - try { - return System.clearProperty(property); - } catch (SecurityException ignored) { - return null; + + /** + * Clears a system property. + * + * @param property The system property name to clear. + * @return The previous value of the system property, {@code null} if there was no prior property + * and property can't be cleared. + */ + public static @Nullable String clear(String property) { + if (property == null) { + return null; + } + try { + return System.clearProperty(property); + } catch (SecurityException ignored) { + return null; + } } - } } diff --git a/components/environment/src/main/java/datadog/environment/ThreadSupport.java b/components/environment/src/main/java/datadog/environment/ThreadSupport.java index 4c3221ceca1..a85b1eb49d8 100644 --- a/components/environment/src/main/java/datadog/environment/ThreadSupport.java +++ b/components/environment/src/main/java/datadog/environment/ThreadSupport.java @@ -16,127 +16,125 @@ * overhead. */ public final class ThreadSupport { - static final MethodHandle THREAD_ID_MH = findThreadIdMethodHandle(); - static final MethodHandle IS_VIRTUAL_MH = findIsVirtualMethodHandle(); - static final MethodHandle NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR_MH = - findNewVirtualThreadPerTaskExecutorMethodHandle(); + static final MethodHandle THREAD_ID_MH = findThreadIdMethodHandle(); + static final MethodHandle IS_VIRTUAL_MH = findIsVirtualMethodHandle(); + static final MethodHandle NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR_MH = + findNewVirtualThreadPerTaskExecutorMethodHandle(); - private ThreadSupport() {} + private ThreadSupport() {} - /** - * Provides the best identifier available for the current {@link Thread}. Uses {@code - * Thread.threadId()} on 19+ or {@link Thread#getId()} on older JVMs. - * - * @return The best identifier available for the current {@link Thread}. - */ - public static long threadId() { - return threadId(Thread.currentThread()); - } + /** + * Provides the best identifier available for the current {@link Thread}. Uses {@code + * Thread.threadId()} on 19+ or {@link Thread#getId()} on older JVMs. + * + * @return The best identifier available for the current {@link Thread}. + */ + public static long threadId() { + return threadId(Thread.currentThread()); + } - /** - * Provides the best identifier available for the given {@link Thread}. Uses {@code - * Thread.threadId()} on 19+ or {@link Thread#getId()} on older JVMs. - * - * @return The best identifier available for the given {@link Thread}. - */ - public static long threadId(Thread thread) { - if (THREAD_ID_MH != null) { - try { - return (long) THREAD_ID_MH.invoke(thread); - } catch (Throwable ignored) { - } + /** + * Provides the best identifier available for the given {@link Thread}. Uses {@code + * Thread.threadId()} on 19+ or {@link Thread#getId()} on older JVMs. + * + * @return The best identifier available for the given {@link Thread}. + */ + public static long threadId(Thread thread) { + if (THREAD_ID_MH != null) { + try { + return (long) THREAD_ID_MH.invoke(thread); + } catch (Throwable ignored) { + } + } + return thread.getId(); } - return thread.getId(); - } - /** - * Checks whether virtual threads are supported on this JVM. - * - * @return {@code true} if virtual threads are supported, {@code false} otherwise. - */ - public static boolean supportsVirtualThreads() { - return (IS_VIRTUAL_MH != null); - } + /** + * Checks whether virtual threads are supported on this JVM. + * + * @return {@code true} if virtual threads are supported, {@code false} otherwise. + */ + public static boolean supportsVirtualThreads() { + return (IS_VIRTUAL_MH != null); + } - /** - * Checks whether the current thread is a virtual thread. - * - * @return {@code true} if the current thread is a virtual thread, {@code false} otherwise. - */ - public static boolean isVirtual() { - // IS_VIRTUAL_MH will constant propagate -- then dead code eliminate -- and inline as needed - return IS_VIRTUAL_MH != null && isVirtual(Thread.currentThread()); - } + /** + * Checks whether the current thread is a virtual thread. + * + * @return {@code true} if the current thread is a virtual thread, {@code false} otherwise. + */ + public static boolean isVirtual() { + // IS_VIRTUAL_MH will constant propagate -- then dead code eliminate -- and inline as needed + return IS_VIRTUAL_MH != null && isVirtual(Thread.currentThread()); + } - /** - * Checks whether the given thread is a virtual thread. - * - * @param thread The thread to check. - * @return {@code true} if the given thread is virtual, {@code false} otherwise. - */ - public static boolean isVirtual(Thread thread) { - // IS_VIRTUAL_MH will constant propagate -- then dead code eliminate -- and inline as needed - if (IS_VIRTUAL_MH != null) { - try { - return (boolean) IS_VIRTUAL_MH.invoke(thread); - } catch (Throwable ignored) { - } + /** + * Checks whether the given thread is a virtual thread. + * + * @param thread The thread to check. + * @return {@code true} if the given thread is virtual, {@code false} otherwise. + */ + public static boolean isVirtual(Thread thread) { + // IS_VIRTUAL_MH will constant propagate -- then dead code eliminate -- and inline as needed + if (IS_VIRTUAL_MH != null) { + try { + return (boolean) IS_VIRTUAL_MH.invoke(thread); + } catch (Throwable ignored) { + } + } + return false; } - return false; - } - /** - * Returns the virtual thread per task executor if available. - * - * @return The virtual thread per task executor if available wrapped into an {@link Optional}, or - * {@link Optional#empty()} otherwise. - */ - public static Optional newVirtualThreadPerTaskExecutor() { - if (NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR_MH != null) { - try { - ExecutorService executorService = - (ExecutorService) NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR_MH.invoke(); - return Optional.of(executorService); - } catch (Throwable ignored) { - } + /** + * Returns the virtual thread per task executor if available. + * + * @return The virtual thread per task executor if available wrapped into an {@link Optional}, or + * {@link Optional#empty()} otherwise. + */ + public static Optional newVirtualThreadPerTaskExecutor() { + if (NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR_MH != null) { + try { + ExecutorService executorService = (ExecutorService) NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR_MH.invoke(); + return Optional.of(executorService); + } catch (Throwable ignored) { + } + } + return Optional.empty(); } - return Optional.empty(); - } - private static MethodHandle findThreadIdMethodHandle() { - if (JavaVirtualMachine.isJavaVersionAtLeast(19)) { - try { - return MethodHandles.lookup() - .findVirtual(Thread.class, "threadId", MethodType.methodType(long.class)); - } catch (Throwable ignored) { + private static MethodHandle findThreadIdMethodHandle() { + if (JavaVirtualMachine.isJavaVersionAtLeast(19)) { + try { + return MethodHandles.lookup().findVirtual(Thread.class, "threadId", MethodType.methodType(long.class)); + } catch (Throwable ignored) { + return null; + } + } return null; - } } - return null; - } - private static MethodHandle findIsVirtualMethodHandle() { - if (JavaVirtualMachine.isJavaVersionAtLeast(21)) { - try { - return MethodHandles.lookup() - .findVirtual(Thread.class, "isVirtual", MethodType.methodType(boolean.class)); - } catch (Throwable ignored) { - } + private static MethodHandle findIsVirtualMethodHandle() { + if (JavaVirtualMachine.isJavaVersionAtLeast(21)) { + try { + return MethodHandles.lookup() + .findVirtual(Thread.class, "isVirtual", MethodType.methodType(boolean.class)); + } catch (Throwable ignored) { + } + } + return null; } - return null; - } - private static MethodHandle findNewVirtualThreadPerTaskExecutorMethodHandle() { - if (JavaVirtualMachine.isJavaVersionAtLeast(21)) { - try { - return MethodHandles.lookup() - .findStatic( - Executors.class, - "newVirtualThreadPerTaskExecutor", - MethodType.methodType(ExecutorService.class)); - } catch (Throwable ignored) { - } + private static MethodHandle findNewVirtualThreadPerTaskExecutorMethodHandle() { + if (JavaVirtualMachine.isJavaVersionAtLeast(21)) { + try { + return MethodHandles.lookup() + .findStatic( + Executors.class, + "newVirtualThreadPerTaskExecutor", + MethodType.methodType(ExecutorService.class)); + } catch (Throwable ignored) { + } + } + return null; } - return null; - } } diff --git a/components/environment/src/test/java/datadog/environment/CommandLineHelper.java b/components/environment/src/test/java/datadog/environment/CommandLineHelper.java index d2e17ba0150..43c9767b417 100644 --- a/components/environment/src/test/java/datadog/environment/CommandLineHelper.java +++ b/components/environment/src/test/java/datadog/environment/CommandLineHelper.java @@ -12,125 +12,121 @@ import java.util.Map; class CommandLineHelper { - private static final String JVM_OPTIONS_MARKER = "-- JVM OPTIONS --"; - private static final String MAIN_CLASS_MARKER = "-- MAIN CLASS --"; - private static final String CMD_ARGUMENTS_MARKER = "-- CMD ARGUMENTS --"; - private static final String REAL_CMD_ARGUMENTS_MARKER = "-- REAL CMD ARGUMENTS --"; - static final String TEST_PROCESS_CLASS_NAME = CommandLineTestProcess.class.getName(); + private static final String JVM_OPTIONS_MARKER = "-- JVM OPTIONS --"; + private static final String MAIN_CLASS_MARKER = "-- MAIN CLASS --"; + private static final String CMD_ARGUMENTS_MARKER = "-- CMD ARGUMENTS --"; + private static final String REAL_CMD_ARGUMENTS_MARKER = "-- REAL CMD ARGUMENTS --"; + static final String TEST_PROCESS_CLASS_NAME = CommandLineTestProcess.class.getName(); - static Result forkAndRunWithArgs(RunArguments arguments) - throws IOException, InterruptedException { - return forkAndRunWithArgs(arguments, emptyMap()); - } + static Result forkAndRunWithArgs(RunArguments arguments) throws IOException, InterruptedException { + return forkAndRunWithArgs(arguments, emptyMap()); + } - static Result forkAndRunWithArgs(RunArguments arguments, Map environmentVariables) - throws IOException, InterruptedException { - // Build the command to run a new Java process - List command = new ArrayList<>(); - command.add(System.getProperty("java.home") + separator + "bin" + separator + "java"); - command.addAll(arguments.jvmOptions); - command.add("-cp"); - command.add(System.getProperty("java.class.path")); - command.add(CommandLineTestProcess.class.getName()); - command.addAll(arguments.cmdArgs); - // Start the process - ProcessBuilder processBuilder = new ProcessBuilder(command); - processBuilder.environment().putAll(environmentVariables); - Process process = processBuilder.start(); - // Read and parse output and error streams - Result result = new Result(); - List current = null; - String output = ""; - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - if (JVM_OPTIONS_MARKER.equals(line)) { - current = result.jvmOptions; - } else if (MAIN_CLASS_MARKER.equals(line)) { - current = result.mainClasses; - } else if (CMD_ARGUMENTS_MARKER.equals(line)) { - current = result.cmdArgs; - } else if (REAL_CMD_ARGUMENTS_MARKER.equals(line)) { - current = result.realCmdArgs; - } else if (current != null) { - current.add(line); + static Result forkAndRunWithArgs(RunArguments arguments, Map environmentVariables) + throws IOException, InterruptedException { + // Build the command to run a new Java process + List command = new ArrayList<>(); + command.add(System.getProperty("java.home") + separator + "bin" + separator + "java"); + command.addAll(arguments.jvmOptions); + command.add("-cp"); + command.add(System.getProperty("java.class.path")); + command.add(CommandLineTestProcess.class.getName()); + command.addAll(arguments.cmdArgs); + // Start the process + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.environment().putAll(environmentVariables); + Process process = processBuilder.start(); + // Read and parse output and error streams + Result result = new Result(); + List current = null; + String output = ""; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (JVM_OPTIONS_MARKER.equals(line)) { + current = result.jvmOptions; + } else if (MAIN_CLASS_MARKER.equals(line)) { + current = result.mainClasses; + } else if (CMD_ARGUMENTS_MARKER.equals(line)) { + current = result.cmdArgs; + } else if (REAL_CMD_ARGUMENTS_MARKER.equals(line)) { + current = result.realCmdArgs; + } else if (current != null) { + current.add(line); + } + output += line + "\n"; + } + // Drop classpath from JVM options as only supported by /proc/fs but not the fallbacks + int indexOfCp; + if ((indexOfCp = result.jvmOptions.indexOf("-cp")) != -1 && indexOfCp < result.jvmOptions.size()) { + // Remove both "-cp" and the classpath + result.jvmOptions.remove(indexOfCp); + result.jvmOptions.remove(indexOfCp); + } } - output += line + "\n"; - } - // Drop classpath from JVM options as only supported by /proc/fs but not the fallbacks - int indexOfCp; - if ((indexOfCp = result.jvmOptions.indexOf("-cp")) != -1 - && indexOfCp < result.jvmOptions.size()) { - // Remove both "-cp" and the classpath - result.jvmOptions.remove(indexOfCp); - result.jvmOptions.remove(indexOfCp); - } - } - String error = ""; - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String line; - while ((line = reader.readLine()) != null) { - error += line + "\n"; - } - } - // Wait for the process to complete - int exitCode = process.waitFor(); - // Dumping state on error - if (exitCode != 0) { - System.err.println("Error running command: " + String.join(" ", command)); - System.err.println("Exit code " + exitCode + " with output:"); - System.err.println(output); - System.err.println("and error:"); - System.err.println(error); + String error = ""; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = reader.readLine()) != null) { + error += line + "\n"; + } + } + // Wait for the process to complete + int exitCode = process.waitFor(); + // Dumping state on error + if (exitCode != 0) { + System.err.println("Error running command: " + String.join(" ", command)); + System.err.println("Exit code " + exitCode + " with output:"); + System.err.println(output); + System.err.println("and error:"); + System.err.println(error); + } + assertEquals(0, exitCode, "Process should exit normally"); + return result; } - assertEquals(0, exitCode, "Process should exit normally"); - return result; - } - static class RunArguments { - List jvmOptions = new ArrayList<>(); - List cmdArgs = new ArrayList<>(); + static class RunArguments { + List jvmOptions = new ArrayList<>(); + List cmdArgs = new ArrayList<>(); - static RunArguments of(List jvmArgs, List cmdArgs) { - RunArguments arguments = new RunArguments(); - arguments.jvmOptions = jvmArgs; - arguments.cmdArgs = cmdArgs; - return arguments; + static RunArguments of(List jvmArgs, List cmdArgs) { + RunArguments arguments = new RunArguments(); + arguments.jvmOptions = jvmArgs; + arguments.cmdArgs = cmdArgs; + return arguments; + } } - } - static class Result extends RunArguments { - List realCmdArgs = new ArrayList<>(); - List mainClasses = new ArrayList<>(); + static class Result extends RunArguments { + List realCmdArgs = new ArrayList<>(); + List mainClasses = new ArrayList<>(); - String mainClass() { - return String.join(",", this.mainClasses); + String mainClass() { + return String.join(",", this.mainClasses); + } } - } - // This class will be executed in the subprocess - public static class CommandLineTestProcess { - public static void main(String[] args) { - // Print each VM argument on a new line - System.out.println(JVM_OPTIONS_MARKER); - for (String option : JavaVirtualMachine.getVmOptions()) { - System.out.println(option); - } - // Print main class - System.out.println(MAIN_CLASS_MARKER); - System.out.println(JavaVirtualMachine.getMainClass()); - // Print each command argument on a new line - System.out.println(CMD_ARGUMENTS_MARKER); - for (String arg : JavaVirtualMachine.getCommandArguments()) { - System.out.println(arg); - } - // Print each real command argument on a new line - System.out.println(REAL_CMD_ARGUMENTS_MARKER); - for (String arg : args) { - System.out.println(arg); - } + // This class will be executed in the subprocess + public static class CommandLineTestProcess { + public static void main(String[] args) { + // Print each VM argument on a new line + System.out.println(JVM_OPTIONS_MARKER); + for (String option : JavaVirtualMachine.getVmOptions()) { + System.out.println(option); + } + // Print main class + System.out.println(MAIN_CLASS_MARKER); + System.out.println(JavaVirtualMachine.getMainClass()); + // Print each command argument on a new line + System.out.println(CMD_ARGUMENTS_MARKER); + for (String arg : JavaVirtualMachine.getCommandArguments()) { + System.out.println(arg); + } + // Print each real command argument on a new line + System.out.println(REAL_CMD_ARGUMENTS_MARKER); + for (String arg : args) { + System.out.println(arg); + } + } } - } } diff --git a/components/environment/src/test/java/datadog/environment/CommandLineTest.java b/components/environment/src/test/java/datadog/environment/CommandLineTest.java index b1673b80a29..4aab9ccfdc9 100644 --- a/components/environment/src/test/java/datadog/environment/CommandLineTest.java +++ b/components/environment/src/test/java/datadog/environment/CommandLineTest.java @@ -27,8 +27,8 @@ class CommandLineTest { - static Stream data() { - // spotless:off + static Stream data() { + // spotless:off return Stream.of( arguments( "No JVM options nor command argument", @@ -65,73 +65,71 @@ static Stream data() { of(flatten("-Dtest.property=value", expectedArsFromArgFile("tab-separated")), asList("arg1", "arg2"))) ); // spotless:on - } - - @ParameterizedTest(name = "[{index}] {0}") - @MethodSource("data") - void testGetVmArguments(String useCase, RunArguments arguments, RunArguments expectedArguments) - throws Exception { - // Skip unsupported test cases - skipArgFileTestOnJava8(arguments); - // Run test process - Result result = forkAndRunWithArgs(arguments); - // Check results - assertEquals(expectedArguments.jvmOptions, result.jvmOptions, "Failed to get JVM options"); - assertEquals(TEST_PROCESS_CLASS_NAME, result.mainClass(), "Failed to get main class"); - assertEquals(result.realCmdArgs, result.cmdArgs, "Failed to get command arguments"); - assertEquals(result.realCmdArgs, expectedArguments.cmdArgs, "Unexpected command arguments"); - } + } - private static void skipArgFileTestOnJava8(RunArguments arguments) { - boolean useArgFile = false; - for (String jvmOption : arguments.jvmOptions) { - if (jvmOption.startsWith("@")) { - useArgFile = true; - break; - } + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("data") + void testGetVmArguments(String useCase, RunArguments arguments, RunArguments expectedArguments) throws Exception { + // Skip unsupported test cases + skipArgFileTestOnJava8(arguments); + // Run test process + Result result = forkAndRunWithArgs(arguments); + // Check results + assertEquals(expectedArguments.jvmOptions, result.jvmOptions, "Failed to get JVM options"); + assertEquals(TEST_PROCESS_CLASS_NAME, result.mainClass(), "Failed to get main class"); + assertEquals(result.realCmdArgs, result.cmdArgs, "Failed to get command arguments"); + assertEquals(result.realCmdArgs, expectedArguments.cmdArgs, "Unexpected command arguments"); } - if (!useArgFile) { - for (String cmdArg : arguments.cmdArgs) { - if (cmdArg.startsWith("@")) { - useArgFile = true; - break; + + private static void skipArgFileTestOnJava8(RunArguments arguments) { + boolean useArgFile = false; + for (String jvmOption : arguments.jvmOptions) { + if (jvmOption.startsWith("@")) { + useArgFile = true; + break; + } + } + if (!useArgFile) { + for (String cmdArg : arguments.cmdArgs) { + if (cmdArg.startsWith("@")) { + useArgFile = true; + break; + } + } + } + if (useArgFile) { + assumeTrue(JavaVirtualMachine.isJavaVersionAtLeast(9)); } - } - } - if (useArgFile) { - assumeTrue(JavaVirtualMachine.isJavaVersionAtLeast(9)); } - } - private static String argFile(String name) { - return "@src/test/resources/argfiles/" + name + ".txt"; - } + private static String argFile(String name) { + return "@src/test/resources/argfiles/" + name + ".txt"; + } - private static List expectedArsFromArgFile(String name) { - List arguments = new ArrayList<>(); - try (InputStream stream = - requireNonNull( - CommandLineTest.class.getResourceAsStream("/argfiles/" + name + "-expected.txt")); - BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { - String line; - while ((line = reader.readLine()) != null) { - arguments.add(line); - } - } catch (IOException e) { - Assertions.fail("Failed to read expected args from " + name + "argfile", e); + private static List expectedArsFromArgFile(String name) { + List arguments = new ArrayList<>(); + try (InputStream stream = requireNonNull( + CommandLineTest.class.getResourceAsStream("/argfiles/" + name + "-expected.txt")); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + String line; + while ((line = reader.readLine()) != null) { + arguments.add(line); + } + } catch (IOException e) { + Assertions.fail("Failed to read expected args from " + name + "argfile", e); + } + return arguments; } - return arguments; - } - private static List flatten(Object... values) { - List result = new ArrayList<>(); - for (Object value : values) { - if (value instanceof Collection) { - result.addAll((Collection) value); - } else { - result.add(value.toString()); - } + private static List flatten(Object... values) { + List result = new ArrayList<>(); + for (Object value : values) { + if (value instanceof Collection) { + result.addAll((Collection) value); + } else { + result.add(value.toString()); + } + } + return result; } - return result; - } } diff --git a/components/environment/src/test/java/datadog/environment/EnvironmentVariablesTest.java b/components/environment/src/test/java/datadog/environment/EnvironmentVariablesTest.java index 98b0a0adf99..f655d20651e 100644 --- a/components/environment/src/test/java/datadog/environment/EnvironmentVariablesTest.java +++ b/components/environment/src/test/java/datadog/environment/EnvironmentVariablesTest.java @@ -11,41 +11,41 @@ import org.junit.jupiter.api.Test; class EnvironmentVariablesTest { - private static final String EXISTING_ENV_VAR = "PATH"; - private static final String MISSING_ENV_VAR = "UNDEFINED_ENV_VAR"; - private static final String DEFAULT_VALUE = "DEFAULT"; + private static final String EXISTING_ENV_VAR = "PATH"; + private static final String MISSING_ENV_VAR = "UNDEFINED_ENV_VAR"; + private static final String DEFAULT_VALUE = "DEFAULT"; - @Test - void testGet() { - // Existing environment variable - assertNotNull(EnvironmentVariables.get(EXISTING_ENV_VAR)); - // Missing environment variable - assertNull(EnvironmentVariables.get(MISSING_ENV_VAR)); - // Null values - assertDoesNotThrow(() -> EnvironmentVariables.get(null)); - assertNull(EnvironmentVariables.get(null)); - } + @Test + void testGet() { + // Existing environment variable + assertNotNull(EnvironmentVariables.get(EXISTING_ENV_VAR)); + // Missing environment variable + assertNull(EnvironmentVariables.get(MISSING_ENV_VAR)); + // Null values + assertDoesNotThrow(() -> EnvironmentVariables.get(null)); + assertNull(EnvironmentVariables.get(null)); + } - @Test - void testGetOrDefault() { - // Existing environment variable - assertNotNull(EnvironmentVariables.getOrDefault(EXISTING_ENV_VAR, null)); - // Missing environment variable - assertEquals(DEFAULT_VALUE, EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, DEFAULT_VALUE)); - assertNull(EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, null)); - // Null values - assertDoesNotThrow(() -> EnvironmentVariables.getOrDefault(null, DEFAULT_VALUE)); - assertEquals(DEFAULT_VALUE, EnvironmentVariables.getOrDefault(null, DEFAULT_VALUE)); - assertDoesNotThrow(() -> EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, null)); - assertNull(EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, null)); - } + @Test + void testGetOrDefault() { + // Existing environment variable + assertNotNull(EnvironmentVariables.getOrDefault(EXISTING_ENV_VAR, null)); + // Missing environment variable + assertEquals(DEFAULT_VALUE, EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, DEFAULT_VALUE)); + assertNull(EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, null)); + // Null values + assertDoesNotThrow(() -> EnvironmentVariables.getOrDefault(null, DEFAULT_VALUE)); + assertEquals(DEFAULT_VALUE, EnvironmentVariables.getOrDefault(null, DEFAULT_VALUE)); + assertDoesNotThrow(() -> EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, null)); + assertNull(EnvironmentVariables.getOrDefault(MISSING_ENV_VAR, null)); + } - @Test - void testGetAll() { - Map all = EnvironmentVariables.getAll(); - assertNotNull(all); - assertFalse(all.isEmpty()); - // Unmodifiable collection - assertThrows(UnsupportedOperationException.class, all::clear); - } + @Test + void testGetAll() { + Map all = EnvironmentVariables.getAll(); + assertNotNull(all); + assertFalse(all.isEmpty()); + // Unmodifiable collection + assertThrows(UnsupportedOperationException.class, all::clear); + } } diff --git a/components/environment/src/test/java/datadog/environment/JavaVersionTest.java b/components/environment/src/test/java/datadog/environment/JavaVersionTest.java index 9fcb3fc3a13..5405efdf4fb 100644 --- a/components/environment/src/test/java/datadog/environment/JavaVersionTest.java +++ b/components/environment/src/test/java/datadog/environment/JavaVersionTest.java @@ -12,92 +12,92 @@ import org.junit.jupiter.params.provider.MethodSource; class JavaVersionTest { - static Stream argumentsParsing() { - return Stream.of( - of("", 0, 0, 0), - of("a.0.0", 0, 0, 0), - of("0.a.0", 0, 0, 0), - of("0.0.a", 0, 0, 0), - of("1.a.0_0", 0, 0, 0), - of("1.8.a_0", 0, 0, 0), - of("1.8.0_a", 0, 0, 0), - of("1.7", 7, 0, 0), - of("1.7.0", 7, 0, 0), - of("1.7.0_221", 7, 0, 221), - of("1.8", 8, 0, 0), - of("1.8.0", 8, 0, 0), - of("1.8.0_212", 8, 0, 212), - of("1.8.0_292", 8, 0, 292), - of("9-ea", 9, 0, 0), - of("9.0.4", 9, 0, 4), - of("9.1.2", 9, 1, 2), - of("10.0.2", 10, 0, 2), - of("11", 11, 0, 0), - of("11.0.6", 11, 0, 6), - of("11.0.11", 11, 0, 11), - of("12.0.2", 12, 0, 2), - of("13.0.2", 13, 0, 2), - of("14", 14, 0, 0), - of("14.0.2", 14, 0, 2), - of("15", 15, 0, 0), - of("15.0.2", 15, 0, 2), - of("16.0.1", 16, 0, 1), - of("11.0.9.1+1", 11, 0, 9), - of("11.0.6+10", 11, 0, 6), - of("17.0.4-x", 17, 0, 4)); - } + static Stream argumentsParsing() { + return Stream.of( + of("", 0, 0, 0), + of("a.0.0", 0, 0, 0), + of("0.a.0", 0, 0, 0), + of("0.0.a", 0, 0, 0), + of("1.a.0_0", 0, 0, 0), + of("1.8.a_0", 0, 0, 0), + of("1.8.0_a", 0, 0, 0), + of("1.7", 7, 0, 0), + of("1.7.0", 7, 0, 0), + of("1.7.0_221", 7, 0, 221), + of("1.8", 8, 0, 0), + of("1.8.0", 8, 0, 0), + of("1.8.0_212", 8, 0, 212), + of("1.8.0_292", 8, 0, 292), + of("9-ea", 9, 0, 0), + of("9.0.4", 9, 0, 4), + of("9.1.2", 9, 1, 2), + of("10.0.2", 10, 0, 2), + of("11", 11, 0, 0), + of("11.0.6", 11, 0, 6), + of("11.0.11", 11, 0, 11), + of("12.0.2", 12, 0, 2), + of("13.0.2", 13, 0, 2), + of("14", 14, 0, 0), + of("14.0.2", 14, 0, 2), + of("15", 15, 0, 0), + of("15.0.2", 15, 0, 2), + of("16.0.1", 16, 0, 1), + of("11.0.9.1+1", 11, 0, 9), + of("11.0.6+10", 11, 0, 6), + of("17.0.4-x", 17, 0, 4)); + } - @ParameterizedTest(name = "[{index}] {0} parsed as {1}.{2}.{3}") - @MethodSource("argumentsParsing") - void testParsing(String version, int major, int minor, int update) { - JavaVersion javaVersion = JavaVersion.parseJavaVersion(version); + @ParameterizedTest(name = "[{index}] {0} parsed as {1}.{2}.{3}") + @MethodSource("argumentsParsing") + void testParsing(String version, int major, int minor, int update) { + JavaVersion javaVersion = JavaVersion.parseJavaVersion(version); - assertEquals(major, javaVersion.major); - assertEquals(minor, javaVersion.minor); - assertEquals(update, javaVersion.update); + assertEquals(major, javaVersion.major); + assertEquals(minor, javaVersion.minor); + assertEquals(update, javaVersion.update); - assertFalse(javaVersion.is(major - 1)); - assertTrue(javaVersion.is(major)); - assertFalse(javaVersion.is(major + 1)); + assertFalse(javaVersion.is(major - 1)); + assertTrue(javaVersion.is(major)); + assertFalse(javaVersion.is(major + 1)); - assertFalse(javaVersion.is(major, minor - 1)); - assertTrue(javaVersion.is(major, minor)); - assertFalse(javaVersion.is(major, minor + 1)); + assertFalse(javaVersion.is(major, minor - 1)); + assertTrue(javaVersion.is(major, minor)); + assertFalse(javaVersion.is(major, minor + 1)); - assertFalse(javaVersion.is(major, minor, update - 1)); - assertTrue(javaVersion.is(major, minor, update)); - assertFalse(javaVersion.is(major, minor, update + 1)); + assertFalse(javaVersion.is(major, minor, update - 1)); + assertTrue(javaVersion.is(major, minor, update)); + assertFalse(javaVersion.is(major, minor, update + 1)); - assertTrue(javaVersion.isAtLeast(major, minor, update)); + assertTrue(javaVersion.isAtLeast(major, minor, update)); - assertFalse(javaVersion.isBetween(major, minor, update, major, minor, update)); + assertFalse(javaVersion.isBetween(major, minor, update, major, minor, update)); - assertTrue(javaVersion.isBetween(major, minor, update, MAX_VALUE, MAX_VALUE, MAX_VALUE)); - assertTrue(javaVersion.isBetween(major, minor, update, major + 1, 0, 0)); - assertTrue(javaVersion.isBetween(major, minor, update, major, minor + 1, 0)); - assertTrue(javaVersion.isBetween(major, minor, update, major, minor, update + 1)); + assertTrue(javaVersion.isBetween(major, minor, update, MAX_VALUE, MAX_VALUE, MAX_VALUE)); + assertTrue(javaVersion.isBetween(major, minor, update, major + 1, 0, 0)); + assertTrue(javaVersion.isBetween(major, minor, update, major, minor + 1, 0)); + assertTrue(javaVersion.isBetween(major, minor, update, major, minor, update + 1)); - assertFalse(javaVersion.isBetween(major, minor, update, major - 1, 0, 0)); - assertFalse(javaVersion.isBetween(major, minor, update, major, minor - 1, 0)); - assertFalse(javaVersion.isBetween(major, minor, update, major, minor, update - 1)); - } + assertFalse(javaVersion.isBetween(major, minor, update, major - 1, 0, 0)); + assertFalse(javaVersion.isBetween(major, minor, update, major, minor - 1, 0)); + assertFalse(javaVersion.isBetween(major, minor, update, major, minor, update - 1)); + } - static Stream argumentsAtLeast() { - return Stream.of( - of("17.0.5+8", 17, 0, 5), - of("17.0.5", 17, 0, 5), - of("17.0.6+8", 17, 0, 5), - of("11.0.17+8", 11, 0, 17), - of("11.0.18+8", 11, 0, 17), - of("11.0.17", 11, 0, 17), - of("1.8.0_352", 8, 0, 352), - of("1.8.0_362", 8, 0, 352)); - } + static Stream argumentsAtLeast() { + return Stream.of( + of("17.0.5+8", 17, 0, 5), + of("17.0.5", 17, 0, 5), + of("17.0.6+8", 17, 0, 5), + of("11.0.17+8", 11, 0, 17), + of("11.0.18+8", 11, 0, 17), + of("11.0.17", 11, 0, 17), + of("1.8.0_352", 8, 0, 352), + of("1.8.0_362", 8, 0, 352)); + } - @ParameterizedTest(name = "[{index}] {0} is at least {1}.{2}.{3}") - @MethodSource("argumentsAtLeast") - void testIsAtLeast(String version, int major, int minor, int update) { - JavaVersion javaVersion = JavaVersion.parseJavaVersion(version); - assertTrue(javaVersion.isAtLeast(major, minor, update)); - } + @ParameterizedTest(name = "[{index}] {0} is at least {1}.{2}.{3}") + @MethodSource("argumentsAtLeast") + void testIsAtLeast(String version, int major, int minor, int update) { + JavaVersion javaVersion = JavaVersion.parseJavaVersion(version); + assertTrue(javaVersion.isAtLeast(major, minor, update)); + } } diff --git a/components/environment/src/test/java/datadog/environment/JavaVirtualMachineTest.java b/components/environment/src/test/java/datadog/environment/JavaVirtualMachineTest.java index 99edf00159d..196216d8d5a 100644 --- a/components/environment/src/test/java/datadog/environment/JavaVirtualMachineTest.java +++ b/components/environment/src/test/java/datadog/environment/JavaVirtualMachineTest.java @@ -18,164 +18,163 @@ import org.junit.jupiter.params.provider.CsvSource; class JavaVirtualMachineTest { - @Test - @EnabledOnJre(JAVA_8) - void onJava8Only() { - assertTrue(JavaVirtualMachine.isJavaVersion(8)); - assertFalse(JavaVirtualMachine.isJavaVersion(11)); - } - - @Test - @EnabledOnJre(JAVA_11) - void onJava11Only() { - assertFalse(JavaVirtualMachine.isJavaVersion(8)); - assertTrue(JavaVirtualMachine.isJavaVersion(11)); - assertFalse(JavaVirtualMachine.isJavaVersion(17)); - } - - @Test - @EnabledOnJre(JAVA_17) - void onJava17Only() { - assertFalse(JavaVirtualMachine.isJavaVersion(11)); - assertTrue(JavaVirtualMachine.isJavaVersion(17)); - assertFalse(JavaVirtualMachine.isJavaVersion(21)); - } - - @Test - @EnabledOnJre(JAVA_21) - void onJava21Only() { - assertFalse(JavaVirtualMachine.isJavaVersion(17)); - assertTrue(JavaVirtualMachine.isJavaVersion(21)); - assertFalse(JavaVirtualMachine.isJavaVersion(25)); - } - - @Test - void onJava7andHigher() { - assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(7)); - } - - @Test - void onJava8AndHigher() { - for (int version = 7; version <= 8; version++) { - assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); - } - } - - @Test - @EnabledForJreRange(min = JAVA_11) - void onJava11AndHigher() { - for (int version = 7; version <= 11; version++) { - assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); - } - } - - @Test - @EnabledForJreRange(min = JAVA_17) - void onJava17AndHigher() { - for (int version = 7; version <= 17; version++) { - assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); - } - } - - @Test - @EnabledForJreRange(min = JAVA_21) - void onJava21AndHigher() { - for (int version = 7; version <= 21; version++) { - assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); - } - } - - @Test - @EnabledForJreRange(min = JAVA_8, max = JAVA_9) - void fromJava8to9() { - assertFalse(JavaVirtualMachine.isJavaVersionBetween(7, 8)); - assertTrue(JavaVirtualMachine.isJavaVersionBetween(8, 10)); - assertFalse(JavaVirtualMachine.isJavaVersionBetween(10, 11)); - } - - @Test - @EnabledForJreRange(min = JAVA_11, max = JAVA_11) - void fromJava11to17() { - assertFalse(JavaVirtualMachine.isJavaVersionBetween(8, 11)); - assertTrue(JavaVirtualMachine.isJavaVersionBetween(11, 18)); - assertFalse(JavaVirtualMachine.isJavaVersionBetween(18, 21)); - } - - @Test - @EnabledIfSystemProperty(named = "java.vendor.version", matches = ".*graalvm.*") - void onlyOnGraalVm() { - assertTrue(JavaVirtualMachine.isGraalVM()); - assertFalse(JavaVirtualMachine.isIbm8()); - assertFalse(JavaVirtualMachine.isJ9()); - assertFalse(JavaVirtualMachine.isOracleJDK8()); - } - - @Test - @EnabledIfSystemProperty(named = "java.vm.vendor", matches = ".*IBM.*") - @EnabledOnJre(JAVA_8) - void onlyOnIbm8() { - assertFalse(JavaVirtualMachine.isGraalVM()); - assertTrue(JavaVirtualMachine.isIbm()); - assertTrue(JavaVirtualMachine.isIbm8()); - assertTrue(JavaVirtualMachine.isJ9()); - assertFalse(JavaVirtualMachine.isOracleJDK8()); - } - - @Test - @EnabledIfSystemProperty(named = "java.vm.name", matches = ".*J9.*") - void onlyOnJ9() { - assertFalse(JavaVirtualMachine.isGraalVM()); - assertTrue(JavaVirtualMachine.isJ9()); - assertFalse(JavaVirtualMachine.isOracleJDK8()); - } - - @Test - @EnabledIfSystemProperty(named = "java.vm.vendor", matches = ".*Oracle.*") - @DisabledIfSystemProperty(named = "java.runtime.name", matches = ".*OpenJDK.*") - @EnabledOnJre(JAVA_8) - void onlyOnOracleJDK8() { - assertFalse(JavaVirtualMachine.isGraalVM()); - assertFalse(JavaVirtualMachine.isIbm8()); - assertFalse(JavaVirtualMachine.isJ9()); - assertTrue(JavaVirtualMachine.isOracleJDK8()); - } - - @Test - @EnabledIfSystemProperty(named = "java.vm.vendor", matches = ".*Azul.*") - @EnabledOnJre(JAVA_8) - void onlyOnZulu8() { - assertFalse(JavaVirtualMachine.isGraalVM()); - assertFalse(JavaVirtualMachine.isIbm8()); - assertFalse(JavaVirtualMachine.isOracleJDK8()); - assertTrue(JavaVirtualMachine.isZulu8()); - } - - @ParameterizedTest - @CsvSource( - value = { - "1.8.0_265 | 1.8.0_265-b01 | OpenJDK | AdoptOpenJDK | 1.8.0_265 | b01 | OpenJDK | AdoptOpenJDK", - "1.8.0_265 | 1.8-b01 | OpenJDK | AdoptOpenJDK | 1.8.0_265 | '' | OpenJDK | AdoptOpenJDK", - "19 | 19 | OpenJDK 64-Bit | Homebrew | 19 | '' | OpenJDK 64-Bit | Homebrew", - "17 | null | null | null | 17 | '' | '' | ''", - "null | 17 | null | null | '' | '' | '' | ''", - }, - nullValues = "null", - delimiter = '|') - void testRuntimeParsing( - String javaVersion, - String javaRuntimeVersion, - String javaRuntimeName, - String javaVmVendor, - String expectedVersion, - String expectedPatches, - String expectedName, - String expectedVendor) { - JavaVirtualMachine.Runtime runtime = - new JavaVirtualMachine.Runtime( - javaVersion, javaRuntimeVersion, javaRuntimeName, javaVmVendor, null); - assertEquals(expectedVersion, runtime.version); - assertEquals(expectedPatches, runtime.patches); - assertEquals(expectedName, runtime.name); - assertEquals(expectedVendor, runtime.vendor); - } + @Test + @EnabledOnJre(JAVA_8) + void onJava8Only() { + assertTrue(JavaVirtualMachine.isJavaVersion(8)); + assertFalse(JavaVirtualMachine.isJavaVersion(11)); + } + + @Test + @EnabledOnJre(JAVA_11) + void onJava11Only() { + assertFalse(JavaVirtualMachine.isJavaVersion(8)); + assertTrue(JavaVirtualMachine.isJavaVersion(11)); + assertFalse(JavaVirtualMachine.isJavaVersion(17)); + } + + @Test + @EnabledOnJre(JAVA_17) + void onJava17Only() { + assertFalse(JavaVirtualMachine.isJavaVersion(11)); + assertTrue(JavaVirtualMachine.isJavaVersion(17)); + assertFalse(JavaVirtualMachine.isJavaVersion(21)); + } + + @Test + @EnabledOnJre(JAVA_21) + void onJava21Only() { + assertFalse(JavaVirtualMachine.isJavaVersion(17)); + assertTrue(JavaVirtualMachine.isJavaVersion(21)); + assertFalse(JavaVirtualMachine.isJavaVersion(25)); + } + + @Test + void onJava7andHigher() { + assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(7)); + } + + @Test + void onJava8AndHigher() { + for (int version = 7; version <= 8; version++) { + assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); + } + } + + @Test + @EnabledForJreRange(min = JAVA_11) + void onJava11AndHigher() { + for (int version = 7; version <= 11; version++) { + assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); + } + } + + @Test + @EnabledForJreRange(min = JAVA_17) + void onJava17AndHigher() { + for (int version = 7; version <= 17; version++) { + assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); + } + } + + @Test + @EnabledForJreRange(min = JAVA_21) + void onJava21AndHigher() { + for (int version = 7; version <= 21; version++) { + assertTrue(JavaVirtualMachine.isJavaVersionAtLeast(version)); + } + } + + @Test + @EnabledForJreRange(min = JAVA_8, max = JAVA_9) + void fromJava8to9() { + assertFalse(JavaVirtualMachine.isJavaVersionBetween(7, 8)); + assertTrue(JavaVirtualMachine.isJavaVersionBetween(8, 10)); + assertFalse(JavaVirtualMachine.isJavaVersionBetween(10, 11)); + } + + @Test + @EnabledForJreRange(min = JAVA_11, max = JAVA_11) + void fromJava11to17() { + assertFalse(JavaVirtualMachine.isJavaVersionBetween(8, 11)); + assertTrue(JavaVirtualMachine.isJavaVersionBetween(11, 18)); + assertFalse(JavaVirtualMachine.isJavaVersionBetween(18, 21)); + } + + @Test + @EnabledIfSystemProperty(named = "java.vendor.version", matches = ".*graalvm.*") + void onlyOnGraalVm() { + assertTrue(JavaVirtualMachine.isGraalVM()); + assertFalse(JavaVirtualMachine.isIbm8()); + assertFalse(JavaVirtualMachine.isJ9()); + assertFalse(JavaVirtualMachine.isOracleJDK8()); + } + + @Test + @EnabledIfSystemProperty(named = "java.vm.vendor", matches = ".*IBM.*") + @EnabledOnJre(JAVA_8) + void onlyOnIbm8() { + assertFalse(JavaVirtualMachine.isGraalVM()); + assertTrue(JavaVirtualMachine.isIbm()); + assertTrue(JavaVirtualMachine.isIbm8()); + assertTrue(JavaVirtualMachine.isJ9()); + assertFalse(JavaVirtualMachine.isOracleJDK8()); + } + + @Test + @EnabledIfSystemProperty(named = "java.vm.name", matches = ".*J9.*") + void onlyOnJ9() { + assertFalse(JavaVirtualMachine.isGraalVM()); + assertTrue(JavaVirtualMachine.isJ9()); + assertFalse(JavaVirtualMachine.isOracleJDK8()); + } + + @Test + @EnabledIfSystemProperty(named = "java.vm.vendor", matches = ".*Oracle.*") + @DisabledIfSystemProperty(named = "java.runtime.name", matches = ".*OpenJDK.*") + @EnabledOnJre(JAVA_8) + void onlyOnOracleJDK8() { + assertFalse(JavaVirtualMachine.isGraalVM()); + assertFalse(JavaVirtualMachine.isIbm8()); + assertFalse(JavaVirtualMachine.isJ9()); + assertTrue(JavaVirtualMachine.isOracleJDK8()); + } + + @Test + @EnabledIfSystemProperty(named = "java.vm.vendor", matches = ".*Azul.*") + @EnabledOnJre(JAVA_8) + void onlyOnZulu8() { + assertFalse(JavaVirtualMachine.isGraalVM()); + assertFalse(JavaVirtualMachine.isIbm8()); + assertFalse(JavaVirtualMachine.isOracleJDK8()); + assertTrue(JavaVirtualMachine.isZulu8()); + } + + @ParameterizedTest + @CsvSource( + value = { + "1.8.0_265 | 1.8.0_265-b01 | OpenJDK | AdoptOpenJDK | 1.8.0_265 | b01 | OpenJDK | AdoptOpenJDK", + "1.8.0_265 | 1.8-b01 | OpenJDK | AdoptOpenJDK | 1.8.0_265 | '' | OpenJDK | AdoptOpenJDK", + "19 | 19 | OpenJDK 64-Bit | Homebrew | 19 | '' | OpenJDK 64-Bit | Homebrew", + "17 | null | null | null | 17 | '' | '' | ''", + "null | 17 | null | null | '' | '' | '' | ''", + }, + nullValues = "null", + delimiter = '|') + void testRuntimeParsing( + String javaVersion, + String javaRuntimeVersion, + String javaRuntimeName, + String javaVmVendor, + String expectedVersion, + String expectedPatches, + String expectedName, + String expectedVendor) { + JavaVirtualMachine.Runtime runtime = + new JavaVirtualMachine.Runtime(javaVersion, javaRuntimeVersion, javaRuntimeName, javaVmVendor, null); + assertEquals(expectedVersion, runtime.version); + assertEquals(expectedPatches, runtime.patches); + assertEquals(expectedName, runtime.name); + assertEquals(expectedVendor, runtime.vendor); + } } diff --git a/components/environment/src/test/java/datadog/environment/JvmOptionsTest.java b/components/environment/src/test/java/datadog/environment/JvmOptionsTest.java index 3d66c562262..9239d024f58 100644 --- a/components/environment/src/test/java/datadog/environment/JvmOptionsTest.java +++ b/components/environment/src/test/java/datadog/environment/JvmOptionsTest.java @@ -30,31 +30,29 @@ import org.junit.jupiter.params.provider.MethodSource; class JvmOptionsTest { - static Stream options() { - return Stream.of( - arguments("", emptyList()), - arguments("-Xmx512m", singletonList("-Xmx512m")), - arguments("-Xms256m -Xmx512m", asList("-Xms256m", "-Xmx512m")), - arguments(" -Xms256m -Xmx512m ", asList("-Xms256m", "-Xmx512m")), - arguments("-Xms256m\t-Xmx512m", asList("-Xms256m", "-Xmx512m")), - arguments("\t -Xms256m \t -Xmx512m \t", asList("-Xms256m", "-Xmx512m")), - arguments( - "-Xmx512m -Dprop=\"value with space\"", asList("-Xmx512m", "-Dprop=value with space")), - arguments( - "-Xmx512m -Dprop='value with space'", asList("-Xmx512m", "-Dprop=value with space")), - arguments("-Xmx512m -Dprop='mixing\"quotes'", asList("-Xmx512m", "-Dprop=mixing\"quotes")), - arguments("-Xmx512m -Dprop=\"mixing'quotes\"", asList("-Xmx512m", "-Dprop=mixing'quotes"))); - } + static Stream options() { + return Stream.of( + arguments("", emptyList()), + arguments("-Xmx512m", singletonList("-Xmx512m")), + arguments("-Xms256m -Xmx512m", asList("-Xms256m", "-Xmx512m")), + arguments(" -Xms256m -Xmx512m ", asList("-Xms256m", "-Xmx512m")), + arguments("-Xms256m\t-Xmx512m", asList("-Xms256m", "-Xmx512m")), + arguments("\t -Xms256m \t -Xmx512m \t", asList("-Xms256m", "-Xmx512m")), + arguments("-Xmx512m -Dprop=\"value with space\"", asList("-Xmx512m", "-Dprop=value with space")), + arguments("-Xmx512m -Dprop='value with space'", asList("-Xmx512m", "-Dprop=value with space")), + arguments("-Xmx512m -Dprop='mixing\"quotes'", asList("-Xmx512m", "-Dprop=mixing\"quotes")), + arguments("-Xmx512m -Dprop=\"mixing'quotes\"", asList("-Xmx512m", "-Dprop=mixing'quotes"))); + } - @ParameterizedTest - @MethodSource("options") - void testParseOptions(String javaToolOptions, List expectedVmOptions) { - List vmOptions = JvmOptions.parseOptions(javaToolOptions); - assertEquals(expectedVmOptions, vmOptions); - } + @ParameterizedTest + @MethodSource("options") + void testParseOptions(String javaToolOptions, List expectedVmOptions) { + List vmOptions = JvmOptions.parseOptions(javaToolOptions); + assertEquals(expectedVmOptions, vmOptions); + } - static Stream data() { - // spotless:off + static Stream data() { + // spotless:off return Stream.of( arguments( "CLI options only", @@ -95,27 +93,27 @@ static Stream data() { ); // spotless:on - } + } - @ParameterizedTest(name = "[{index}] {0}") - @MethodSource("data") - void testFindVmOptions( - String useCase, - Map environmentVariables, - CommandLineHelper.RunArguments arguments, - CommandLineHelper.RunArguments expectedArguments) - throws Exception { - // Skip unsupported test cases - skipJdkJavaOptionsOnJava8(environmentVariables); - // Run test process - Result result = forkAndRunWithArgs(arguments, environmentVariables); - // Check results - assertEquals(expectedArguments.jvmOptions, result.jvmOptions, "Failed to get JVM options"); - } + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("data") + void testFindVmOptions( + String useCase, + Map environmentVariables, + CommandLineHelper.RunArguments arguments, + CommandLineHelper.RunArguments expectedArguments) + throws Exception { + // Skip unsupported test cases + skipJdkJavaOptionsOnJava8(environmentVariables); + // Run test process + Result result = forkAndRunWithArgs(arguments, environmentVariables); + // Check results + assertEquals(expectedArguments.jvmOptions, result.jvmOptions, "Failed to get JVM options"); + } - @MethodSource - private static Stream procFsCmdLine() { - // spotless:off + @MethodSource + private static Stream procFsCmdLine() { + // spotless:off return Stream.of( arguments( "No arguments", @@ -163,62 +161,59 @@ private static Stream procFsCmdLine() { flatten("-Dargfile.prop=test", "-verbose:class", expectedArsFromArgFile("tab-separated")) )); // spotless:on - } - - @ParameterizedTest(name = "[{index}] {0}") - @MethodSource("procFsCmdLine") - void testFindVmOptionsFromProcFs(String useCase, String[] procfsCmdline, List expected) { - JvmOptions vmOptions = new JvmOptions(); - List found = vmOptions.findVmOptionsFromProcFs(procfsCmdline); - assertEquals(expected, found); - } + } - private void skipJdkJavaOptionsOnJava8(Map environmentVariables) { - assumeTrue( - JavaVirtualMachine.isJavaVersionAtLeast(9) - || !environmentVariables.containsKey("JDK_JAVA_OPTIONS")); - } + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("procFsCmdLine") + void testFindVmOptionsFromProcFs(String useCase, String[] procfsCmdline, List expected) { + JvmOptions vmOptions = new JvmOptions(); + List found = vmOptions.findVmOptionsFromProcFs(procfsCmdline); + assertEquals(expected, found); + } - private static Map env(String... keysAndValues) { - if (keysAndValues.length % 2 != 0) { - throw new IllegalArgumentException("Invalid key-value pair"); + private void skipJdkJavaOptionsOnJava8(Map environmentVariables) { + assumeTrue(JavaVirtualMachine.isJavaVersionAtLeast(9) || !environmentVariables.containsKey("JDK_JAVA_OPTIONS")); } - Map env = new HashMap<>(); - for (int i = 0; i < keysAndValues.length; i += 2) { - env.put(keysAndValues[i], keysAndValues[i + 1]); + + private static Map env(String... keysAndValues) { + if (keysAndValues.length % 2 != 0) { + throw new IllegalArgumentException("Invalid key-value pair"); + } + Map env = new HashMap<>(); + for (int i = 0; i < keysAndValues.length; i += 2) { + env.put(keysAndValues[i], keysAndValues[i + 1]); + } + return env; } - return env; - } - private static String argFile(String name) { - return "@src/test/resources/argfiles/" + name + ".txt"; - } + private static String argFile(String name) { + return "@src/test/resources/argfiles/" + name + ".txt"; + } - private static List expectedArsFromArgFile(String name) { - List arguments = new ArrayList<>(); - try (InputStream stream = - requireNonNull( - CommandLineTest.class.getResourceAsStream("/argfiles/" + name + "-expected.txt")); - BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { - String line; - while ((line = reader.readLine()) != null) { - arguments.add(line); - } - } catch (IOException e) { - Assertions.fail("Failed to read expected args from " + name + "argfile", e); + private static List expectedArsFromArgFile(String name) { + List arguments = new ArrayList<>(); + try (InputStream stream = requireNonNull( + CommandLineTest.class.getResourceAsStream("/argfiles/" + name + "-expected.txt")); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + String line; + while ((line = reader.readLine()) != null) { + arguments.add(line); + } + } catch (IOException e) { + Assertions.fail("Failed to read expected args from " + name + "argfile", e); + } + return arguments; } - return arguments; - } - private static List flatten(Object... values) { - List result = new ArrayList<>(); - for (Object value : values) { - if (value instanceof Collection) { - result.addAll((Collection) value); - } else { - result.add(value.toString()); - } + private static List flatten(Object... values) { + List result = new ArrayList<>(); + for (Object value : values) { + if (value instanceof Collection) { + result.addAll((Collection) value); + } else { + result.add(value.toString()); + } + } + return result; } - return result; - } } diff --git a/components/environment/src/test/java/datadog/environment/OperatingSystemTest.java b/components/environment/src/test/java/datadog/environment/OperatingSystemTest.java index ff5bbe9b871..cfb4b4623d5 100644 --- a/components/environment/src/test/java/datadog/environment/OperatingSystemTest.java +++ b/components/environment/src/test/java/datadog/environment/OperatingSystemTest.java @@ -16,54 +16,54 @@ import org.junit.jupiter.api.condition.EnabledOnOs; class OperatingSystemTest { - @Test - @EnabledOnOs(LINUX) - void onLinuxOnly() { - assertTrue(OperatingSystem.isLinux()); - assertFalse(OperatingSystem.isMacOs()); - assertFalse(OperatingSystem.isWindows()); - assertEquals(Type.LINUX, OperatingSystem.type()); - } + @Test + @EnabledOnOs(LINUX) + void onLinuxOnly() { + assertTrue(OperatingSystem.isLinux()); + assertFalse(OperatingSystem.isMacOs()); + assertFalse(OperatingSystem.isWindows()); + assertEquals(Type.LINUX, OperatingSystem.type()); + } - @Test - @EnabledOnOs(MAC) - void onMacOsOnly() { - assertFalse(OperatingSystem.isLinux()); - assertTrue(OperatingSystem.isMacOs()); - assertFalse(OperatingSystem.isWindows()); - assertEquals(Type.MACOS, OperatingSystem.type()); - } + @Test + @EnabledOnOs(MAC) + void onMacOsOnly() { + assertFalse(OperatingSystem.isLinux()); + assertTrue(OperatingSystem.isMacOs()); + assertFalse(OperatingSystem.isWindows()); + assertEquals(Type.MACOS, OperatingSystem.type()); + } - @Test - @EnabledOnOs(WINDOWS) - void onWindowsOnly() { - assertFalse(OperatingSystem.isLinux()); - assertFalse(OperatingSystem.isMacOs()); - assertTrue(OperatingSystem.isWindows()); - assertEquals(Type.WINDOWS, OperatingSystem.type()); - } + @Test + @EnabledOnOs(WINDOWS) + void onWindowsOnly() { + assertFalse(OperatingSystem.isLinux()); + assertFalse(OperatingSystem.isMacOs()); + assertTrue(OperatingSystem.isWindows()); + assertEquals(Type.WINDOWS, OperatingSystem.type()); + } - @Test - @EnabledOnOs(architectures = {"x86_64", "amd64", "k8"}) - void onX64() { - assertEquals(X64, OperatingSystem.architecture()); - } + @Test + @EnabledOnOs(architectures = {"x86_64", "amd64", "k8"}) + void onX64() { + assertEquals(X64, OperatingSystem.architecture()); + } - @Test - @EnabledOnOs(architectures = {"x86", "i386", "i486", "i586", "i686"}) - void onX86() { - assertEquals(X86, OperatingSystem.architecture()); - } + @Test + @EnabledOnOs(architectures = {"x86", "i386", "i486", "i586", "i686"}) + void onX86() { + assertEquals(X86, OperatingSystem.architecture()); + } - @Test - @EnabledOnOs(architectures = {"arm", "aarch32"}) - void onArm() { - assertEquals(ARM, OperatingSystem.architecture()); - } + @Test + @EnabledOnOs(architectures = {"arm", "aarch32"}) + void onArm() { + assertEquals(ARM, OperatingSystem.architecture()); + } - @Test - @EnabledOnOs(architectures = {"arm64", "aarch64"}) - void onArm64() { - assertEquals(ARM64, OperatingSystem.architecture()); - } + @Test + @EnabledOnOs(architectures = {"arm64", "aarch64"}) + void onArm64() { + assertEquals(ARM64, OperatingSystem.architecture()); + } } diff --git a/components/environment/src/test/java/datadog/environment/SystemPropertiesTest.java b/components/environment/src/test/java/datadog/environment/SystemPropertiesTest.java index 390f69239ae..4139adfbf37 100644 --- a/components/environment/src/test/java/datadog/environment/SystemPropertiesTest.java +++ b/components/environment/src/test/java/datadog/environment/SystemPropertiesTest.java @@ -13,72 +13,69 @@ import org.junit.jupiter.api.Test; class SystemPropertiesTest { - private static final String EXISTING_SYSTEM_PROPERTY = "java.home"; - private static final String MISSING_SYSTEM_PROPERTY = "undefined.system.property"; - private static final String DEFAULT_VALUE = "DEFAULT"; + private static final String EXISTING_SYSTEM_PROPERTY = "java.home"; + private static final String MISSING_SYSTEM_PROPERTY = "undefined.system.property"; + private static final String DEFAULT_VALUE = "DEFAULT"; - @Test - void testGet() { - // Existing system properties - assertNotNull(SystemProperties.get(EXISTING_SYSTEM_PROPERTY)); - // Missing system properties - assertNull(SystemProperties.get(MISSING_SYSTEM_PROPERTY)); - // Null values - assertDoesNotThrow(() -> SystemProperties.get(null)); - assertNull(SystemProperties.get(null)); - } + @Test + void testGet() { + // Existing system properties + assertNotNull(SystemProperties.get(EXISTING_SYSTEM_PROPERTY)); + // Missing system properties + assertNull(SystemProperties.get(MISSING_SYSTEM_PROPERTY)); + // Null values + assertDoesNotThrow(() -> SystemProperties.get(null)); + assertNull(SystemProperties.get(null)); + } - @Test - void testGetOrDefault() { - // Existing system properties - assertNotNull(SystemProperties.getOrDefault(EXISTING_SYSTEM_PROPERTY, null)); - // Missing system properties - assertEquals( - DEFAULT_VALUE, SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, DEFAULT_VALUE)); - assertNull(SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, null)); - // Null values - assertDoesNotThrow(() -> SystemProperties.getOrDefault(null, DEFAULT_VALUE)); - assertEquals(DEFAULT_VALUE, SystemProperties.getOrDefault(null, DEFAULT_VALUE)); - assertDoesNotThrow(() -> SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, null)); - assertNull(SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, null)); - } + @Test + void testGetOrDefault() { + // Existing system properties + assertNotNull(SystemProperties.getOrDefault(EXISTING_SYSTEM_PROPERTY, null)); + // Missing system properties + assertEquals(DEFAULT_VALUE, SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, DEFAULT_VALUE)); + assertNull(SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, null)); + // Null values + assertDoesNotThrow(() -> SystemProperties.getOrDefault(null, DEFAULT_VALUE)); + assertEquals(DEFAULT_VALUE, SystemProperties.getOrDefault(null, DEFAULT_VALUE)); + assertDoesNotThrow(() -> SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, null)); + assertNull(SystemProperties.getOrDefault(MISSING_SYSTEM_PROPERTY, null)); + } - @Test - void testSet() { - String testProperty = "test.set.property"; - String testValue = "test.set.value"; - assertNull(SystemProperties.get(testProperty)); - assertTrue(SystemProperties.set(testProperty, testValue)); - assertEquals(testValue, SystemProperties.get(testProperty)); - // Null values - assertDoesNotThrow(() -> SystemProperties.set(testProperty, null)); - assertFalse(SystemProperties.set(testProperty, null)); - assertDoesNotThrow(() -> SystemProperties.set(null, testValue)); - assertFalse(SystemProperties.set(null, testValue)); - } + @Test + void testSet() { + String testProperty = "test.set.property"; + String testValue = "test.set.value"; + assertNull(SystemProperties.get(testProperty)); + assertTrue(SystemProperties.set(testProperty, testValue)); + assertEquals(testValue, SystemProperties.get(testProperty)); + // Null values + assertDoesNotThrow(() -> SystemProperties.set(testProperty, null)); + assertFalse(SystemProperties.set(testProperty, null)); + assertDoesNotThrow(() -> SystemProperties.set(null, testValue)); + assertFalse(SystemProperties.set(null, testValue)); + } - @Test - void testClear() { - String testProperty = "test.clear.property"; - String testValue = "test.clear.value"; - assertNull(SystemProperties.get(testProperty)); - assertNull(SystemProperties.clear(testProperty)); - assumeTrue(SystemProperties.set(testProperty, testValue)); - assertEquals(testValue, SystemProperties.clear(testProperty)); - assertNull(SystemProperties.clear(testProperty)); - // Null values - assertDoesNotThrow(() -> SystemProperties.clear(null)); - assertNull(SystemProperties.clear(null)); - } + @Test + void testClear() { + String testProperty = "test.clear.property"; + String testValue = "test.clear.value"; + assertNull(SystemProperties.get(testProperty)); + assertNull(SystemProperties.clear(testProperty)); + assumeTrue(SystemProperties.set(testProperty, testValue)); + assertEquals(testValue, SystemProperties.clear(testProperty)); + assertNull(SystemProperties.clear(testProperty)); + // Null values + assertDoesNotThrow(() -> SystemProperties.clear(null)); + assertNull(SystemProperties.clear(null)); + } - @Test - void testAsStringMap() { - Map stringMap = SystemProperties.asStringMap(); - assertNotNull(stringMap); - assertFalse(stringMap.isEmpty()); - // Unmodifiable collection - assertThrows( - UnsupportedOperationException.class, - () -> stringMap.put(MISSING_SYSTEM_PROPERTY, DEFAULT_VALUE)); - } + @Test + void testAsStringMap() { + Map stringMap = SystemProperties.asStringMap(); + assertNotNull(stringMap); + assertFalse(stringMap.isEmpty()); + // Unmodifiable collection + assertThrows(UnsupportedOperationException.class, () -> stringMap.put(MISSING_SYSTEM_PROPERTY, DEFAULT_VALUE)); + } } diff --git a/components/environment/src/test/java/datadog/environment/ThreadSupportTest.java b/components/environment/src/test/java/datadog/environment/ThreadSupportTest.java index be2032c5470..46ec570624b 100644 --- a/components/environment/src/test/java/datadog/environment/ThreadSupportTest.java +++ b/components/environment/src/test/java/datadog/environment/ThreadSupportTest.java @@ -14,69 +14,68 @@ import org.junit.jupiter.api.condition.EnabledOnJre; class ThreadSupportTest { - private static ExecutorService singleThreadExecutor; - private static ExecutorService newVirtualThreadPerTaskExecutor; + private static ExecutorService singleThreadExecutor; + private static ExecutorService newVirtualThreadPerTaskExecutor; - @BeforeAll - static void beforeAll() { - singleThreadExecutor = Executors.newSingleThreadExecutor(); - newVirtualThreadPerTaskExecutor = ThreadSupport.newVirtualThreadPerTaskExecutor().orElse(null); - } + @BeforeAll + static void beforeAll() { + singleThreadExecutor = Executors.newSingleThreadExecutor(); + newVirtualThreadPerTaskExecutor = + ThreadSupport.newVirtualThreadPerTaskExecutor().orElse(null); + } - @Test - public void testThreadId() throws InterruptedException { - AtomicLong threadId = new AtomicLong(); - Thread thread = new Thread(() -> threadId.set(ThreadSupport.threadId()), "foo"); - thread.start(); - try { - // always works on Thread's where getId isn't overridden by child class - assertEquals(thread.getId(), ThreadSupport.threadId(thread)); - } finally { - thread.join(); + @Test + public void testThreadId() throws InterruptedException { + AtomicLong threadId = new AtomicLong(); + Thread thread = new Thread(() -> threadId.set(ThreadSupport.threadId()), "foo"); + thread.start(); + try { + // always works on Thread's where getId isn't overridden by child class + assertEquals(thread.getId(), ThreadSupport.threadId(thread)); + } finally { + thread.join(); + } + assertEquals(thread.getId(), threadId.get()); } - assertEquals(thread.getId(), threadId.get()); - } - @Test - void testSupportsVirtualThreads() { - assertEquals( - JavaVirtualMachine.isJavaVersionAtLeast(21), - ThreadSupport.supportsVirtualThreads(), - "expected virtual threads support status"); - } + @Test + void testSupportsVirtualThreads() { + assertEquals( + JavaVirtualMachine.isJavaVersionAtLeast(21), + ThreadSupport.supportsVirtualThreads(), + "expected virtual threads support status"); + } - @Test - void testPlatformThread() { - assertVirtualThread(singleThreadExecutor, false); - } + @Test + void testPlatformThread() { + assertVirtualThread(singleThreadExecutor, false); + } - @Test - @EnabledOnJre(JAVA_21) - void testVirtualThread() { - assertVirtualThread(newVirtualThreadPerTaskExecutor, true); - } + @Test + @EnabledOnJre(JAVA_21) + void testVirtualThread() { + assertVirtualThread(newVirtualThreadPerTaskExecutor, true); + } - static void assertVirtualThread(ExecutorService executorService, boolean expected) { - Future futureCurrent = executorService.submit(() -> ThreadSupport.isVirtual()); - Future futureGiven = - executorService.submit( - () -> { - Thread thread = Thread.currentThread(); - return ThreadSupport.isVirtual(thread); - }); - try { - assertEquals(expected, futureCurrent.get(), "invalid current thread virtual status"); - assertEquals(expected, futureGiven.get(), "invalid given thread virtual status"); - } catch (Throwable e) { - fail("Can't get thread virtual status", e); + static void assertVirtualThread(ExecutorService executorService, boolean expected) { + Future futureCurrent = executorService.submit(() -> ThreadSupport.isVirtual()); + Future futureGiven = executorService.submit(() -> { + Thread thread = Thread.currentThread(); + return ThreadSupport.isVirtual(thread); + }); + try { + assertEquals(expected, futureCurrent.get(), "invalid current thread virtual status"); + assertEquals(expected, futureGiven.get(), "invalid given thread virtual status"); + } catch (Throwable e) { + fail("Can't get thread virtual status", e); + } } - } - @AfterAll - static void afterAll() { - singleThreadExecutor.shutdown(); - if (newVirtualThreadPerTaskExecutor != null) { - newVirtualThreadPerTaskExecutor.shutdown(); + @AfterAll + static void afterAll() { + singleThreadExecutor.shutdown(); + if (newVirtualThreadPerTaskExecutor != null) { + newVirtualThreadPerTaskExecutor.shutdown(); + } } - } } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpClient.java b/components/http/http-api/src/main/java/datadog/http/client/HttpClient.java index fb6a637cfdc..dd16acc0977 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpClient.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpClient.java @@ -16,98 +16,98 @@ *

HttpClient instances should be reused across requests for connection pooling. */ public interface HttpClient { - /** - * Executes an HTTP request synchronously and returns the response. The caller is responsible for - * closing the response. - * - * @param request the request to execute - * @return the HTTP response - * @throws IOException if an I/O error occurs - */ - HttpResponse execute(HttpRequest request) throws IOException; - - /** - * Executes an HTTP request asynchronously and returns a {@link CompletableFuture}. The caller is - * responsible for closing the response. - * - * @param request the request to execute - * @return a CompletableFuture that completes with the HTTP response - */ - CompletableFuture executeAsync(HttpRequest request); - - /** - * Creates a new {@link Builder} for constructing HTTP clients. - * - * @return a new http client builder - */ - static Builder newBuilder() { - return HttpProviders.get().newClientBuilder(); - } - - /** Builder for constructing {@link HttpClient} instances. */ - interface Builder { /** - * Sets the client timeouts, including the connection. + * Executes an HTTP request synchronously and returns the response. The caller is responsible for + * closing the response. * - * @param timeout the timeout duration - * @return this builder + * @param request the request to execute + * @return the HTTP response + * @throws IOException if an I/O error occurs */ - Builder connectTimeout(Duration timeout); + HttpResponse execute(HttpRequest request) throws IOException; /** - * Sets the proxy configuration. + * Executes an HTTP request asynchronously and returns a {@link CompletableFuture}. The caller is + * responsible for closing the response. * - * @param proxy the proxy to use - * @return this builder + * @param request the request to execute + * @return a CompletableFuture that completes with the HTTP response */ - Builder proxy(Proxy proxy); + CompletableFuture executeAsync(HttpRequest request); /** - * Sets proxy authentication credentials. + * Creates a new {@link Builder} for constructing HTTP clients. * - * @param username the proxy username - * @param password the proxy password, or {@code null} to use an empty password - * @return this builder + * @return a new http client builder */ - Builder proxyAuthenticator(String username, @Nullable String password); + static Builder newBuilder() { + return HttpProviders.get().newClientBuilder(); + } - /** - * Configures the client to use a Unix domain socket. - * - * @param socketFile the Unix domain socket file - * @return this builder - */ - Builder unixDomainSocket(File socketFile); + /** Builder for constructing {@link HttpClient} instances. */ + interface Builder { + /** + * Sets the client timeouts, including the connection. + * + * @param timeout the timeout duration + * @return this builder + */ + Builder connectTimeout(Duration timeout); - /** - * Configures the client to use a named pipe (Windows). - * - * @param pipeName the named pipe name - * @return this builder - */ - Builder namedPipe(String pipeName); + /** + * Sets the proxy configuration. + * + * @param proxy the proxy to use + * @return this builder + */ + Builder proxy(Proxy proxy); - /** - * Forces clear text (HTTP) connections, disabling TLS. - * - * @param clearText {@code true} to force HTTP, {@code false} to allow HTTPS - * @return this builder - */ - Builder clearText(boolean clearText); + /** + * Sets proxy authentication credentials. + * + * @param username the proxy username + * @param password the proxy password, or {@code null} to use an empty password + * @return this builder + */ + Builder proxyAuthenticator(String username, @Nullable String password); - /** - * Sets a custom executor for executing async requests. - * - * @param executor the executor to use for async requests - * @return this builder - */ - Builder executor(Executor executor); + /** + * Configures the client to use a Unix domain socket. + * + * @param socketFile the Unix domain socket file + * @return this builder + */ + Builder unixDomainSocket(File socketFile); - /** - * Builds the {@link HttpClient} with the configured settings. - * - * @return the constructed HttpClient - */ - HttpClient build(); - } + /** + * Configures the client to use a named pipe (Windows). + * + * @param pipeName the named pipe name + * @return this builder + */ + Builder namedPipe(String pipeName); + + /** + * Forces clear text (HTTP) connections, disabling TLS. + * + * @param clearText {@code true} to force HTTP, {@code false} to allow HTTPS + * @return this builder + */ + Builder clearText(boolean clearText); + + /** + * Sets a custom executor for executing async requests. + * + * @param executor the executor to use for async requests + * @return this builder + */ + Builder executor(Executor executor); + + /** + * Builds the {@link HttpClient} with the configured settings. + * + * @return the constructed HttpClient + */ + HttpClient build(); + } } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpProvider.java b/components/http/http-api/src/main/java/datadog/http/client/HttpProvider.java index 4754ad5139f..fe3bb4c73a9 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpProvider.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpProvider.java @@ -6,23 +6,23 @@ /** Factory class providing the various HTTP client class implementations. */ public abstract class HttpProvider { - public abstract HttpClient.Builder newClientBuilder(); + public abstract HttpClient.Builder newClientBuilder(); - public abstract HttpRequest.Builder newRequestBuilder(); + public abstract HttpRequest.Builder newRequestBuilder(); - public abstract HttpUrl.Builder newUrlBuilder(); + public abstract HttpUrl.Builder newUrlBuilder(); - public abstract HttpUrl httpUrlParse(String url); + public abstract HttpUrl httpUrlParse(String url); - public abstract HttpUrl httpUrlFrom(URI uri); + public abstract HttpUrl httpUrlFrom(URI uri); - public abstract HttpRequestBody requestBodyOfString(String content); + public abstract HttpRequestBody requestBodyOfString(String content); - public abstract HttpRequestBody requestBodyOfBytes(byte[] bytes); + public abstract HttpRequestBody requestBodyOfBytes(byte[] bytes); - public abstract HttpRequestBody requestBodyOfByteBuffers(List buffers); + public abstract HttpRequestBody requestBodyOfByteBuffers(List buffers); - public abstract HttpRequestBody requestBodyGzip(HttpRequestBody body); + public abstract HttpRequestBody requestBodyGzip(HttpRequestBody body); - public abstract HttpRequestBody.MultipartBuilder requestBodyMultipart(); + public abstract HttpRequestBody.MultipartBuilder requestBodyMultipart(); } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpProviders.java b/components/http/http-api/src/main/java/datadog/http/client/HttpProviders.java index efa217c0dfc..b8a5f1f91f8 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpProviders.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpProviders.java @@ -18,57 +18,55 @@ * thread-safe and all methods can be safely called from multiple threads. */ public final class HttpProviders { - private static final String JDK_HTTP_PROVIDER_CLASS_NAME = - "datadog.http.client.jdk.JdkHttpProvider"; - private static final String OKHTTP_PROVIDER_CLASS_NAME = - "datadog.http.client.okhttp.OkHttpProvider"; - private static volatile boolean compatibilityMode = false; - private static HttpProvider provider; + private static final String JDK_HTTP_PROVIDER_CLASS_NAME = "datadog.http.client.jdk.JdkHttpProvider"; + private static final String OKHTTP_PROVIDER_CLASS_NAME = "datadog.http.client.okhttp.OkHttpProvider"; + private static volatile boolean compatibilityMode = false; + private static HttpProvider provider; - private HttpProviders() {} + private HttpProviders() {} - public static void forceCompatClient() { - // Skip if already in compat mode - if (compatibilityMode) { - return; + public static void forceCompatClient() { + // Skip if already in compat mode + if (compatibilityMode) { + return; + } + compatibilityMode = true; + provider = null; } - compatibilityMode = true; - provider = null; - } - public static HttpProvider get() { - if (provider == null) { - provider = findProvider(); + public static HttpProvider get() { + if (provider == null) { + provider = findProvider(); + } + return provider; } - return provider; - } - @SuppressForbidden // Class#forName(String) used to dynamically load the http API implementation - private static HttpProvider findProvider() { - Class clazz = null; - // Load the default client class - if (!compatibilityMode) { - try { - clazz = Class.forName(JDK_HTTP_PROVIDER_CLASS_NAME); - } catch (ClassNotFoundException | UnsupportedClassVersionError ignored) { - compatibilityMode = true; - } - } - // If not loaded, load the compat client class - if (clazz == null) { - try { - clazz = Class.forName(OKHTTP_PROVIDER_CLASS_NAME); - } catch (ClassNotFoundException ignored) { - } - } - // If no class loaded, raise the illegal state - if (clazz == null) { - throw new IllegalStateException("No http client implementation found"); - } - try { - return (HttpProvider) clazz.getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("No http client implementation found", e); + @SuppressForbidden // Class#forName(String) used to dynamically load the http API implementation + private static HttpProvider findProvider() { + Class clazz = null; + // Load the default client class + if (!compatibilityMode) { + try { + clazz = Class.forName(JDK_HTTP_PROVIDER_CLASS_NAME); + } catch (ClassNotFoundException | UnsupportedClassVersionError ignored) { + compatibilityMode = true; + } + } + // If not loaded, load the compat client class + if (clazz == null) { + try { + clazz = Class.forName(OKHTTP_PROVIDER_CLASS_NAME); + } catch (ClassNotFoundException ignored) { + } + } + // If no class loaded, raise the illegal state + if (clazz == null) { + throw new IllegalStateException("No http client implementation found"); + } + try { + return (HttpProvider) clazz.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("No http client implementation found", e); + } } - } } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpRequest.java b/components/http/http-api/src/main/java/datadog/http/client/HttpRequest.java index b4649f4da27..bf5f7155eaf 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpRequest.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpRequest.java @@ -8,133 +8,133 @@ * body. */ public interface HttpRequest { - /* Common headers names and values widely used in HTTP requests */ - String CONTENT_TYPE = "Content-Type"; - String APPLICATION_JSON = "application/json; charset=utf-8"; - - /** - * Returns the request URL. - * - * @return the HttpUrl - */ - HttpUrl url(); - - /** - * Returns the HTTP method ({@code GET}, {@code POST}, {@code PUT}, etc.). - * - * @return the method name - */ - String method(); - - /** - * Returns the first header value for the given name, or {@code null} if not present. - * - * @param name the header name - * @return the first header value, or {@code null} if not present - */ - @Nullable - String header(String name); - - /** - * Returns all header values for the given name. - * - * @param name the header name - * @return list of header values, an empty list if not present - */ - List headers(String name); - - /** - * Returns the request body, or {@code null} if this request has no body (e.g., GET requests). - * - * @return the request body, or {@code null} if this request has no body - */ - @Nullable - HttpRequestBody body(); - - /** - * Creates a new {@link Builder} for constructing HTTP requests. - * - * @return a new builder - */ - static Builder newBuilder() { - return HttpProviders.get().newRequestBuilder(); - } - - /** Builder for constructing {@link HttpRequest} instances. */ - interface Builder { - /** - * Sets the request URL. - * - * @param url the URL - * @return this builder - */ - Builder url(HttpUrl url); - - /** - * Sets the request URL from a {@link String}. - * - * @param url the URL string - * @return this builder - */ - Builder url(String url); - - /** - * Sets the request method to {@code GET}. This is the default method if other methods are not - * set. - * - * @return this builder - */ - Builder get(); + /* Common headers names and values widely used in HTTP requests */ + String CONTENT_TYPE = "Content-Type"; + String APPLICATION_JSON = "application/json; charset=utf-8"; /** - * Sets the request method to {@code POST} with the given body. + * Returns the request URL. * - * @param body the request body - * @return this builder + * @return the HttpUrl */ - Builder post(HttpRequestBody body); + HttpUrl url(); /** - * Sets the request method to {@code PUT} with the given body. + * Returns the HTTP method ({@code GET}, {@code POST}, {@code PUT}, etc.). * - * @param body the request body - * @return this builder + * @return the method name */ - Builder put(HttpRequestBody body); + String method(); /** - * Sets a header, replacing any existing values for the same name. + * Returns the first header value for the given name, or {@code null} if not present. * * @param name the header name - * @param value the header value - * @return this builder + * @return the first header value, or {@code null} if not present */ - Builder header(String name, String value); + @Nullable + String header(String name); /** - * Adds a header without removing existing values for the same name. + * Returns all header values for the given name. * * @param name the header name - * @param value the header value - * @return this builder + * @return list of header values, an empty list if not present */ - Builder addHeader(String name, String value); + List headers(String name); /** - * Sets the request listener. + * Returns the request body, or {@code null} if this request has no body (e.g., GET requests). * - * @param listener the listener to notify of request events or {@code null} to remove any - * existing listener. - * @return this builder + * @return the request body, or {@code null} if this request has no body */ - Builder listener(@Nullable HttpRequestListener listener); + @Nullable + HttpRequestBody body(); /** - * Builds the HttpRequest. + * Creates a new {@link Builder} for constructing HTTP requests. * - * @return the constructed HttpRequest - * @throws IllegalStateException if required fields (like url) are missing + * @return a new builder */ - HttpRequest build(); - } + static Builder newBuilder() { + return HttpProviders.get().newRequestBuilder(); + } + + /** Builder for constructing {@link HttpRequest} instances. */ + interface Builder { + /** + * Sets the request URL. + * + * @param url the URL + * @return this builder + */ + Builder url(HttpUrl url); + + /** + * Sets the request URL from a {@link String}. + * + * @param url the URL string + * @return this builder + */ + Builder url(String url); + + /** + * Sets the request method to {@code GET}. This is the default method if other methods are not + * set. + * + * @return this builder + */ + Builder get(); + + /** + * Sets the request method to {@code POST} with the given body. + * + * @param body the request body + * @return this builder + */ + Builder post(HttpRequestBody body); + + /** + * Sets the request method to {@code PUT} with the given body. + * + * @param body the request body + * @return this builder + */ + Builder put(HttpRequestBody body); + + /** + * Sets a header, replacing any existing values for the same name. + * + * @param name the header name + * @param value the header value + * @return this builder + */ + Builder header(String name, String value); + + /** + * Adds a header without removing existing values for the same name. + * + * @param name the header name + * @param value the header value + * @return this builder + */ + Builder addHeader(String name, String value); + + /** + * Sets the request listener. + * + * @param listener the listener to notify of request events or {@code null} to remove any + * existing listener. + * @return this builder + */ + Builder listener(@Nullable HttpRequestListener listener); + + /** + * Builds the HttpRequest. + * + * @return the constructed HttpRequest + * @throws IllegalStateException if required fields (like url) are missing + */ + HttpRequest build(); + } } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpRequestBody.java b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestBody.java index b46fc223771..e271ab970ca 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpRequestBody.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestBody.java @@ -12,122 +12,122 @@ * compression and multipart/form-data. */ public interface HttpRequestBody { - /** - * Returns the content length in bytes, or {@code -1} if unknown (e.g., for gzipped content). - * - * @return the content length, or {@code -1} if unknown - */ - long contentLength(); - - /** - * Writes the body content to the given output stream. - * - * @param out the output stream to write to - * @throws IOException if an I/O error occurs - */ - void writeTo(OutputStream out) throws IOException; - - /** - * Creates a request body from a String using UTF-8 encoding. Content-Type should be set via - * request headers. - * - * @param content the string content - * @return a new {@link HttpRequestBody} - */ - static HttpRequestBody of(String content) { - return HttpProviders.get().requestBodyOfString(content); - } - - /** - * Creates a request body from raw bytes. Content-Type should be set via request headers. - * - * @param bytes the byte array content - * @return a new {@link HttpRequestBody} - */ - static HttpRequestBody of(byte[] bytes) { - return HttpProviders.get().requestBodyOfBytes(bytes); - } - - /** - * Creates a request body from a list of {@link ByteBuffer}s. Content-Type should be set via - * request headers. - * - * @param buffers the list of byte buffers content - * @return a new {@link HttpRequestBody} - */ - static HttpRequestBody of(List buffers) { - return HttpProviders.get().requestBodyOfByteBuffers(buffers); - } - - /** - * Wraps a request body with gzip compression. The body is compressed eagerly and the content - * length reflects the compressed size. Content-Encoding header should be set to "gzip" separately - * via request headers. - * - * @param body the body to compress - * @return a new gzip-compressed {@link HttpRequestBody} - */ - static HttpRequestBody gzip(HttpRequestBody body) { - return HttpProviders.get().requestBodyGzip(body); - } + /** + * Returns the content length in bytes, or {@code -1} if unknown (e.g., for gzipped content). + * + * @return the content length, or {@code -1} if unknown + */ + long contentLength(); - /** - * Creates a builder for multipart/form-data request bodies. - * - * @return a new {@link MultipartBuilder} - */ - static MultipartBuilder multipart() { - return HttpProviders.get().requestBodyMultipart(); - } + /** + * Writes the body content to the given output stream. + * + * @param out the output stream to write to + * @throws IOException if an I/O error occurs + */ + void writeTo(OutputStream out) throws IOException; - /** - * Builder for creating multipart/form-data request bodies. Implements RFC 7578 - * multipart/form-data format. - */ - interface MultipartBuilder { /** - * Adds a form data part with a text value. + * Creates a request body from a String using UTF-8 encoding. Content-Type should be set via + * request headers. * - * @param name the field name - * @param value the field value - * @return this builder + * @param content the string content + * @return a new {@link HttpRequestBody} */ - MultipartBuilder addFormDataPart(String name, String value); + static HttpRequestBody of(String content) { + return HttpProviders.get().requestBodyOfString(content); + } /** - * Adds a form data part with a file attachment. + * Creates a request body from raw bytes. Content-Type should be set via request headers. * - * @param name the field name - * @param filename the filename - * @param body the file content - * @return this builder + * @param bytes the byte array content + * @return a new {@link HttpRequestBody} */ - MultipartBuilder addFormDataPart(String name, String filename, HttpRequestBody body); + static HttpRequestBody of(byte[] bytes) { + return HttpProviders.get().requestBodyOfBytes(bytes); + } /** - * Adds a part with custom headers (advanced usage). Use this when you need full control over - * part headers. + * Creates a request body from a list of {@link ByteBuffer}s. Content-Type should be set via + * request headers. * - * @param headers map of header name to value (e.g., {@code Content-Disposition}, {@code - * Content-Type}) - * @param body the part content - * @return this builder + * @param buffers the list of byte buffers content + * @return a new {@link HttpRequestBody} */ - MultipartBuilder addPart(Map headers, HttpRequestBody body); + static HttpRequestBody of(List buffers) { + return HttpProviders.get().requestBodyOfByteBuffers(buffers); + } /** - * Returns the {@code Content-Type} header value for this multipart body. Includes the boundary - * parameter required for parsing. Can be called before or after build(). + * Wraps a request body with gzip compression. The body is compressed eagerly and the content + * length reflects the compressed size. Content-Encoding header should be set to "gzip" separately + * via request headers. * - * @return the content type string (e.g., "multipart/form-data; boundary=...") + * @param body the body to compress + * @return a new gzip-compressed {@link HttpRequestBody} */ - String contentType(); + static HttpRequestBody gzip(HttpRequestBody body) { + return HttpProviders.get().requestBodyGzip(body); + } /** - * Builds the multipart request body. + * Creates a builder for multipart/form-data request bodies. * - * @return the constructed {@link HttpRequestBody} + * @return a new {@link MultipartBuilder} + */ + static MultipartBuilder multipart() { + return HttpProviders.get().requestBodyMultipart(); + } + + /** + * Builder for creating multipart/form-data request bodies. Implements RFC 7578 + * multipart/form-data format. */ - HttpRequestBody build(); - } + interface MultipartBuilder { + /** + * Adds a form data part with a text value. + * + * @param name the field name + * @param value the field value + * @return this builder + */ + MultipartBuilder addFormDataPart(String name, String value); + + /** + * Adds a form data part with a file attachment. + * + * @param name the field name + * @param filename the filename + * @param body the file content + * @return this builder + */ + MultipartBuilder addFormDataPart(String name, String filename, HttpRequestBody body); + + /** + * Adds a part with custom headers (advanced usage). Use this when you need full control over + * part headers. + * + * @param headers map of header name to value (e.g., {@code Content-Disposition}, {@code + * Content-Type}) + * @param body the part content + * @return this builder + */ + MultipartBuilder addPart(Map headers, HttpRequestBody body); + + /** + * Returns the {@code Content-Type} header value for this multipart body. Includes the boundary + * parameter required for parsing. Can be called before or after build(). + * + * @return the content type string (e.g., "multipart/form-data; boundary=...") + */ + String contentType(); + + /** + * Builds the multipart request body. + * + * @return the constructed {@link HttpRequestBody} + */ + HttpRequestBody build(); + } } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpRequestListener.java b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestListener.java index 4d86b99d890..5c4818a64c3 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpRequestListener.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpRequestListener.java @@ -8,26 +8,26 @@ * request timing, log requests, or handle errors. */ public interface HttpRequestListener { - /** - * Called when a request is about to be sent. - * - * @param request the request being sent - */ - void onRequestStart(HttpRequest request); + /** + * Called when a request is about to be sent. + * + * @param request the request being sent + */ + void onRequestStart(HttpRequest request); - /** - * Called when a response is received successfully. - * - * @param request the request that was sent - * @param response the response received, or {@code null} if response body hasn't been read yet - */ - void onRequestEnd(HttpRequest request, @Nullable HttpResponse response); + /** + * Called when a response is received successfully. + * + * @param request the request that was sent + * @param response the response received, or {@code null} if response body hasn't been read yet + */ + void onRequestEnd(HttpRequest request, @Nullable HttpResponse response); - /** - * Called when a request fails with an exception. - * - * @param request the request that failed - * @param exception the exception that occurred - */ - void onRequestFailure(HttpRequest request, IOException exception); + /** + * Called when a request fails with an exception. + * + * @param request the request that failed + * @param exception the exception that occurred + */ + void onRequestFailure(HttpRequest request, IOException exception); } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpResponse.java b/components/http/http-api/src/main/java/datadog/http/client/HttpResponse.java index 09751b6244e..928f3edc9d8 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpResponse.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpResponse.java @@ -13,59 +13,59 @@ *

HttpResponse instances must be closed after use to release resources. */ public interface HttpResponse { - /** - * Returns the HTTP status code. - * - * @return the status code (e.g., 200, 404, 500) - */ - int code(); + /** + * Returns the HTTP status code. + * + * @return the status code (e.g., 200, 404, 500) + */ + int code(); - /** - * Check whether the response code is in [200..300), indicating the request was successful. - * - * @return {@code true} if successful, {@code false} otherwise - */ - boolean isSuccessful(); + /** + * Check whether the response code is in [200..300), indicating the request was successful. + * + * @return {@code true} if successful, {@code false} otherwise + */ + boolean isSuccessful(); - /** - * Returns the first header value for the given name, or {@code null} if not present. Header names - * are case-insensitive. - * - * @param name the header name - * @return the first header value, or {@code null} if not present - */ - @Nullable - String header(String name); + /** + * Returns the first header value for the given name, or {@code null} if not present. Header names + * are case-insensitive. + * + * @param name the header name + * @return the first header value, or {@code null} if not present + */ + @Nullable + String header(String name); - /** - * Returns all header values for the given name. Header names are case-insensitive. - * - * @param name the header name - * @return list of header values, an empty list if not present - */ - List headers(String name); + /** + * Returns all header values for the given name. Header names are case-insensitive. + * + * @param name the header name + * @return list of header values, an empty list if not present + */ + List headers(String name); - /** - * Returns all header names in this response. Header names are returned in their canonical form. - * - * @return set of header names, empty if no headers present - */ - Set headerNames(); + /** + * Returns all header names in this response. Header names are returned in their canonical form. + * + * @return set of header names, empty if no headers present + */ + Set headerNames(); - /** - * Returns the response body as an {@link InputStream}. The caller is responsible for closing the - * stream. - * - * @return the response body stream - */ - InputStream body(); + /** + * Returns the response body as an {@link InputStream}. The caller is responsible for closing the + * stream. + * + * @return the response body stream + */ + InputStream body(); - /** - * Returns the response body as a {@link String} using {@code Content-Type} charset or UTF-8 if - * absent. - * - * @return the response body as a {@link String} - * @throws IOException if an I/O error occurs - */ - String bodyAsString() throws IOException; + /** + * Returns the response body as a {@link String} using {@code Content-Type} charset or UTF-8 if + * absent. + * + * @return the response body as a {@link String} + * @throws IOException if an I/O error occurs + */ + String bodyAsString() throws IOException; } diff --git a/components/http/http-api/src/main/java/datadog/http/client/HttpUrl.java b/components/http/http-api/src/main/java/datadog/http/client/HttpUrl.java index 4bdeb2ada1f..752df043b16 100644 --- a/components/http/http-api/src/main/java/datadog/http/client/HttpUrl.java +++ b/components/http/http-api/src/main/java/datadog/http/client/HttpUrl.java @@ -11,131 +11,131 @@ * #from(URI)}, or parse from strings using {@link #parse(String)}. */ public interface HttpUrl { - /** - * Returns the complete URL as a string. - * - * @return the URL string - */ - String url(); - - /** - * Returns the scheme (protocol) of this URL. - * - * @return the scheme (e.g., "http", "https") - */ - String scheme(); - - /** - * Returns the host of this URL. - * - * @return the host name or IP address - */ - String host(); - - /** - * Returns the port of this URL. Returns the default port for the scheme if not explicitly set (80 - * for http, 443 for https). - * - * @return the port number - */ - int port(); - - /** - * Resolves a relative URL against this URL. - * - * @param path the relative path to resolve - * @return a new {@link HttpUrl} with the resolved path - */ - HttpUrl resolve(String path); - - /** - * Returns a {@link Builder} to modify this URL. - * - * @return a new {@link Builder} based on this URL - */ - Builder newBuilder(); - - /** - * Parses a URL string into an {@link HttpUrl}. - * - * @param url the URL string to parse - * @return the parsed {@link HttpUrl} - * @throws IllegalArgumentException if the URL is malformed - */ - static HttpUrl parse(String url) throws IllegalArgumentException { - requireNonNull(url, "url"); - return HttpProviders.get().httpUrlParse(url); - } - - /** - * Creates an HttpUrl from an {@link URI}. - * - * @param uri the {@link URI} to get an {@link HttpUrl} from - * @return the {@link HttpUrl} related to the URI - */ - static HttpUrl from(URI uri) { - requireNonNull(uri, "uri"); - return HttpProviders.get().httpUrlFrom(uri); - } - - /** - * Creates a new {@link Builder}der for constructing URLs. - * - * @return a new {@link Builder} - */ - static Builder builder() { - return HttpProviders.get().newUrlBuilder(); - } - - /** Builder for constructing {@link HttpUrl} instances. */ - interface Builder { /** - * Sets the scheme (protocol) for the URL. + * Returns the complete URL as a string. * - * @param scheme the scheme (e.g., "http", "https") - * @return this builder + * @return the URL string */ - Builder scheme(String scheme); + String url(); /** - * Sets the host for the URL. + * Returns the scheme (protocol) of this URL. * - * @param host the host name or IP address - * @return this builder + * @return the scheme (e.g., "http", "https") */ - Builder host(String host); + String scheme(); /** - * Sets the port for the URL. + * Returns the host of this URL. * - * @param port the port number - * @return this builder + * @return the host name or IP address */ - Builder port(int port); + String host(); /** - * Adds a path segment to the URL. + * Returns the port of this URL. Returns the default port for the scheme if not explicitly set (80 + * for http, 443 for https). * - * @param segment the path segment to add - * @return this builder + * @return the port number */ - Builder addPathSegment(String segment); + int port(); /** - * Adds a query parameter to the URL. + * Resolves a relative URL against this URL. * - * @param name the parameter name - * @param value the parameter value - * @return this builder + * @param path the relative path to resolve + * @return a new {@link HttpUrl} with the resolved path */ - Builder addQueryParameter(String name, @Nullable String value); + HttpUrl resolve(String path); /** - * Builds the HttpUrl. + * Returns a {@link Builder} to modify this URL. * - * @return the constructed HttpUrl - * @throws IllegalStateException if required fields are missing + * @return a new {@link Builder} based on this URL */ - HttpUrl build(); - } + Builder newBuilder(); + + /** + * Parses a URL string into an {@link HttpUrl}. + * + * @param url the URL string to parse + * @return the parsed {@link HttpUrl} + * @throws IllegalArgumentException if the URL is malformed + */ + static HttpUrl parse(String url) throws IllegalArgumentException { + requireNonNull(url, "url"); + return HttpProviders.get().httpUrlParse(url); + } + + /** + * Creates an HttpUrl from an {@link URI}. + * + * @param uri the {@link URI} to get an {@link HttpUrl} from + * @return the {@link HttpUrl} related to the URI + */ + static HttpUrl from(URI uri) { + requireNonNull(uri, "uri"); + return HttpProviders.get().httpUrlFrom(uri); + } + + /** + * Creates a new {@link Builder}der for constructing URLs. + * + * @return a new {@link Builder} + */ + static Builder builder() { + return HttpProviders.get().newUrlBuilder(); + } + + /** Builder for constructing {@link HttpUrl} instances. */ + interface Builder { + /** + * Sets the scheme (protocol) for the URL. + * + * @param scheme the scheme (e.g., "http", "https") + * @return this builder + */ + Builder scheme(String scheme); + + /** + * Sets the host for the URL. + * + * @param host the host name or IP address + * @return this builder + */ + Builder host(String host); + + /** + * Sets the port for the URL. + * + * @param port the port number + * @return this builder + */ + Builder port(int port); + + /** + * Adds a path segment to the URL. + * + * @param segment the path segment to add + * @return this builder + */ + Builder addPathSegment(String segment); + + /** + * Adds a query parameter to the URL. + * + * @param name the parameter name + * @param value the parameter value + * @return this builder + */ + Builder addQueryParameter(String name, @Nullable String value); + + /** + * Builds the HttpUrl. + * + * @return the constructed HttpUrl + * @throws IllegalStateException if required fields are missing + */ + HttpUrl build(); + } } diff --git a/components/http/http-api/src/test/java/datadog/http/client/HttpProvidersTest.java b/components/http/http-api/src/test/java/datadog/http/client/HttpProvidersTest.java index fcf12314b48..22ad992ae65 100644 --- a/components/http/http-api/src/test/java/datadog/http/client/HttpProvidersTest.java +++ b/components/http/http-api/src/test/java/datadog/http/client/HttpProvidersTest.java @@ -11,62 +11,62 @@ import org.junit.jupiter.api.condition.EnabledForJreRange; abstract class HttpProvidersTest { - abstract String getImplementationPackage(); + abstract String getImplementationPackage(); - @Test - void testProviderLookup() { - // Check runtime type - HttpProvider httpProvider = HttpProviders.get(); - assertType(httpProvider); - // Downgrade to compatibility mode - HttpProviders.forceCompatClient(); - httpProvider = HttpProviders.get(); - assertCompatType(httpProvider); - // Ensure downgrade is idempotent - HttpProviders.forceCompatClient(); - httpProvider = HttpProviders.get(); - assertCompatType(httpProvider); - } - - private void assertType(Object type) { - assertNotNull(type); - assertTrue(type.getClass().getName().startsWith(getImplementationPackage())); - } + @Test + void testProviderLookup() { + // Check runtime type + HttpProvider httpProvider = HttpProviders.get(); + assertType(httpProvider); + // Downgrade to compatibility mode + HttpProviders.forceCompatClient(); + httpProvider = HttpProviders.get(); + assertCompatType(httpProvider); + // Ensure downgrade is idempotent + HttpProviders.forceCompatClient(); + httpProvider = HttpProviders.get(); + assertCompatType(httpProvider); + } - private void assertCompatType(Object type) { - assertNotNull(type); - assertTrue(type.getClass().getName().startsWith("datadog.http.client.okhttp")); - } + private void assertType(Object type) { + assertNotNull(type); + assertTrue(type.getClass().getName().startsWith(getImplementationPackage())); + } - @Disabled - @EnabledForJreRange(max = JAVA_10) - static class OkHttpProvidersForkedTest extends HttpProvidersTest { - @Override - String getImplementationPackage() { - return "datadog.http.client.okhttp"; + private void assertCompatType(Object type) { + assertNotNull(type); + assertTrue(type.getClass().getName().startsWith("datadog.http.client.okhttp")); } - } - @Disabled - @EnabledForJreRange(min = JAVA_11) - static class JdkHttpProvidersForkedTest extends HttpProvidersTest { - @Override - String getImplementationPackage() { - return "datadog.http.client.jdk"; + @Disabled + @EnabledForJreRange(max = JAVA_10) + static class OkHttpProvidersForkedTest extends HttpProvidersTest { + @Override + String getImplementationPackage() { + return "datadog.http.client.okhttp"; + } } - } - @Disabled - @EnabledForJreRange(min = JAVA_11) - static class HttpProvidersCompatForkedTest extends HttpProvidersTest { - @BeforeAll - static void beforeAll() { - HttpProviders.forceCompatClient(); + @Disabled + @EnabledForJreRange(min = JAVA_11) + static class JdkHttpProvidersForkedTest extends HttpProvidersTest { + @Override + String getImplementationPackage() { + return "datadog.http.client.jdk"; + } } - @Override - String getImplementationPackage() { - return "datadog.http.client.okhttp"; + @Disabled + @EnabledForJreRange(min = JAVA_11) + static class HttpProvidersCompatForkedTest extends HttpProvidersTest { + @BeforeAll + static void beforeAll() { + HttpProviders.forceCompatClient(); + } + + @Override + String getImplementationPackage() { + return "datadog.http.client.okhttp"; + } } - } } diff --git a/components/http/http-api/src/test/test/java/datadog/http/client/HttpProvidersTest.java b/components/http/http-api/src/test/test/java/datadog/http/client/HttpProvidersTest.java index d337299c1d6..06cdd395631 100644 --- a/components/http/http-api/src/test/test/java/datadog/http/client/HttpProvidersTest.java +++ b/components/http/http-api/src/test/test/java/datadog/http/client/HttpProvidersTest.java @@ -19,141 +19,138 @@ import org.junit.jupiter.params.provider.MethodSource; abstract class HttpProvidersTest { - private static final String URL_EXAMPLE = "http://localhost"; - private static final String CONTENT_EXAMPLE = "content"; - - abstract String getImplementationPackage(); - - @ParameterizedTest(name = "[{index}] {0} builder") - @MethodSource("builders") - void testNewBuilder(String type, Supplier builderSupplier) { - Object builder = builderSupplier.get(); - assertType(builder); - } - - static Stream builders() { - return Stream.of( - arguments("client", (Supplier) HttpProviders::newClientBuilder), - arguments("request", (Supplier) HttpProviders::newRequestBuilder), - arguments("url", (Supplier) HttpProviders::newUrlBuilder)); - } - - @Test - void testHttpUrlParse() { - HttpUrl url = HttpProviders.httpUrlParse(URL_EXAMPLE); - assertType(url); - } - - @Test - void testHttpUrlParseInvalidUrl() { - // An invalid URL causes the underlying parse() to throw IllegalArgumentException, - // wrapped as InvocationTargetException. HttpProviders unwraps and re-throws it. - assertThrows( - IllegalArgumentException.class, () -> HttpProviders.httpUrlParse("not a valid url")); - } - - @Test - void testHttpUrlFromUri() { - HttpUrl url = HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE)); - assertType(url); - } - - @Test - void testRequestBodyOfString() { - HttpRequestBody body = HttpProviders.requestBodyOfString(CONTENT_EXAMPLE); - assertType(body); - } - - @Test - void testRequestBodyOfBytes() { - HttpRequestBody body = HttpProviders.requestBodyOfBytes(new byte[0]); - assertType(body); - } - - @Test - void testRequestBodyOfByteBuffers() { - HttpRequestBody body = HttpProviders.requestBodyOfByteBuffers(emptyList()); - assertType(body); - } - - @Test - void testRequestBodyGzip() { - HttpRequestBody body = - HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)); - assertType(body); - } - - @Test - void testRequestBodyMultipart() { - HttpRequestBody.MultipartBuilder builder = HttpProviders.requestBodyMultipart(); - assertType(builder); - } - - @Test - void testCachedProviders() { - // First calls — populate all lazy-init caches - HttpProviders.newClientBuilder(); - HttpProviders.newRequestBuilder(); - HttpProviders.newUrlBuilder(); - HttpProviders.httpUrlParse(URL_EXAMPLE); - HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE)); - HttpProviders.requestBodyOfString(CONTENT_EXAMPLE); - HttpProviders.requestBodyOfBytes(new byte[0]); - HttpProviders.requestBodyOfByteBuffers(emptyList()); - HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)); - HttpProviders.requestBodyMultipart(); - // Second calls — hit the non-null (cached) branch for every lazy field - assertNotNull(HttpProviders.newClientBuilder()); - assertNotNull(HttpProviders.newRequestBuilder()); - assertNotNull(HttpProviders.newUrlBuilder()); - assertNotNull(HttpProviders.httpUrlParse(URL_EXAMPLE)); - assertNotNull(HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE))); - assertNotNull(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)); - assertNotNull(HttpProviders.requestBodyOfBytes(new byte[0])); - assertNotNull(HttpProviders.requestBodyOfByteBuffers(emptyList())); - assertNotNull( - HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE))); - assertNotNull(HttpProviders.requestBodyMultipart()); - } - - private void assertType(Object builder) { - assertNotNull(builder); - assertTrue(builder.getClass().getName().startsWith(getImplementationPackage())); - } - - @EnabledForJreRange(max = JAVA_10) - static class OkHttpProvidersForkedTest extends HttpProvidersTest { - @Override - String getImplementationPackage() { - return "datadog.http.client.okhttp"; + private static final String URL_EXAMPLE = "http://localhost"; + private static final String CONTENT_EXAMPLE = "content"; + + abstract String getImplementationPackage(); + + @ParameterizedTest(name = "[{index}] {0} builder") + @MethodSource("builders") + void testNewBuilder(String type, Supplier builderSupplier) { + Object builder = builderSupplier.get(); + assertType(builder); + } + + static Stream builders() { + return Stream.of( + arguments("client", (Supplier) HttpProviders::newClientBuilder), + arguments("request", (Supplier) HttpProviders::newRequestBuilder), + arguments("url", (Supplier) HttpProviders::newUrlBuilder)); + } + + @Test + void testHttpUrlParse() { + HttpUrl url = HttpProviders.httpUrlParse(URL_EXAMPLE); + assertType(url); + } + + @Test + void testHttpUrlParseInvalidUrl() { + // An invalid URL causes the underlying parse() to throw IllegalArgumentException, + // wrapped as InvocationTargetException. HttpProviders unwraps and re-throws it. + assertThrows(IllegalArgumentException.class, () -> HttpProviders.httpUrlParse("not a valid url")); + } + + @Test + void testHttpUrlFromUri() { + HttpUrl url = HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE)); + assertType(url); + } + + @Test + void testRequestBodyOfString() { + HttpRequestBody body = HttpProviders.requestBodyOfString(CONTENT_EXAMPLE); + assertType(body); + } + + @Test + void testRequestBodyOfBytes() { + HttpRequestBody body = HttpProviders.requestBodyOfBytes(new byte[0]); + assertType(body); } - } - @EnabledForJreRange(min = JAVA_11) - static class JdkHttpProvidersForkedTest extends HttpProvidersTest { - @Override - String getImplementationPackage() { - return "datadog.http.client.jdk"; + @Test + void testRequestBodyOfByteBuffers() { + HttpRequestBody body = HttpProviders.requestBodyOfByteBuffers(emptyList()); + assertType(body); } - } - @EnabledForJreRange(min = JAVA_11) - static class HttpProvidersCompatForkedTest extends HttpProvidersTest { - @BeforeAll - static void beforeAll() { - HttpProviders.forceCompatClient(); + @Test + void testRequestBodyGzip() { + HttpRequestBody body = HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)); + assertType(body); } - @Override - String getImplementationPackage() { - return "datadog.http.client.okhttp"; + @Test + void testRequestBodyMultipart() { + HttpRequestBody.MultipartBuilder builder = HttpProviders.requestBodyMultipart(); + assertType(builder); } @Test - void testForceCompatClientIsIdempotent() { - // compatibilityMode is already true — second call must hit early return (no NPE, no reset) - HttpProviders.forceCompatClient(); - assertNotNull(HttpProviders.newClientBuilder()); + void testCachedProviders() { + // First calls — populate all lazy-init caches + HttpProviders.newClientBuilder(); + HttpProviders.newRequestBuilder(); + HttpProviders.newUrlBuilder(); + HttpProviders.httpUrlParse(URL_EXAMPLE); + HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE)); + HttpProviders.requestBodyOfString(CONTENT_EXAMPLE); + HttpProviders.requestBodyOfBytes(new byte[0]); + HttpProviders.requestBodyOfByteBuffers(emptyList()); + HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)); + HttpProviders.requestBodyMultipart(); + // Second calls — hit the non-null (cached) branch for every lazy field + assertNotNull(HttpProviders.newClientBuilder()); + assertNotNull(HttpProviders.newRequestBuilder()); + assertNotNull(HttpProviders.newUrlBuilder()); + assertNotNull(HttpProviders.httpUrlParse(URL_EXAMPLE)); + assertNotNull(HttpProviders.httpUrlFrom(URI.create(URL_EXAMPLE))); + assertNotNull(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE)); + assertNotNull(HttpProviders.requestBodyOfBytes(new byte[0])); + assertNotNull(HttpProviders.requestBodyOfByteBuffers(emptyList())); + assertNotNull(HttpProviders.requestBodyGzip(HttpProviders.requestBodyOfString(CONTENT_EXAMPLE))); + assertNotNull(HttpProviders.requestBodyMultipart()); + } + + private void assertType(Object builder) { + assertNotNull(builder); + assertTrue(builder.getClass().getName().startsWith(getImplementationPackage())); + } + + @EnabledForJreRange(max = JAVA_10) + static class OkHttpProvidersForkedTest extends HttpProvidersTest { + @Override + String getImplementationPackage() { + return "datadog.http.client.okhttp"; + } + } + + @EnabledForJreRange(min = JAVA_11) + static class JdkHttpProvidersForkedTest extends HttpProvidersTest { + @Override + String getImplementationPackage() { + return "datadog.http.client.jdk"; + } + } + + @EnabledForJreRange(min = JAVA_11) + static class HttpProvidersCompatForkedTest extends HttpProvidersTest { + @BeforeAll + static void beforeAll() { + HttpProviders.forceCompatClient(); + } + + @Override + String getImplementationPackage() { + return "datadog.http.client.okhttp"; + } + + @Test + void testForceCompatClientIsIdempotent() { + // compatibilityMode is already true — second call must hit early return (no NPE, no reset) + HttpProviders.forceCompatClient(); + assertNotNull(HttpProviders.newClientBuilder()); + } } - } } diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientAsyncTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientAsyncTest.java index 30c2df16168..9bd48050888 100644 --- a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientAsyncTest.java +++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientAsyncTest.java @@ -24,218 +24,214 @@ @ExtendWith(MockServerExtension.class) public class HttpClientAsyncTest { - private static final int TIMEOUT_SECONDS = 5; - private ClientAndServer server; - private HttpClient client; - private String baseUrl; - - @BeforeEach - void setUp(ClientAndServer server) { - this.server = server; - this.client = HttpClient.newBuilder().build(); - this.baseUrl = "http://localhost:" + server.getPort(); - } - - @AfterEach - void tearDown() { - this.server.reset(); - } - - @Test - void testExecuteAsyncSuccess() throws Exception { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - this.server.when(expectedRequest).respond(response().withStatusCode(200).withBody("success")); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - CompletableFuture future = this.client.executeAsync(request); - - HttpResponse response = future.get(TIMEOUT_SECONDS, SECONDS); - assertNotNull(response); - assertEquals(200, response.code()); - assertTrue(response.isSuccessful()); - assertEquals("success", response.bodyAsString()); - - this.server.verify(expectedRequest); - } - - @Test - void testExecuteAsyncHttpError() throws Exception { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/notfound"); - this.server.when(expectedRequest).respond(response().withStatusCode(404)); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/notfound"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - CompletableFuture future = this.client.executeAsync(request); - - // HTTP errors (4xx, 5xx) should complete normally, not exceptionally - HttpResponse response = future.get(TIMEOUT_SECONDS, SECONDS); - assertNotNull(response); - assertEquals(404, response.code()); - - this.server.verify(expectedRequest); - } - - @Test - void testExecuteAsyncWithListener() throws Exception { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - this.server.when(expectedRequest).respond(response().withStatusCode(200)); - - AtomicBoolean startCalled = new AtomicBoolean(false); - AtomicBoolean endCalled = new AtomicBoolean(false); - AtomicReference capturedResponse = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = - HttpRequest.newBuilder() - .url(url) - .get() - .listener( - new HttpRequestListener() { - @Override - public void onRequestStart(HttpRequest request) { - startCalled.set(true); - } - - @Override - public void onRequestEnd(HttpRequest request, HttpResponse response) { - endCalled.set(true); - capturedResponse.set(response); - latch.countDown(); - } - - @Override - public void onRequestFailure(HttpRequest request, IOException exception) { - fail("Should not fail"); - } + private static final int TIMEOUT_SECONDS = 5; + private ClientAndServer server; + private HttpClient client; + private String baseUrl; + + @BeforeEach + void setUp(ClientAndServer server) { + this.server = server; + this.client = HttpClient.newBuilder().build(); + this.baseUrl = "http://localhost:" + server.getPort(); + } + + @AfterEach + void tearDown() { + this.server.reset(); + } + + @Test + void testExecuteAsyncSuccess() throws Exception { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + this.server.when(expectedRequest).respond(response().withStatusCode(200).withBody("success")); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + CompletableFuture future = this.client.executeAsync(request); + + HttpResponse response = future.get(TIMEOUT_SECONDS, SECONDS); + assertNotNull(response); + assertEquals(200, response.code()); + assertTrue(response.isSuccessful()); + assertEquals("success", response.bodyAsString()); + + this.server.verify(expectedRequest); + } + + @Test + void testExecuteAsyncHttpError() throws Exception { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/notfound"); + this.server.when(expectedRequest).respond(response().withStatusCode(404)); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/notfound"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + CompletableFuture future = this.client.executeAsync(request); + + // HTTP errors (4xx, 5xx) should complete normally, not exceptionally + HttpResponse response = future.get(TIMEOUT_SECONDS, SECONDS); + assertNotNull(response); + assertEquals(404, response.code()); + + this.server.verify(expectedRequest); + } + + @Test + void testExecuteAsyncWithListener() throws Exception { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + this.server.when(expectedRequest).respond(response().withStatusCode(200)); + + AtomicBoolean startCalled = new AtomicBoolean(false); + AtomicBoolean endCalled = new AtomicBoolean(false); + AtomicReference capturedResponse = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder() + .url(url) + .get() + .listener(new HttpRequestListener() { + @Override + public void onRequestStart(HttpRequest request) { + startCalled.set(true); + } + + @Override + public void onRequestEnd(HttpRequest request, HttpResponse response) { + endCalled.set(true); + capturedResponse.set(response); + latch.countDown(); + } + + @Override + public void onRequestFailure(HttpRequest request, IOException exception) { + fail("Should not fail"); + } }) - .build(); - - this.client.executeAsync(request); - - assertTrue(latch.await(TIMEOUT_SECONDS, SECONDS), "Listener should be called"); - assertTrue(startCalled.get(), "onRequestStart should be called"); - assertTrue(endCalled.get(), "onRequestEnd should be called"); - assertNotNull(capturedResponse.get()); - assertEquals(200, capturedResponse.get().code()); - - this.server.verify(expectedRequest); - } - - @Test - void testExecuteAsyncWithListenerOnFailure() throws Exception { - // Use an invalid port to cause connection failure - HttpUrl url = HttpUrl.parse("http://localhost:1/test"); - - AtomicBoolean startCalled = new AtomicBoolean(false); - AtomicBoolean failureCalled = new AtomicBoolean(false); - AtomicReference capturedException = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - - HttpRequest request = - HttpRequest.newBuilder() - .url(url) - .get() - .listener( - new HttpRequestListener() { - @Override - public void onRequestStart(HttpRequest request) { - startCalled.set(true); - } - - @Override - public void onRequestEnd(HttpRequest request, HttpResponse response) { - fail("Should not succeed"); - } - - @Override - public void onRequestFailure(HttpRequest request, IOException exception) { - failureCalled.set(true); - capturedException.set(exception); - latch.countDown(); - } + .build(); + + this.client.executeAsync(request); + + assertTrue(latch.await(TIMEOUT_SECONDS, SECONDS), "Listener should be called"); + assertTrue(startCalled.get(), "onRequestStart should be called"); + assertTrue(endCalled.get(), "onRequestEnd should be called"); + assertNotNull(capturedResponse.get()); + assertEquals(200, capturedResponse.get().code()); + + this.server.verify(expectedRequest); + } + + @Test + void testExecuteAsyncWithListenerOnFailure() throws Exception { + // Use an invalid port to cause connection failure + HttpUrl url = HttpUrl.parse("http://localhost:1/test"); + + AtomicBoolean startCalled = new AtomicBoolean(false); + AtomicBoolean failureCalled = new AtomicBoolean(false); + AtomicReference capturedException = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + HttpRequest request = HttpRequest.newBuilder() + .url(url) + .get() + .listener(new HttpRequestListener() { + @Override + public void onRequestStart(HttpRequest request) { + startCalled.set(true); + } + + @Override + public void onRequestEnd(HttpRequest request, HttpResponse response) { + fail("Should not succeed"); + } + + @Override + public void onRequestFailure(HttpRequest request, IOException exception) { + failureCalled.set(true); + capturedException.set(exception); + latch.countDown(); + } }) - .build(); + .build(); + + CompletableFuture future = this.client.executeAsync(request); + + assertTrue(latch.await(TIMEOUT_SECONDS, SECONDS), "Listener should be called"); + assertTrue(startCalled.get(), "onRequestStart should be called"); + assertTrue(failureCalled.get(), "onRequestFailure should be called"); + assertNotNull(capturedException.get()); + + // The future should also complete exceptionally + try { + future.get(TIMEOUT_SECONDS, SECONDS); + fail("Future should complete exceptionally"); + } catch (ExecutionException e) { + assertInstanceOf(IOException.class, e.getCause()); + } + } + + @Test + void testExecuteAsyncComposition() throws Exception { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + this.server.when(expectedRequest).respond(response().withStatusCode(200).withBody("42")); - CompletableFuture future = this.client.executeAsync(request); + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - assertTrue(latch.await(TIMEOUT_SECONDS, SECONDS), "Listener should be called"); - assertTrue(startCalled.get(), "onRequestStart should be called"); - assertTrue(failureCalled.get(), "onRequestFailure should be called"); - assertNotNull(capturedException.get()); + // Test thenApply composition + CompletableFuture future = this.client.executeAsync(request).thenApply(response -> { + try { + return Integer.parseInt(response.bodyAsString().trim()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + + Integer result = future.get(TIMEOUT_SECONDS, SECONDS); + assertEquals(42, result); + + this.server.verify(expectedRequest); + } - // The future should also complete exceptionally - try { - future.get(TIMEOUT_SECONDS, SECONDS); - fail("Future should complete exceptionally"); - } catch (ExecutionException e) { - assertInstanceOf(IOException.class, e.getCause()); + @Test + void testExecuteAsyncMultipleRequests() throws Exception { + org.mockserver.model.HttpRequest expectedRequest1 = + request().withMethod("GET").withPath("/test1"); + org.mockserver.model.HttpRequest expectedRequest2 = + request().withMethod("GET").withPath("/test2"); + this.server + .when(expectedRequest1) + .respond(response().withStatusCode(200).withBody("response1")); + this.server + .when(expectedRequest2) + .respond(response().withStatusCode(200).withBody("response2")); + + HttpRequest request1 = HttpRequest.newBuilder() + .url(HttpUrl.parse(this.baseUrl + "/test1")) + .get() + .build(); + HttpRequest request2 = HttpRequest.newBuilder() + .url(HttpUrl.parse(this.baseUrl + "/test2")) + .get() + .build(); + + // Execute both requests concurrently + CompletableFuture future1 = this.client.executeAsync(request1); + CompletableFuture future2 = this.client.executeAsync(request2); + + // Wait for both + CompletableFuture.allOf(future1, future2).get(TIMEOUT_SECONDS, SECONDS); + + HttpResponse response1 = future1.get(); + HttpResponse response2 = future2.get(); + + assertEquals("response1", response1.bodyAsString()); + assertEquals("response2", response2.bodyAsString()); } - } - - @Test - void testExecuteAsyncComposition() throws Exception { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - this.server.when(expectedRequest).respond(response().withStatusCode(200).withBody("42")); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - // Test thenApply composition - CompletableFuture future = - this.client - .executeAsync(request) - .thenApply( - response -> { - try { - return Integer.parseInt(response.bodyAsString().trim()); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - - Integer result = future.get(TIMEOUT_SECONDS, SECONDS); - assertEquals(42, result); - - this.server.verify(expectedRequest); - } - - @Test - void testExecuteAsyncMultipleRequests() throws Exception { - org.mockserver.model.HttpRequest expectedRequest1 = - request().withMethod("GET").withPath("/test1"); - org.mockserver.model.HttpRequest expectedRequest2 = - request().withMethod("GET").withPath("/test2"); - this.server - .when(expectedRequest1) - .respond(response().withStatusCode(200).withBody("response1")); - this.server - .when(expectedRequest2) - .respond(response().withStatusCode(200).withBody("response2")); - - HttpRequest request1 = - HttpRequest.newBuilder().url(HttpUrl.parse(this.baseUrl + "/test1")).get().build(); - HttpRequest request2 = - HttpRequest.newBuilder().url(HttpUrl.parse(this.baseUrl + "/test2")).get().build(); - - // Execute both requests concurrently - CompletableFuture future1 = this.client.executeAsync(request1); - CompletableFuture future2 = this.client.executeAsync(request2); - - // Wait for both - CompletableFuture.allOf(future1, future2).get(TIMEOUT_SECONDS, SECONDS); - - HttpResponse response1 = future1.get(); - HttpResponse response2 = future2.get(); - - assertEquals("response1", response1.bodyAsString()); - assertEquals("response2", response2.bodyAsString()); - } } diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientTest.java index 811ed742723..caa85d39f91 100644 --- a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientTest.java +++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpClientTest.java @@ -18,183 +18,176 @@ @ExtendWith(MockServerExtension.class) public class HttpClientTest { - private ClientAndServer server; - private HttpClient client; - private String baseUrl; - - @BeforeEach - void setUp(ClientAndServer server) { - this.server = server; - this.client = HttpClient.newBuilder().build(); - this.baseUrl = "http://localhost:" + server.getPort(); - } - - @AfterEach - void tearDown() { - this.server.reset(); - } - - @Test - void testGetRequest() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - this.server.when(expectedRequest).respond(response()); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - HttpResponse response = this.client.execute(request); - - assertNotNull(response); - assertEquals(200, response.code()); - assertTrue(response.isSuccessful()); - - this.server.verify(expectedRequest); - } - - @Test - void testPostRequest() throws IOException { - String payload = "{\"key\":\"value\"}"; - org.mockserver.model.HttpRequest expectedRequest = - request() - .withMethod("POST") - .withPath("/test") - .withHeader("Content-Type", "application/json") - .withBody(payload); - this.server.when(expectedRequest).respond(response().withStatusCode(201)); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequestBody body = HttpRequestBody.of(payload); - HttpRequest request = - HttpRequest.newBuilder() - .url(url) - .header("Content-Type", "application/json") - .post(body) - .build(); - - HttpResponse response = this.client.execute(request); - - assertNotNull(response); - assertEquals(201, response.code()); - assertTrue(response.isSuccessful()); - - this.server.verify(expectedRequest); - } - - @Test - void testPutRequest() throws IOException { - String payload = "{\"key\":\"value\"}"; - org.mockserver.model.HttpRequest expectedRequest = - request() - .withMethod("PUT") - .withPath("/test") - .withHeader("Content-Type", "application/json") - .withBody(payload); - this.server.when(expectedRequest).respond(response().withStatusCode(200)); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequestBody body = HttpRequestBody.of(payload); - HttpRequest request = - HttpRequest.newBuilder() - .url(url) - .header("Content-Type", "application/json") - .put(body) - .build(); - - HttpResponse response = this.client.execute(request); - - assertNotNull(response); - assertEquals(200, response.code()); - assertTrue(response.isSuccessful()); - - this.server.verify(expectedRequest); - } - - @Test - void testErrorResponse() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/missing"); - this.server.when(expectedRequest).respond(response().withStatusCode(404)); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/missing"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - HttpResponse response = this.client.execute(request); - - assertNotNull(response); - assertEquals(404, response.code()); - assertFalse(response.isSuccessful()); - - this.server.verify(expectedRequest); - } - - @Test - void testRequestHeaders() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request() - .withMethod("GET") - .withPath("/test") - .withHeader("Accept", "text/plain") - .withHeader("X-Custom-Header", "custom-value1", "custom-value2", "custom-value3"); - this.server.when(expectedRequest).respond(response().withStatusCode(200)); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = - HttpRequest.newBuilder() - .url(url) - .get() - .header("Accept", "text/plain") - .addHeader("X-Custom-Header", "custom-value1") - .addHeader("X-Custom-Header", "custom-value2") - .addHeader("X-Custom-Header", "custom-value3") - .build(); - - HttpResponse response = this.client.execute(request); - - assertNotNull(response); - assertEquals(200, response.code()); - assertTrue(response.isSuccessful()); - - this.server.verify(expectedRequest); - } - - @Test - void testResponseHeaders() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - org.mockserver.model.HttpResponse resultResponse = - response() - .withStatusCode(200) - .withHeader("Content-Type", "text/plain") - .withHeader("X-Custom-Header", "value1", "value2", "value3") - .withBody("test-response"); - this.server.when(expectedRequest).respond(resultResponse); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - HttpResponse response = this.client.execute(request); - - assertNotNull(response); - assertEquals(200, response.code()); - assertTrue(response.isSuccessful()); - assertEquals("text/plain", response.header("Content-Type")); - assertEquals("value1", response.header("X-Custom-Header")); - List customHeaderValues = response.headers("X-Custom-Header"); - assertEquals(3, customHeaderValues.size()); - assertEquals("value1", customHeaderValues.get(0)); - assertEquals("value2", customHeaderValues.get(1)); - assertEquals("value3", customHeaderValues.get(2)); - - this.server.verify(expectedRequest); - } - - @Test - void testNewBuilder() { - HttpClient.Builder builder = HttpClient.newBuilder(); - assertNotNull(builder); - - HttpClient client = builder.build(); - assertNotNull(client); - } + private ClientAndServer server; + private HttpClient client; + private String baseUrl; + + @BeforeEach + void setUp(ClientAndServer server) { + this.server = server; + this.client = HttpClient.newBuilder().build(); + this.baseUrl = "http://localhost:" + server.getPort(); + } + + @AfterEach + void tearDown() { + this.server.reset(); + } + + @Test + void testGetRequest() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + this.server.when(expectedRequest).respond(response()); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(200, response.code()); + assertTrue(response.isSuccessful()); + + this.server.verify(expectedRequest); + } + + @Test + void testPostRequest() throws IOException { + String payload = "{\"key\":\"value\"}"; + org.mockserver.model.HttpRequest expectedRequest = request() + .withMethod("POST") + .withPath("/test") + .withHeader("Content-Type", "application/json") + .withBody(payload); + this.server.when(expectedRequest).respond(response().withStatusCode(201)); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequestBody body = HttpRequestBody.of(payload); + HttpRequest request = HttpRequest.newBuilder() + .url(url) + .header("Content-Type", "application/json") + .post(body) + .build(); + + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(201, response.code()); + assertTrue(response.isSuccessful()); + + this.server.verify(expectedRequest); + } + + @Test + void testPutRequest() throws IOException { + String payload = "{\"key\":\"value\"}"; + org.mockserver.model.HttpRequest expectedRequest = request() + .withMethod("PUT") + .withPath("/test") + .withHeader("Content-Type", "application/json") + .withBody(payload); + this.server.when(expectedRequest).respond(response().withStatusCode(200)); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequestBody body = HttpRequestBody.of(payload); + HttpRequest request = HttpRequest.newBuilder() + .url(url) + .header("Content-Type", "application/json") + .put(body) + .build(); + + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(200, response.code()); + assertTrue(response.isSuccessful()); + + this.server.verify(expectedRequest); + } + + @Test + void testErrorResponse() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/missing"); + this.server.when(expectedRequest).respond(response().withStatusCode(404)); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/missing"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(404, response.code()); + assertFalse(response.isSuccessful()); + + this.server.verify(expectedRequest); + } + + @Test + void testRequestHeaders() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = request() + .withMethod("GET") + .withPath("/test") + .withHeader("Accept", "text/plain") + .withHeader("X-Custom-Header", "custom-value1", "custom-value2", "custom-value3"); + this.server.when(expectedRequest).respond(response().withStatusCode(200)); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder() + .url(url) + .get() + .header("Accept", "text/plain") + .addHeader("X-Custom-Header", "custom-value1") + .addHeader("X-Custom-Header", "custom-value2") + .addHeader("X-Custom-Header", "custom-value3") + .build(); + + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(200, response.code()); + assertTrue(response.isSuccessful()); + + this.server.verify(expectedRequest); + } + + @Test + void testResponseHeaders() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + org.mockserver.model.HttpResponse resultResponse = response() + .withStatusCode(200) + .withHeader("Content-Type", "text/plain") + .withHeader("X-Custom-Header", "value1", "value2", "value3") + .withBody("test-response"); + this.server.when(expectedRequest).respond(resultResponse); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(200, response.code()); + assertTrue(response.isSuccessful()); + assertEquals("text/plain", response.header("Content-Type")); + assertEquals("value1", response.header("X-Custom-Header")); + List customHeaderValues = response.headers("X-Custom-Header"); + assertEquals(3, customHeaderValues.size()); + assertEquals("value1", customHeaderValues.get(0)); + assertEquals("value2", customHeaderValues.get(1)); + assertEquals("value3", customHeaderValues.get(2)); + + this.server.verify(expectedRequest); + } + + @Test + void testNewBuilder() { + HttpClient.Builder builder = HttpClient.newBuilder(); + assertNotNull(builder); + + HttpClient client = builder.build(); + assertNotNull(client); + } } diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestBodyTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestBodyTest.java index 966175b070b..2e439e48685 100644 --- a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestBodyTest.java +++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestBodyTest.java @@ -17,139 +17,137 @@ public class HttpRequestBodyTest { - // TODO Test empty string - // TODO Test empty byte array - // TODO Test empty ByteBuffer list - - @Test - void testNullString() { - assertThrows(NullPointerException.class, () -> HttpRequestBody.of((String) null)); - } - - @Test - void testNullBytes() { - assertThrows(NullPointerException.class, () -> HttpRequestBody.of((byte[]) null)); - } - - @Test - void testNullByteBuffer() { - assertThrows(NullPointerException.class, () -> HttpRequestBody.of((List) null)); - } - - @Test - void testMultipartBuilder() { - HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); - assertNotNull(builder); - } - - @Test - void testMultipartContentType() { - HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); - builder.addFormDataPart("name", "value"); - String contentType = builder.contentType(); - assertTrue(contentType.startsWith("multipart/form-data; boundary=")); - } - - @Test - void testMultipartAddFormDataPart() throws IOException { - HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); - builder.addFormDataPart("name", "value"); - HttpRequestBody body = builder.build(); - - assertNotNull(body); - assertTrue(body.contentLength() > 0); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - body.writeTo(out); - String content = out.toString("UTF-8"); - assertTrue(content.contains("name=\"name\"")); - assertTrue(content.contains("value")); - } - - @Test - void testMultipartAddFormDataPartWithFile() throws IOException { - HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); - HttpRequestBody fileBody = HttpRequestBody.of("file content"); - builder.addFormDataPart("file", "test.txt", fileBody); - HttpRequestBody body = builder.build(); - - assertNotNull(body); - assertTrue(body.contentLength() > 0); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - body.writeTo(out); - String content = out.toString("UTF-8"); - assertTrue(content.contains("name=\"file\"")); - assertTrue(content.contains("filename=\"test.txt\"")); - assertTrue(content.contains("file content")); - } - - @Test - void testMultipartAddPart() throws IOException { - HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); - Map headers = new HashMap<>(); - headers.put("Content-Disposition", "form-data; name=\"custom\"; filename=\"data.bin\""); - HttpRequestBody partBody = HttpRequestBody.of("custom content"); - builder.addPart(headers, partBody); - HttpRequestBody body = builder.build(); - - assertNotNull(body); - assertTrue(body.contentLength() > 0); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - body.writeTo(out); - String content = out.toString("UTF-8"); - assertTrue(content.contains("name=\"custom\"")); - assertTrue(content.contains("custom content")); - } - - @Test - void testMultipartNullParams() { - HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); - assertThrows(NullPointerException.class, () -> builder.addFormDataPart(null, "value")); - assertThrows(NullPointerException.class, () -> builder.addFormDataPart("name", null)); - - HttpRequestBody fileBody = HttpRequestBody.of("content"); - assertThrows( - NullPointerException.class, () -> builder.addFormDataPart(null, "file.txt", fileBody)); - assertThrows(NullPointerException.class, () -> builder.addFormDataPart("name", null, fileBody)); - assertThrows( - NullPointerException.class, () -> builder.addFormDataPart("name", "file.txt", null)); - - HttpRequestBody partBody = HttpRequestBody.of("content"); - Map headers = new HashMap<>(); - headers.put("Content-Disposition", "form-data; name=\"test\""); - assertThrows(NullPointerException.class, () -> builder.addPart(null, partBody)); - assertThrows(NullPointerException.class, () -> builder.addPart(headers, null)); - } - - @Test - void testGzipBody() throws IOException { - String content = "this is test content for gzip compression"; - HttpRequestBody originalBody = HttpRequestBody.of(content); - HttpRequestBody gzippedBody = HttpRequestBody.gzip(originalBody); - assertNotNull(gzippedBody); - // Content length is unknown since compression is done lazily - assertEquals(-1, gzippedBody.contentLength()); - // Dump zipped content to bytes - ByteArrayOutputStream compressedOut = new ByteArrayOutputStream(); - gzippedBody.writeTo(compressedOut); - byte[] compressedBytes = compressedOut.toByteArray(); - // Decompress and verify content matches the original content - try (GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(compressedBytes)); - ByteArrayOutputStream decompressedOut = new ByteArrayOutputStream()) { - byte[] buffer = new byte[1024]; - int len; - while ((len = gzipIn.read(buffer)) != -1) { - decompressedOut.write(buffer, 0, len); - } - String decompressedContent = decompressedOut.toString("UTF-8"); - assertEquals(content, decompressedContent); + // TODO Test empty string + // TODO Test empty byte array + // TODO Test empty ByteBuffer list + + @Test + void testNullString() { + assertThrows(NullPointerException.class, () -> HttpRequestBody.of((String) null)); + } + + @Test + void testNullBytes() { + assertThrows(NullPointerException.class, () -> HttpRequestBody.of((byte[]) null)); + } + + @Test + void testNullByteBuffer() { + assertThrows(NullPointerException.class, () -> HttpRequestBody.of((List) null)); + } + + @Test + void testMultipartBuilder() { + HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); + assertNotNull(builder); + } + + @Test + void testMultipartContentType() { + HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); + builder.addFormDataPart("name", "value"); + String contentType = builder.contentType(); + assertTrue(contentType.startsWith("multipart/form-data; boundary=")); + } + + @Test + void testMultipartAddFormDataPart() throws IOException { + HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); + builder.addFormDataPart("name", "value"); + HttpRequestBody body = builder.build(); + + assertNotNull(body); + assertTrue(body.contentLength() > 0); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + body.writeTo(out); + String content = out.toString("UTF-8"); + assertTrue(content.contains("name=\"name\"")); + assertTrue(content.contains("value")); + } + + @Test + void testMultipartAddFormDataPartWithFile() throws IOException { + HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); + HttpRequestBody fileBody = HttpRequestBody.of("file content"); + builder.addFormDataPart("file", "test.txt", fileBody); + HttpRequestBody body = builder.build(); + + assertNotNull(body); + assertTrue(body.contentLength() > 0); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + body.writeTo(out); + String content = out.toString("UTF-8"); + assertTrue(content.contains("name=\"file\"")); + assertTrue(content.contains("filename=\"test.txt\"")); + assertTrue(content.contains("file content")); + } + + @Test + void testMultipartAddPart() throws IOException { + HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); + Map headers = new HashMap<>(); + headers.put("Content-Disposition", "form-data; name=\"custom\"; filename=\"data.bin\""); + HttpRequestBody partBody = HttpRequestBody.of("custom content"); + builder.addPart(headers, partBody); + HttpRequestBody body = builder.build(); + + assertNotNull(body); + assertTrue(body.contentLength() > 0); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + body.writeTo(out); + String content = out.toString("UTF-8"); + assertTrue(content.contains("name=\"custom\"")); + assertTrue(content.contains("custom content")); } - } - @Test - void testGzipNullBody() { - assertThrows(NullPointerException.class, () -> HttpRequestBody.gzip(null)); - } + @Test + void testMultipartNullParams() { + HttpRequestBody.MultipartBuilder builder = HttpRequestBody.multipart(); + assertThrows(NullPointerException.class, () -> builder.addFormDataPart(null, "value")); + assertThrows(NullPointerException.class, () -> builder.addFormDataPart("name", null)); + + HttpRequestBody fileBody = HttpRequestBody.of("content"); + assertThrows(NullPointerException.class, () -> builder.addFormDataPart(null, "file.txt", fileBody)); + assertThrows(NullPointerException.class, () -> builder.addFormDataPart("name", null, fileBody)); + assertThrows(NullPointerException.class, () -> builder.addFormDataPart("name", "file.txt", null)); + + HttpRequestBody partBody = HttpRequestBody.of("content"); + Map headers = new HashMap<>(); + headers.put("Content-Disposition", "form-data; name=\"test\""); + assertThrows(NullPointerException.class, () -> builder.addPart(null, partBody)); + assertThrows(NullPointerException.class, () -> builder.addPart(headers, null)); + } + + @Test + void testGzipBody() throws IOException { + String content = "this is test content for gzip compression"; + HttpRequestBody originalBody = HttpRequestBody.of(content); + HttpRequestBody gzippedBody = HttpRequestBody.gzip(originalBody); + assertNotNull(gzippedBody); + // Content length is unknown since compression is done lazily + assertEquals(-1, gzippedBody.contentLength()); + // Dump zipped content to bytes + ByteArrayOutputStream compressedOut = new ByteArrayOutputStream(); + gzippedBody.writeTo(compressedOut); + byte[] compressedBytes = compressedOut.toByteArray(); + // Decompress and verify content matches the original content + try (GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(compressedBytes)); + ByteArrayOutputStream decompressedOut = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; + int len; + while ((len = gzipIn.read(buffer)) != -1) { + decompressedOut.write(buffer, 0, len); + } + String decompressedContent = decompressedOut.toString("UTF-8"); + assertEquals(content, decompressedContent); + } + } + + @Test + void testGzipNullBody() { + assertThrows(NullPointerException.class, () -> HttpRequestBody.gzip(null)); + } } diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestTest.java index 28b9efb0484..1b6b6546c41 100644 --- a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestTest.java +++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpRequestTest.java @@ -10,133 +10,134 @@ import org.junit.jupiter.api.Test; public class HttpRequestTest { - @Test - void testGetRequest() { - HttpUrl url = HttpUrl.parse("http://localhost:8080/api"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - assertNotNull(request); - assertEquals(url, request.url()); - assertEquals("GET", request.method()); - } - - @Test - void testPostRequest() { - HttpUrl url = HttpUrl.parse("http://localhost:8080/api"); - String payload = "{\"key\":\"value\"}"; - HttpRequestBody body = HttpRequestBody.of(payload); - - HttpRequest request = HttpRequest.newBuilder().url(url).post(body).build(); - - assertNotNull(request); - assertEquals(url, request.url()); - assertEquals("POST", request.method()); - } - - @Test - void testPutRequest() { - HttpUrl url = HttpUrl.parse("http://localhost:8080/api"); - String payload = "{\"key\":\"value\"}"; - HttpRequestBody body = HttpRequestBody.of(payload); - - HttpRequest request = HttpRequest.newBuilder().url(url).put(body).build(); - - assertEquals("PUT", request.method()); - } - - @Test - void testWithoutMethod() { - HttpRequest request = HttpRequest.newBuilder().url("http://localhost:8080/test").build(); - assertEquals("GET", request.method()); - } - - @Test - void testRequestWithUrlString() { - HttpRequest request = HttpRequest.newBuilder().url("http://localhost:8080/test").get().build(); - - assertNotNull(request); - assertEquals("http://localhost:8080/test", request.url().url()); - } - - @Test - void testRequestWithSingleHeader() { - HttpRequest request = - HttpRequest.newBuilder() - .url("http://localhost:8080/test") - .header("Content-Type", "application/json") - .get() - .build(); - - assertEquals("application/json", request.header("Content-Type")); - } - - @Test - void testRequestWithMultipleHeaders() { - HttpRequest request = - HttpRequest.newBuilder() - .url("http://localhost:8080/test") - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .addHeader("X-Custom-Header", "value1") - .addHeader("X-Custom-Header", "value2") - .get() - .build(); - - assertEquals("application/json", request.header("Content-Type")); - assertEquals("application/json", request.header("Accept")); - - List customHeaders = request.headers("X-Custom-Header"); - assertEquals(2, customHeaders.size()); - assertTrue(customHeaders.contains("value1")); - assertTrue(customHeaders.contains("value2")); - } - - @Test - void testWithoutUrl() { - assertThrows(IllegalStateException.class, () -> HttpRequest.newBuilder().get().build()); - } - - @Test - void testHeaderReplacement() { - HttpRequest request = - HttpRequest.newBuilder() - .url("http://localhost:8080/test") - .header("Content-Type", "text/plain") - .header("Content-Type", "application/json") - .get() - .build(); - - assertEquals("application/json", request.header("Content-Type")); - } - - @Test - void testMissingHeader() { - HttpRequest request = HttpRequest.newBuilder().url("http://localhost:8080/test").get().build(); - - assertNull(request.header("X-Missing")); - List missing = request.headers("X-Missing"); - assertNotNull(missing); - assertTrue(missing.isEmpty()); - } - - @Test - void testEmptyHeaderValue() { - HttpRequest request = - HttpRequest.newBuilder() - .url("http://localhost:8080/test") - .header("X-Empty-Header", "") - .get() - .build(); - - assertEquals("", request.header("X-Empty-Header")); - } - - @Test - void testNullHeader() { - HttpRequest.Builder builder = HttpRequest.newBuilder().url("http://localhost:8080/test"); - assertThrows(NullPointerException.class, () -> builder.header(null, "value")); - assertThrows(NullPointerException.class, () -> builder.header("X-Custom-Header", null)); - assertThrows(NullPointerException.class, () -> builder.addHeader(null, "value")); - assertThrows(NullPointerException.class, () -> builder.addHeader("X-Custom-Header", null)); - } + @Test + void testGetRequest() { + HttpUrl url = HttpUrl.parse("http://localhost:8080/api"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + assertNotNull(request); + assertEquals(url, request.url()); + assertEquals("GET", request.method()); + } + + @Test + void testPostRequest() { + HttpUrl url = HttpUrl.parse("http://localhost:8080/api"); + String payload = "{\"key\":\"value\"}"; + HttpRequestBody body = HttpRequestBody.of(payload); + + HttpRequest request = HttpRequest.newBuilder().url(url).post(body).build(); + + assertNotNull(request); + assertEquals(url, request.url()); + assertEquals("POST", request.method()); + } + + @Test + void testPutRequest() { + HttpUrl url = HttpUrl.parse("http://localhost:8080/api"); + String payload = "{\"key\":\"value\"}"; + HttpRequestBody body = HttpRequestBody.of(payload); + + HttpRequest request = HttpRequest.newBuilder().url(url).put(body).build(); + + assertEquals("PUT", request.method()); + } + + @Test + void testWithoutMethod() { + HttpRequest request = + HttpRequest.newBuilder().url("http://localhost:8080/test").build(); + assertEquals("GET", request.method()); + } + + @Test + void testRequestWithUrlString() { + HttpRequest request = + HttpRequest.newBuilder().url("http://localhost:8080/test").get().build(); + + assertNotNull(request); + assertEquals("http://localhost:8080/test", request.url().url()); + } + + @Test + void testRequestWithSingleHeader() { + HttpRequest request = HttpRequest.newBuilder() + .url("http://localhost:8080/test") + .header("Content-Type", "application/json") + .get() + .build(); + + assertEquals("application/json", request.header("Content-Type")); + } + + @Test + void testRequestWithMultipleHeaders() { + HttpRequest request = HttpRequest.newBuilder() + .url("http://localhost:8080/test") + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .addHeader("X-Custom-Header", "value1") + .addHeader("X-Custom-Header", "value2") + .get() + .build(); + + assertEquals("application/json", request.header("Content-Type")); + assertEquals("application/json", request.header("Accept")); + + List customHeaders = request.headers("X-Custom-Header"); + assertEquals(2, customHeaders.size()); + assertTrue(customHeaders.contains("value1")); + assertTrue(customHeaders.contains("value2")); + } + + @Test + void testWithoutUrl() { + assertThrows( + IllegalStateException.class, + () -> HttpRequest.newBuilder().get().build()); + } + + @Test + void testHeaderReplacement() { + HttpRequest request = HttpRequest.newBuilder() + .url("http://localhost:8080/test") + .header("Content-Type", "text/plain") + .header("Content-Type", "application/json") + .get() + .build(); + + assertEquals("application/json", request.header("Content-Type")); + } + + @Test + void testMissingHeader() { + HttpRequest request = + HttpRequest.newBuilder().url("http://localhost:8080/test").get().build(); + + assertNull(request.header("X-Missing")); + List missing = request.headers("X-Missing"); + assertNotNull(missing); + assertTrue(missing.isEmpty()); + } + + @Test + void testEmptyHeaderValue() { + HttpRequest request = HttpRequest.newBuilder() + .url("http://localhost:8080/test") + .header("X-Empty-Header", "") + .get() + .build(); + + assertEquals("", request.header("X-Empty-Header")); + } + + @Test + void testNullHeader() { + HttpRequest.Builder builder = HttpRequest.newBuilder().url("http://localhost:8080/test"); + assertThrows(NullPointerException.class, () -> builder.header(null, "value")); + assertThrows(NullPointerException.class, () -> builder.header("X-Custom-Header", null)); + assertThrows(NullPointerException.class, () -> builder.addHeader(null, "value")); + assertThrows(NullPointerException.class, () -> builder.addHeader("X-Custom-Header", null)); + } } diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpResponseTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpResponseTest.java index 2bfde756dcc..8e93469478e 100644 --- a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpResponseTest.java +++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpResponseTest.java @@ -22,115 +22,113 @@ @ExtendWith(MockServerExtension.class) public class HttpResponseTest { - private ClientAndServer server; - private HttpClient client; - private String baseUrl; - - @BeforeEach - void setUp(ClientAndServer server) { - this.server = server; - this.client = HttpClient.newBuilder().build(); - this.baseUrl = "http://localhost:" + server.getPort(); - } - - @AfterEach - void tearDown() { - this.server.reset(); - } - - @Test - void testBody() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - String responseBody = "content"; - this.server.when(expectedRequest).respond(response().withBody(responseBody)); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - HttpResponse response = this.client.execute(request); - - assertNotNull(response); - assertEquals(200, response.code()); - assertTrue(response.isSuccessful()); - try (InputStream body = response.body()) { - assertEquals(responseBody, readAll(body)); + private ClientAndServer server; + private HttpClient client; + private String baseUrl; + + @BeforeEach + void setUp(ClientAndServer server) { + this.server = server; + this.client = HttpClient.newBuilder().build(); + this.baseUrl = "http://localhost:" + server.getPort(); } - } - @Test - void testEmptyBody() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - this.server.when(expectedRequest).respond(response()); + @AfterEach + void tearDown() { + this.server.reset(); + } + + @Test + void testBody() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + String responseBody = "content"; + this.server.when(expectedRequest).respond(response().withBody(responseBody)); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(200, response.code()); + assertTrue(response.isSuccessful()); + try (InputStream body = response.body()) { + assertEquals(responseBody, readAll(body)); + } + } - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + @Test + void testEmptyBody() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + this.server.when(expectedRequest).respond(response()); - HttpResponse response = this.client.execute(request); + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - assertNotNull(response); - assertEquals(200, response.code()); - assertTrue(response.isSuccessful()); - try (InputStream body = response.body()) { - assertEquals("", readAll(body)); + HttpResponse response = this.client.execute(request); + + assertNotNull(response); + assertEquals(200, response.code()); + assertTrue(response.isSuccessful()); + try (InputStream body = response.body()) { + assertEquals("", readAll(body)); + } + } + + @Test + void testHeader() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + org.mockserver.model.HttpResponse resultResponse = response().withHeader("Content-Type", "text/plain"); + this.server.when(expectedRequest).respond(resultResponse); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + HttpResponse response = this.client.execute(request); + + // case-insensitive + assertEquals("text/plain", response.header("Content-Type")); + assertEquals("text/plain", response.header("content-type")); + assertEquals("text/plain", response.header("CONTENT-TYPE")); + // missing header + assertNull(response.header("X-Missing-Header")); + assertTrue(response.headers("X-Missing-Header").isEmpty()); } - } - - @Test - void testHeader() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - org.mockserver.model.HttpResponse resultResponse = - response().withHeader("Content-Type", "text/plain"); - this.server.when(expectedRequest).respond(resultResponse); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - HttpResponse response = this.client.execute(request); - - // case-insensitive - assertEquals("text/plain", response.header("Content-Type")); - assertEquals("text/plain", response.header("content-type")); - assertEquals("text/plain", response.header("CONTENT-TYPE")); - // missing header - assertNull(response.header("X-Missing-Header")); - assertTrue(response.headers("X-Missing-Header").isEmpty()); - } - - @Test - void testHeaderNames() throws IOException { - org.mockserver.model.HttpRequest expectedRequest = - request().withMethod("GET").withPath("/test"); - org.mockserver.model.HttpResponse resultResponse = - response() - .withHeader("Content-Type", "application/json") - .withHeader("X-Custom-Header", "custom-value") - .withHeader("X-Another-Header", "another-value"); - this.server.when(expectedRequest).respond(resultResponse); - - HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); - HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); - - HttpResponse response = this.client.execute(request); - - Set headerNames = response.headerNames(); - assertTrue(headerNames.contains("Content-Type")); - assertTrue(headerNames.contains("X-Custom-Header")); - assertTrue(headerNames.contains("X-Another-Header")); - } - - private String readAll(InputStream in) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(in, UTF_8)); - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - if (sb.length() > 0) { - sb.append('\n'); - } - sb.append(line); + + @Test + void testHeaderNames() throws IOException { + org.mockserver.model.HttpRequest expectedRequest = + request().withMethod("GET").withPath("/test"); + org.mockserver.model.HttpResponse resultResponse = response() + .withHeader("Content-Type", "application/json") + .withHeader("X-Custom-Header", "custom-value") + .withHeader("X-Another-Header", "another-value"); + this.server.when(expectedRequest).respond(resultResponse); + + HttpUrl url = HttpUrl.parse(this.baseUrl + "/test"); + HttpRequest request = HttpRequest.newBuilder().url(url).get().build(); + + HttpResponse response = this.client.execute(request); + + Set headerNames = response.headerNames(); + assertTrue(headerNames.contains("Content-Type")); + assertTrue(headerNames.contains("X-Custom-Header")); + assertTrue(headerNames.contains("X-Another-Header")); + } + + private String readAll(InputStream in) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(in, UTF_8)); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(line); + } + return sb.toString(); } - return sb.toString(); - } } diff --git a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpUrlTest.java b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpUrlTest.java index da4f6f1b513..8897cff7b85 100644 --- a/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpUrlTest.java +++ b/components/http/http-api/src/testFixtures/java/datadog/http/client/HttpUrlTest.java @@ -12,414 +12,414 @@ // Mostly a bunch of generated tests to ensure similar behavior of the different implementations public class HttpUrlTest { - // ==================== parse() tests ==================== - - @Test - void testParseSimpleUrl() { - HttpUrl url = HttpUrl.parse("https://example.com"); - - assertNotNull(url); - assertEquals("https", url.scheme()); - assertEquals("example.com", url.host()); - assertEquals(443, url.port()); - } - - @Test - void testParseUrlWithPort() { - HttpUrl url = HttpUrl.parse("http://localhost:8080"); - - assertEquals("http", url.scheme()); - assertEquals("localhost", url.host()); - assertEquals(8080, url.port()); - } - - @Test - void testParseUrlWithPath() { - HttpUrl url = HttpUrl.parse("https://example.com/api/v1/users"); - - assertEquals("https", url.scheme()); - assertEquals("example.com", url.host()); - assertTrue(url.url().contains("/api/v1/users")); - } - - @Test - void testParseUrlWithQueryParameters() { - HttpUrl url = HttpUrl.parse("https://example.com/search?q=test&page=1"); - - assertTrue(url.url().contains("q=test")); - assertTrue(url.url().contains("page=1")); - } - - @Test - void testParseInvalidUrl() { - assertThrows(IllegalArgumentException.class, () -> HttpUrl.parse("not a valid url")); - } - - @Test - void testParseNullUrl() { - assertThrows(NullPointerException.class, () -> HttpUrl.parse(null)); - } - - // ==================== from(URI) tests ==================== - - @Test - void testFromUri() { - URI uri = URI.create("https://example.com:8443/path"); - HttpUrl url = HttpUrl.from(uri); - - assertNotNull(url); - assertEquals("https", url.scheme()); - assertEquals("example.com", url.host()); - assertEquals(8443, url.port()); - assertTrue(url.url().contains("/path")); - } - - @Test - void testFromUriNull() { - assertThrows(NullPointerException.class, () -> HttpUrl.from(null)); - } - - // ==================== scheme() tests ==================== - - @Test - void testSchemeHttp() { - HttpUrl url = HttpUrl.parse("http://example.com"); - assertEquals("http", url.scheme()); - } - - @Test - void testSchemeHttps() { - HttpUrl url = HttpUrl.parse("https://example.com"); - assertEquals("https", url.scheme()); - } - - // ==================== host() tests ==================== - - @Test - void testHostDomain() { - HttpUrl url = HttpUrl.parse("https://www.example.com"); - assertEquals("www.example.com", url.host()); - } - - @Test - void testHostLocalhost() { - HttpUrl url = HttpUrl.parse("http://localhost:8080"); - assertEquals("localhost", url.host()); - } - - @Test - void testHostIpAddress() { - HttpUrl url = HttpUrl.parse("http://192.168.1.1:8080"); - assertEquals("192.168.1.1", url.host()); - } - - // ==================== port() tests ==================== - - @Test - void testPortExplicit() { - HttpUrl url = HttpUrl.parse("https://example.com:8443"); - assertEquals(8443, url.port()); - } - - @Test - void testPortDefaultHttp() { - HttpUrl url = HttpUrl.parse("http://example.com"); - assertEquals(80, url.port()); - } - - @Test - void testPortDefaultHttps() { - HttpUrl url = HttpUrl.parse("https://example.com"); - assertEquals(443, url.port()); - } - - // ==================== resolve() tests ==================== - - @Test - void testResolveRelativePath() { - HttpUrl baseUrl = HttpUrl.parse("https://example.com/api"); - HttpUrl resolved = baseUrl.resolve("users"); - - assertTrue(resolved.url().contains("example.com")); - assertTrue(resolved.url().contains("users")); - } - - @Test - void testResolveAbsolutePath() { - HttpUrl baseUrl = HttpUrl.parse("https://example.com/api/v1"); - HttpUrl resolved = baseUrl.resolve("/v2/users"); - - assertTrue(resolved.url().contains("example.com")); - assertTrue(resolved.url().contains("/v2/users")); - assertFalse(resolved.url().contains("/api")); - } - - @Test - void testResolveWithQueryParameters() { - HttpUrl baseUrl = HttpUrl.parse("https://example.com/api"); - HttpUrl resolved = baseUrl.resolve("search?q=test"); - - assertTrue(resolved.url().contains("search")); - assertTrue(resolved.url().contains("q=test")); - } - - // ==================== newBuilder() tests ==================== - - @Test - void testNewBuilderPreservesUrl() { - HttpUrl original = HttpUrl.parse("https://example.com:8443/api"); - HttpUrl rebuilt = original.newBuilder().build(); - - assertEquals(original.scheme(), rebuilt.scheme()); - assertEquals(original.host(), rebuilt.host()); - assertEquals(original.port(), rebuilt.port()); - } - - @Test - void testNewBuilderAllowsModification() { - HttpUrl original = HttpUrl.parse("https://example.com/api"); - HttpUrl modified = original.newBuilder().addPathSegment("v2").build(); - - assertTrue(modified.url().contains("/api")); - assertTrue(modified.url().contains("v2")); - } - - // ==================== addPathSegment() tests ==================== - - @Test - void testAddPathSegmentSingle() { - HttpUrl url = - HttpUrl.builder().scheme("https").host("example.com").addPathSegment("api").build(); - - assertTrue(url.url().contains("/api")); - } - - @Test - void testAddPathSegmentMultiple() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .addPathSegment("api") - .addPathSegment("v1") - .addPathSegment("users") - .build(); - - String urlString = url.url(); - assertTrue(urlString.contains("/api")); - assertTrue(urlString.contains("/v1")); - assertTrue(urlString.contains("/users")); - } - - @Test - void testAddPathSegmentWithPort() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .port(8443) - .addPathSegment("api") - .build(); - - String urlString = url.url(); - assertTrue(urlString.contains(":8443")); - assertTrue(urlString.contains("/api")); - } - - // ==================== builder scheme/host/port tests ==================== - - @Test - void testBuilderSchemeHostPort() { - HttpUrl url = HttpUrl.builder().scheme("https").host("api.example.com").port(8443).build(); - - assertEquals("https", url.scheme()); - assertEquals("api.example.com", url.host()); - assertEquals(8443, url.port()); - } - - @Test - void testBuilderDefaultScheme() { - HttpUrl url = HttpUrl.builder().host("example.com").build(); - - assertEquals("http", url.scheme()); - } - - // ==================== addQueryParameter() tests ==================== - - @Test - void testAddQueryParameterSingle() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .addQueryParameter("key", "value") - .build(); - - String urlString = url.url(); - // OkHttp adds trailing slash, JDK doesn't - both are valid - assertTrue( - urlString.matches("https://example\\.com/?\\?key=value"), - "Expected URL with query parameter, got: " + urlString); - } - - @Test - void testAddQueryParameterMultiple() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .addQueryParameter("key1", "value1") - .addQueryParameter("key2", "value2") - .addQueryParameter("key3", "value3") - .build(); - - String urlString = url.url(); - // OkHttp adds trailing slash, JDK doesn't - both are valid - assertTrue( - urlString.matches("https://example\\.com/?\\?.*"), - "Expected URL with query parameters, got: " + urlString); - assertTrue(urlString.contains("key1=value1")); - assertTrue(urlString.contains("key2=value2")); - assertTrue(urlString.contains("key3=value3")); - assertTrue(urlString.contains("&")); - } - - @Test - void testAddQueryParameterWithNullValue() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .addQueryParameter("flag", null) - .build(); - - String urlString = url.url(); - assertTrue(urlString.contains("flag")); - assertFalse(urlString.contains("=")); - assertFalse(urlString.contains("null")); - } - - @Test - void testAddQueryParameterWithEncoding() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .addQueryParameter("message", "hello world") - .addQueryParameter("special", "a=b&c=d") - .build(); - - String urlString = url.url(); - // Values should be URL encoded - accept both + and %20 for space - assertTrue( - urlString.contains("message=hello+world") || urlString.contains("message=hello%20world"), - "Expected encoded space in URL, got: " + urlString); - assertTrue( - urlString.contains("special=a%3Db%26c%3Dd"), - "Expected encoded special chars in URL, got: " + urlString); - } - - @Test - void testAddQueryParameterWithPath() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .addPathSegment("api") - .addPathSegment("v1") - .addQueryParameter("page", "1") - .addQueryParameter("limit", "10") - .build(); - - String urlString = url.url(); - assertTrue(urlString.contains("example.com/api/v1")); - assertTrue(urlString.contains("page=1")); - assertTrue(urlString.contains("limit=10")); - } - - @Test - void testAddQueryParameterFromExistingUrl() { - HttpUrl baseUrl = HttpUrl.parse("https://example.com/api"); - HttpUrl url = baseUrl.newBuilder().addQueryParameter("token", "abc123").build(); - - String urlString = url.url(); - assertTrue(urlString.contains("example.com/api")); - assertTrue(urlString.contains("token=abc123")); - } - - @Test - void testAddQueryParameterPreservesExistingQuery() { - HttpUrl baseUrl = HttpUrl.parse("https://example.com/api?existing=param"); - HttpUrl url = baseUrl.newBuilder().addQueryParameter("new", "value").build(); - - String urlString = url.url(); - assertTrue(urlString.contains("existing=param")); - assertTrue(urlString.contains("new=value")); - } - - @Test - void testAddQueryParameterEmptyValue() { - HttpUrl url = - HttpUrl.builder().scheme("https").host("example.com").addQueryParameter("key", "").build(); - - String urlString = url.url(); - assertTrue(urlString.contains("key=")); - } - - @Test - void testAddQueryParameterSpecialCharactersInName() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .addQueryParameter("my-key", "value") - .addQueryParameter("my_key", "value2") - .build(); - - String urlString = url.url(); - assertTrue(urlString.contains("my-key=value")); - assertTrue(urlString.contains("my_key=value2")); - } - - @Test - void testAddQueryParameterWithPort() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .port(8443) - .addQueryParameter("key", "value") - .build(); - - String urlString = url.url(); - assertTrue(urlString.contains(":8443")); - assertTrue(urlString.contains("key=value")); - } - - // ==================== url() tests ==================== - - @Test - void testUrlReturnsCompleteUrl() { - HttpUrl url = - HttpUrl.builder() - .scheme("https") - .host("example.com") - .port(8443) - .addPathSegment("api") - .addQueryParameter("key", "value") - .build(); - - String urlString = url.url(); - assertTrue(urlString.startsWith("https://")); - assertTrue(urlString.contains("example.com")); - assertTrue(urlString.contains(":8443")); - assertTrue(urlString.contains("/api")); - assertTrue(urlString.contains("key=value")); - } - - @Test - void testUrlMatchesToString() { - HttpUrl url = HttpUrl.parse("https://example.com/api"); - assertEquals(url.url(), url.toString()); - } + // ==================== parse() tests ==================== + + @Test + void testParseSimpleUrl() { + HttpUrl url = HttpUrl.parse("https://example.com"); + + assertNotNull(url); + assertEquals("https", url.scheme()); + assertEquals("example.com", url.host()); + assertEquals(443, url.port()); + } + + @Test + void testParseUrlWithPort() { + HttpUrl url = HttpUrl.parse("http://localhost:8080"); + + assertEquals("http", url.scheme()); + assertEquals("localhost", url.host()); + assertEquals(8080, url.port()); + } + + @Test + void testParseUrlWithPath() { + HttpUrl url = HttpUrl.parse("https://example.com/api/v1/users"); + + assertEquals("https", url.scheme()); + assertEquals("example.com", url.host()); + assertTrue(url.url().contains("/api/v1/users")); + } + + @Test + void testParseUrlWithQueryParameters() { + HttpUrl url = HttpUrl.parse("https://example.com/search?q=test&page=1"); + + assertTrue(url.url().contains("q=test")); + assertTrue(url.url().contains("page=1")); + } + + @Test + void testParseInvalidUrl() { + assertThrows(IllegalArgumentException.class, () -> HttpUrl.parse("not a valid url")); + } + + @Test + void testParseNullUrl() { + assertThrows(NullPointerException.class, () -> HttpUrl.parse(null)); + } + + // ==================== from(URI) tests ==================== + + @Test + void testFromUri() { + URI uri = URI.create("https://example.com:8443/path"); + HttpUrl url = HttpUrl.from(uri); + + assertNotNull(url); + assertEquals("https", url.scheme()); + assertEquals("example.com", url.host()); + assertEquals(8443, url.port()); + assertTrue(url.url().contains("/path")); + } + + @Test + void testFromUriNull() { + assertThrows(NullPointerException.class, () -> HttpUrl.from(null)); + } + + // ==================== scheme() tests ==================== + + @Test + void testSchemeHttp() { + HttpUrl url = HttpUrl.parse("http://example.com"); + assertEquals("http", url.scheme()); + } + + @Test + void testSchemeHttps() { + HttpUrl url = HttpUrl.parse("https://example.com"); + assertEquals("https", url.scheme()); + } + + // ==================== host() tests ==================== + + @Test + void testHostDomain() { + HttpUrl url = HttpUrl.parse("https://www.example.com"); + assertEquals("www.example.com", url.host()); + } + + @Test + void testHostLocalhost() { + HttpUrl url = HttpUrl.parse("http://localhost:8080"); + assertEquals("localhost", url.host()); + } + + @Test + void testHostIpAddress() { + HttpUrl url = HttpUrl.parse("http://192.168.1.1:8080"); + assertEquals("192.168.1.1", url.host()); + } + + // ==================== port() tests ==================== + + @Test + void testPortExplicit() { + HttpUrl url = HttpUrl.parse("https://example.com:8443"); + assertEquals(8443, url.port()); + } + + @Test + void testPortDefaultHttp() { + HttpUrl url = HttpUrl.parse("http://example.com"); + assertEquals(80, url.port()); + } + + @Test + void testPortDefaultHttps() { + HttpUrl url = HttpUrl.parse("https://example.com"); + assertEquals(443, url.port()); + } + + // ==================== resolve() tests ==================== + + @Test + void testResolveRelativePath() { + HttpUrl baseUrl = HttpUrl.parse("https://example.com/api"); + HttpUrl resolved = baseUrl.resolve("users"); + + assertTrue(resolved.url().contains("example.com")); + assertTrue(resolved.url().contains("users")); + } + + @Test + void testResolveAbsolutePath() { + HttpUrl baseUrl = HttpUrl.parse("https://example.com/api/v1"); + HttpUrl resolved = baseUrl.resolve("/v2/users"); + + assertTrue(resolved.url().contains("example.com")); + assertTrue(resolved.url().contains("/v2/users")); + assertFalse(resolved.url().contains("/api")); + } + + @Test + void testResolveWithQueryParameters() { + HttpUrl baseUrl = HttpUrl.parse("https://example.com/api"); + HttpUrl resolved = baseUrl.resolve("search?q=test"); + + assertTrue(resolved.url().contains("search")); + assertTrue(resolved.url().contains("q=test")); + } + + // ==================== newBuilder() tests ==================== + + @Test + void testNewBuilderPreservesUrl() { + HttpUrl original = HttpUrl.parse("https://example.com:8443/api"); + HttpUrl rebuilt = original.newBuilder().build(); + + assertEquals(original.scheme(), rebuilt.scheme()); + assertEquals(original.host(), rebuilt.host()); + assertEquals(original.port(), rebuilt.port()); + } + + @Test + void testNewBuilderAllowsModification() { + HttpUrl original = HttpUrl.parse("https://example.com/api"); + HttpUrl modified = original.newBuilder().addPathSegment("v2").build(); + + assertTrue(modified.url().contains("/api")); + assertTrue(modified.url().contains("v2")); + } + + // ==================== addPathSegment() tests ==================== + + @Test + void testAddPathSegmentSingle() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addPathSegment("api") + .build(); + + assertTrue(url.url().contains("/api")); + } + + @Test + void testAddPathSegmentMultiple() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addPathSegment("api") + .addPathSegment("v1") + .addPathSegment("users") + .build(); + + String urlString = url.url(); + assertTrue(urlString.contains("/api")); + assertTrue(urlString.contains("/v1")); + assertTrue(urlString.contains("/users")); + } + + @Test + void testAddPathSegmentWithPort() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .port(8443) + .addPathSegment("api") + .build(); + + String urlString = url.url(); + assertTrue(urlString.contains(":8443")); + assertTrue(urlString.contains("/api")); + } + + // ==================== builder scheme/host/port tests ==================== + + @Test + void testBuilderSchemeHostPort() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("api.example.com") + .port(8443) + .build(); + + assertEquals("https", url.scheme()); + assertEquals("api.example.com", url.host()); + assertEquals(8443, url.port()); + } + + @Test + void testBuilderDefaultScheme() { + HttpUrl url = HttpUrl.builder().host("example.com").build(); + + assertEquals("http", url.scheme()); + } + + // ==================== addQueryParameter() tests ==================== + + @Test + void testAddQueryParameterSingle() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addQueryParameter("key", "value") + .build(); + + String urlString = url.url(); + // OkHttp adds trailing slash, JDK doesn't - both are valid + assertTrue( + urlString.matches("https://example\\.com/?\\?key=value"), + "Expected URL with query parameter, got: " + urlString); + } + + @Test + void testAddQueryParameterMultiple() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addQueryParameter("key1", "value1") + .addQueryParameter("key2", "value2") + .addQueryParameter("key3", "value3") + .build(); + + String urlString = url.url(); + // OkHttp adds trailing slash, JDK doesn't - both are valid + assertTrue( + urlString.matches("https://example\\.com/?\\?.*"), + "Expected URL with query parameters, got: " + urlString); + assertTrue(urlString.contains("key1=value1")); + assertTrue(urlString.contains("key2=value2")); + assertTrue(urlString.contains("key3=value3")); + assertTrue(urlString.contains("&")); + } + + @Test + void testAddQueryParameterWithNullValue() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addQueryParameter("flag", null) + .build(); + + String urlString = url.url(); + assertTrue(urlString.contains("flag")); + assertFalse(urlString.contains("=")); + assertFalse(urlString.contains("null")); + } + + @Test + void testAddQueryParameterWithEncoding() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addQueryParameter("message", "hello world") + .addQueryParameter("special", "a=b&c=d") + .build(); + + String urlString = url.url(); + // Values should be URL encoded - accept both + and %20 for space + assertTrue( + urlString.contains("message=hello+world") || urlString.contains("message=hello%20world"), + "Expected encoded space in URL, got: " + urlString); + assertTrue( + urlString.contains("special=a%3Db%26c%3Dd"), + "Expected encoded special chars in URL, got: " + urlString); + } + + @Test + void testAddQueryParameterWithPath() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addPathSegment("api") + .addPathSegment("v1") + .addQueryParameter("page", "1") + .addQueryParameter("limit", "10") + .build(); + + String urlString = url.url(); + assertTrue(urlString.contains("example.com/api/v1")); + assertTrue(urlString.contains("page=1")); + assertTrue(urlString.contains("limit=10")); + } + + @Test + void testAddQueryParameterFromExistingUrl() { + HttpUrl baseUrl = HttpUrl.parse("https://example.com/api"); + HttpUrl url = baseUrl.newBuilder().addQueryParameter("token", "abc123").build(); + + String urlString = url.url(); + assertTrue(urlString.contains("example.com/api")); + assertTrue(urlString.contains("token=abc123")); + } + + @Test + void testAddQueryParameterPreservesExistingQuery() { + HttpUrl baseUrl = HttpUrl.parse("https://example.com/api?existing=param"); + HttpUrl url = baseUrl.newBuilder().addQueryParameter("new", "value").build(); + + String urlString = url.url(); + assertTrue(urlString.contains("existing=param")); + assertTrue(urlString.contains("new=value")); + } + + @Test + void testAddQueryParameterEmptyValue() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addQueryParameter("key", "") + .build(); + + String urlString = url.url(); + assertTrue(urlString.contains("key=")); + } + + @Test + void testAddQueryParameterSpecialCharactersInName() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .addQueryParameter("my-key", "value") + .addQueryParameter("my_key", "value2") + .build(); + + String urlString = url.url(); + assertTrue(urlString.contains("my-key=value")); + assertTrue(urlString.contains("my_key=value2")); + } + + @Test + void testAddQueryParameterWithPort() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .port(8443) + .addQueryParameter("key", "value") + .build(); + + String urlString = url.url(); + assertTrue(urlString.contains(":8443")); + assertTrue(urlString.contains("key=value")); + } + + // ==================== url() tests ==================== + + @Test + void testUrlReturnsCompleteUrl() { + HttpUrl url = HttpUrl.builder() + .scheme("https") + .host("example.com") + .port(8443) + .addPathSegment("api") + .addQueryParameter("key", "value") + .build(); + + String urlString = url.url(); + assertTrue(urlString.startsWith("https://")); + assertTrue(urlString.contains("example.com")); + assertTrue(urlString.contains(":8443")); + assertTrue(urlString.contains("/api")); + assertTrue(urlString.contains("key=value")); + } + + @Test + void testUrlMatchesToString() { + HttpUrl url = HttpUrl.parse("https://example.com/api"); + assertEquals(url.url(), url.toString()); + } } diff --git a/components/json/src/jmh/java/datadog/json/JsonWriterBenchmark.java b/components/json/src/jmh/java/datadog/json/JsonWriterBenchmark.java index 6e9adc62efc..a67aacaa31a 100644 --- a/components/json/src/jmh/java/datadog/json/JsonWriterBenchmark.java +++ b/components/json/src/jmh/java/datadog/json/JsonWriterBenchmark.java @@ -14,93 +14,90 @@ @Fork(value = 1) @SuppressWarnings("unused") public class JsonWriterBenchmark { - @Benchmark - public void writeSimpleArray(Blackhole blackhole) { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginArray() - .beginObject() - .name("true") - .value(true) - .endObject() - .beginObject() - .name("false") - .value(false) - .endObject() - .endArray(); - blackhole.consume(writer.toString()); + @Benchmark + public void writeSimpleArray(Blackhole blackhole) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray() + .beginObject() + .name("true") + .value(true) + .endObject() + .beginObject() + .name("false") + .value(false) + .endObject() + .endArray(); + blackhole.consume(writer.toString()); + } } - } - @Benchmark - public void writeComplexArray(Blackhole blackhole) { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginArray() - .value("first level") - .beginArray() - .value("second level") - .beginArray() - .value("third level") - .beginObject() - .name("key") - .value("value") - .endObject() - .beginObject() - .name("key") - .value("value") - .endObject() - .endArray() // second level - .beginObject() - .name("key") - .value("value") - .endObject() - .endArray() // first level - .beginObject() - .name("key") - .value("value") - .endObject() - .value("last value") - .endArray(); - blackhole.consume(writer.toString()); + @Benchmark + public void writeComplexArray(Blackhole blackhole) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray() + .value("first level") + .beginArray() + .value("second level") + .beginArray() + .value("third level") + .beginObject() + .name("key") + .value("value") + .endObject() + .beginObject() + .name("key") + .value("value") + .endObject() + .endArray() // second level + .beginObject() + .name("key") + .value("value") + .endObject() + .endArray() // first level + .beginObject() + .name("key") + .value("value") + .endObject() + .value("last value") + .endArray(); + blackhole.consume(writer.toString()); + } } - } - @Benchmark - public void writeComplexObject(Blackhole blackhole) { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginObject() - .name("attrs") - .beginObject() - .name("attr1") - .value("value1") - .name("attr2") - .value("value2") - .endObject() - .name("data") - .beginArray() - .beginObject() - .name("x") - .value(1) - .name("y") - .value(12.3) - .endObject() - .beginObject() - .name("x") - .value(2) - .name("y") - .value(4.56) - .endObject() - .beginObject() - .name("x") - .value(3) - .name("y") - .value(789) - .endObject() - .endArray() - .endObject(); - blackhole.consume(writer.toString()); + @Benchmark + public void writeComplexObject(Blackhole blackhole) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject() + .name("attrs") + .beginObject() + .name("attr1") + .value("value1") + .name("attr2") + .value("value2") + .endObject() + .name("data") + .beginArray() + .beginObject() + .name("x") + .value(1) + .name("y") + .value(12.3) + .endObject() + .beginObject() + .name("x") + .value(2) + .name("y") + .value(4.56) + .endObject() + .beginObject() + .name("x") + .value(3) + .name("y") + .value(789) + .endObject() + .endArray() + .endObject(); + blackhole.consume(writer.toString()); + } } - } } diff --git a/components/json/src/main/java/datadog/json/JsonMapper.java b/components/json/src/main/java/datadog/json/JsonMapper.java index a997d6b9d5b..bb9e3d8e1da 100644 --- a/components/json/src/main/java/datadog/json/JsonMapper.java +++ b/components/json/src/main/java/datadog/json/JsonMapper.java @@ -12,210 +12,211 @@ /** Utility class for simple Java structure mapping into JSON strings. */ public final class JsonMapper { - private JsonMapper() {} - - /** - * Converts a {@link String} to a JSON string. - * - * @param string The string to convert. - * @return The converted JSON string. - */ - public static String toJson(String string) { - if (string == null || string.isEmpty()) { - return ""; + private JsonMapper() {} + + /** + * Converts a {@link String} to a JSON string. + * + * @param string The string to convert. + * @return The converted JSON string. + */ + public static String toJson(String string) { + if (string == null || string.isEmpty()) { + return ""; + } + try (JsonWriter writer = new JsonWriter()) { + writer.value(string); + return writer.toString(); + } + } + + /** + * Converts a {@link Map} to a JSON object. + * + * @param map The map to convert. + * @return The converted JSON object as Java string. + */ + public static String toJson(Map map) { + if (map == null || map.isEmpty()) { + return "{}"; + } + try (JsonWriter writer = new JsonWriter()) { + writeMap(writer, map); + return writer.toString(); + } + } + + /** + * Writes the map as JSON value to the given mapper. + * + * @param writer The writer to write the map as value to. + * @param map The map to write. + */ + public static void writeAsJsonValue(JsonWriter writer, Map map) { + if (writer == null) { + throw new NullPointerException("writer cannot be null"); + } + if (map == null) { + writer.beginObject(); + writer.endObject(); + } else { + writeMap(writer, map); + } + } + + private static void writeMap(JsonWriter writer, Map map) { + writer.beginObject(); + for (Map.Entry entry : map.entrySet()) { + writer.name(entry.getKey()); + Object value = entry.getValue(); + if (value == null) { + writer.nullValue(); + } else if (value instanceof String) { + writer.value((String) value); + } else if (value instanceof Double) { + writer.value((Double) value); + } else if (value instanceof Float) { + writer.value((Float) value); + } else if (value instanceof Long) { + writer.value((Long) value); + } else if (value instanceof Integer) { + writer.value((Integer) value); + } else if (value instanceof Boolean) { + writer.value((Boolean) value); + } else { + writer.value(value.toString()); + } + } + writer.endObject(); + } + + /** + * Converts a {@code Collection} to a JSON array. + * + * @param items The iterable to convert. + * @return The converted JSON array as Java string. + */ + @SuppressWarnings("DuplicatedCode") + public static String toJson(Collection items) { + if (items == null || items.isEmpty()) { + return "[]"; + } + try (JsonWriter writer = new JsonWriter()) { + writeArray(items, writer); + return writer.toString(); + } + } + + /** + * Writes the {@code Collection} as a JSON array to the given writer. + * + * @param items The collection to write. + * @param writer The writer to write the collection as JSON array to. + */ + public static void writeAsJsonValue(Collection items, JsonWriter writer) { + if (writer == null) { + throw new NullPointerException("writer cannot be null"); + } + if (items == null) { + writer.beginArray(); + writer.endArray(); + } else { + writeArray(items, writer); + } + } + + private static void writeArray(Iterable items, JsonWriter writer) { + writer.beginArray(); + for (String item : items) { + writer.value(item); + } + writer.endArray(); + } + + /** + * Converts a String array to a JSON array. + * + * @param items The array to convert. + * @return The converted JSON array as Java string. + */ + @SuppressWarnings("DuplicatedCode") + public static String toJson(String[] items) { + if (items == null) { + return "[]"; + } + try (JsonWriter writer = new JsonWriter()) { + writeArray(items, writer); + return writer.toString(); + } + } + + /** + * Writes the String array as a JSON array to the given writer. + * + * @param items The array to write. + * @param writer The writer to write the array as JSON array to. + */ + public static void writeAsJsonValue(String[] items, JsonWriter writer) { + if (writer == null) { + throw new NullPointerException("writer cannot be null"); + } + if (items == null) { + writer.beginArray(); + writer.endArray(); + } else { + writeArray(items, writer); + } + } + + private static void writeArray(String[] items, JsonWriter writer) { + writer.beginArray(); + for (String item : items) { + writer.value(item); + } + writer.endArray(); + } + + /** + * Parses a JSON string into a {@link Map}. + * + * @param json The JSON string to parse. + * @return A {@link Map} containing the parsed JSON object's key-value pairs. + * @throws IOException If the JSON is invalid or a reader error occurs. + */ + @SuppressWarnings("unchecked") + public static Map fromJsonToMap(String json) throws IOException { + if (json == null || json.isEmpty() || "{}".equals(json) || "null".equals(json)) { + return emptyMap(); + } + try (JsonReader reader = new JsonReader(json)) { + Object value = reader.nextValue(); + if (!(value instanceof Map)) { + throw new IOException( + "Expected JSON object but was " + value.getClass().getSimpleName()); + } + return (Map) value; + } + } + + /** + * Parses a JSON string array into a {@code List}. + * + * @param json The JSON string array to parse. + * @return A {@code List} containing the parsed JSON strings. + * @throws IOException If the JSON is invalid or a reader error occurs. + */ + public static List fromJsonToList(String json) throws IOException { + if (json == null || json.isEmpty() || "[]".equals(json) || "null".equals(json)) { + return emptyList(); + } + try (JsonReader reader = new JsonReader(json)) { + List list = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + list.add(reader.nextString()); + } + reader.endArray(); + return list; + } } - try (JsonWriter writer = new JsonWriter()) { - writer.value(string); - return writer.toString(); - } - } - - /** - * Converts a {@link Map} to a JSON object. - * - * @param map The map to convert. - * @return The converted JSON object as Java string. - */ - public static String toJson(Map map) { - if (map == null || map.isEmpty()) { - return "{}"; - } - try (JsonWriter writer = new JsonWriter()) { - writeMap(writer, map); - return writer.toString(); - } - } - - /** - * Writes the map as JSON value to the given mapper. - * - * @param writer The writer to write the map as value to. - * @param map The map to write. - */ - public static void writeAsJsonValue(JsonWriter writer, Map map) { - if (writer == null) { - throw new NullPointerException("writer cannot be null"); - } - if (map == null) { - writer.beginObject(); - writer.endObject(); - } else { - writeMap(writer, map); - } - } - - private static void writeMap(JsonWriter writer, Map map) { - writer.beginObject(); - for (Map.Entry entry : map.entrySet()) { - writer.name(entry.getKey()); - Object value = entry.getValue(); - if (value == null) { - writer.nullValue(); - } else if (value instanceof String) { - writer.value((String) value); - } else if (value instanceof Double) { - writer.value((Double) value); - } else if (value instanceof Float) { - writer.value((Float) value); - } else if (value instanceof Long) { - writer.value((Long) value); - } else if (value instanceof Integer) { - writer.value((Integer) value); - } else if (value instanceof Boolean) { - writer.value((Boolean) value); - } else { - writer.value(value.toString()); - } - } - writer.endObject(); - } - - /** - * Converts a {@code Collection} to a JSON array. - * - * @param items The iterable to convert. - * @return The converted JSON array as Java string. - */ - @SuppressWarnings("DuplicatedCode") - public static String toJson(Collection items) { - if (items == null || items.isEmpty()) { - return "[]"; - } - try (JsonWriter writer = new JsonWriter()) { - writeArray(items, writer); - return writer.toString(); - } - } - - /** - * Writes the {@code Collection} as a JSON array to the given writer. - * - * @param items The collection to write. - * @param writer The writer to write the collection as JSON array to. - */ - public static void writeAsJsonValue(Collection items, JsonWriter writer) { - if (writer == null) { - throw new NullPointerException("writer cannot be null"); - } - if (items == null) { - writer.beginArray(); - writer.endArray(); - } else { - writeArray(items, writer); - } - } - - private static void writeArray(Iterable items, JsonWriter writer) { - writer.beginArray(); - for (String item : items) { - writer.value(item); - } - writer.endArray(); - } - - /** - * Converts a String array to a JSON array. - * - * @param items The array to convert. - * @return The converted JSON array as Java string. - */ - @SuppressWarnings("DuplicatedCode") - public static String toJson(String[] items) { - if (items == null) { - return "[]"; - } - try (JsonWriter writer = new JsonWriter()) { - writeArray(items, writer); - return writer.toString(); - } - } - - /** - * Writes the String array as a JSON array to the given writer. - * - * @param items The array to write. - * @param writer The writer to write the array as JSON array to. - */ - public static void writeAsJsonValue(String[] items, JsonWriter writer) { - if (writer == null) { - throw new NullPointerException("writer cannot be null"); - } - if (items == null) { - writer.beginArray(); - writer.endArray(); - } else { - writeArray(items, writer); - } - } - - private static void writeArray(String[] items, JsonWriter writer) { - writer.beginArray(); - for (String item : items) { - writer.value(item); - } - writer.endArray(); - } - - /** - * Parses a JSON string into a {@link Map}. - * - * @param json The JSON string to parse. - * @return A {@link Map} containing the parsed JSON object's key-value pairs. - * @throws IOException If the JSON is invalid or a reader error occurs. - */ - @SuppressWarnings("unchecked") - public static Map fromJsonToMap(String json) throws IOException { - if (json == null || json.isEmpty() || "{}".equals(json) || "null".equals(json)) { - return emptyMap(); - } - try (JsonReader reader = new JsonReader(json)) { - Object value = reader.nextValue(); - if (!(value instanceof Map)) { - throw new IOException("Expected JSON object but was " + value.getClass().getSimpleName()); - } - return (Map) value; - } - } - - /** - * Parses a JSON string array into a {@code List}. - * - * @param json The JSON string array to parse. - * @return A {@code List} containing the parsed JSON strings. - * @throws IOException If the JSON is invalid or a reader error occurs. - */ - public static List fromJsonToList(String json) throws IOException { - if (json == null || json.isEmpty() || "[]".equals(json) || "null".equals(json)) { - return emptyList(); - } - try (JsonReader reader = new JsonReader(json)) { - List list = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - list.add(reader.nextString()); - } - reader.endArray(); - return list; - } - } } diff --git a/components/json/src/main/java/datadog/json/JsonReader.java b/components/json/src/main/java/datadog/json/JsonReader.java index 771617cf0a2..2cc0d253530 100644 --- a/components/json/src/main/java/datadog/json/JsonReader.java +++ b/components/json/src/main/java/datadog/json/JsonReader.java @@ -13,503 +13,499 @@ * checks unless using the lenient mode. */ public class JsonReader implements AutoCloseable { - private static final int BUFFER_SIZE = 1024; - private final Reader reader; - private final char[] buffer = new char[BUFFER_SIZE]; - private final JsonStructure structure; - private int position = 0; - private int limit = 0; - private int lineNumber = 1; - private int linePosition = 0; - - /** Creates a reader with structure check. */ - public JsonReader(String json) { - this(new StringReader(json), true); - } - - /** Creates a reader with structure check. */ - public JsonReader(Reader reader) { - this(reader, true); - } - - /** - * Creates a reader. - * - * @param reader The source reader. - * @param safe {@code true} to use safe structure check, {@code false} for lenient mode. - */ - public JsonReader(Reader reader, boolean safe) { - if (reader == null) { - throw new IllegalArgumentException("reader cannot be null"); + private static final int BUFFER_SIZE = 1024; + private final Reader reader; + private final char[] buffer = new char[BUFFER_SIZE]; + private final JsonStructure structure; + private int position = 0; + private int limit = 0; + private int lineNumber = 1; + private int linePosition = 0; + + /** Creates a reader with structure check. */ + public JsonReader(String json) { + this(new StringReader(json), true); } - this.reader = reader; - this.structure = safe ? new SafeJsonStructure() : new LenientJsonStructure(); - } - - /** - * Begins reading a JSON object. - * - * @return This reader instance. - * @throws IOException If an I/O error occurs. - */ - public JsonReader beginObject() throws IOException { - char c = advanceUpToNextValueChar(); - if (c != '{') { - throw unexpectedSyntaxError("'{'", c); + + /** Creates a reader with structure check. */ + public JsonReader(Reader reader) { + this(reader, true); + } + + /** + * Creates a reader. + * + * @param reader The source reader. + * @param safe {@code true} to use safe structure check, {@code false} for lenient mode. + */ + public JsonReader(Reader reader, boolean safe) { + if (reader == null) { + throw new IllegalArgumentException("reader cannot be null"); + } + this.reader = reader; + this.structure = safe ? new SafeJsonStructure() : new LenientJsonStructure(); + } + + /** + * Begins reading a JSON object. + * + * @return This reader instance. + * @throws IOException If an I/O error occurs. + */ + public JsonReader beginObject() throws IOException { + char c = advanceUpToNextValueChar(); + if (c != '{') { + throw unexpectedSyntaxError("'{'", c); + } + advance(); + this.structure.beginObject(); + return this; } - advance(); - this.structure.beginObject(); - return this; - } - - /** - * Ends reading a JSON object. - * - * @return This reader instance. - * @throws IOException If an I/O error occurs. - */ - public JsonReader endObject() throws IOException { - consumeWhitespace(); - char c = peek(); - if (c != '}') { - throw unexpectedSyntaxError("'}'", c); + + /** + * Ends reading a JSON object. + * + * @return This reader instance. + * @throws IOException If an I/O error occurs. + */ + public JsonReader endObject() throws IOException { + consumeWhitespace(); + char c = peek(); + if (c != '}') { + throw unexpectedSyntaxError("'}'", c); + } + advance(); + this.structure.endObject(); + return this; } - advance(); - this.structure.endObject(); - return this; - } - - /** - * Begins reading a JSON array. - * - * @return This reader instance. - * @throws IOException If an I/O error occurs. - */ - public JsonReader beginArray() throws IOException { - char c = advanceUpToNextValueChar(); - if (c != '[') { - throw unexpectedSyntaxError("'['", c); + + /** + * Begins reading a JSON array. + * + * @return This reader instance. + * @throws IOException If an I/O error occurs. + */ + public JsonReader beginArray() throws IOException { + char c = advanceUpToNextValueChar(); + if (c != '[') { + throw unexpectedSyntaxError("'['", c); + } + advance(); + this.structure.beginArray(); + return this; } - advance(); - this.structure.beginArray(); - return this; - } - - /** - * Ends reading a JSON array. - * - * @return This reader instance. - * @throws IOException If an I/O error occurs. - */ - public JsonReader endArray() throws IOException { - consumeWhitespace(); - char c = peek(); - if (c != ']') { - throw unexpectedSyntaxError("']'", c); + + /** + * Ends reading a JSON array. + * + * @return This reader instance. + * @throws IOException If an I/O error occurs. + */ + public JsonReader endArray() throws IOException { + consumeWhitespace(); + char c = peek(); + if (c != ']') { + throw unexpectedSyntaxError("']'", c); + } + advance(); + this.structure.endArray(); + return this; } - advance(); - this.structure.endArray(); - return this; - } - - /** - * Reads the next property name in an object. - * - * @return The property name or null if the object is empty or ended. - * @throws IOException If an I/O error occurs. - */ - public String nextName() throws IOException { - char c = advanceUpToNextValueChar(); - if (c == '}') { - return null; + + /** + * Reads the next property name in an object. + * + * @return The property name or null if the object is empty or ended. + * @throws IOException If an I/O error occurs. + */ + public String nextName() throws IOException { + char c = advanceUpToNextValueChar(); + if (c == '}') { + return null; + } + if (c != '"') { + throw unexpectedSyntaxError("'\"'", c); + } + String name = readString(); + consumeWhitespace(); + c = peek(); + if (c != ':') { + throw unexpectedSyntaxError("':'", c); + } + advance(); + this.structure.addName(); + return name; } - if (c != '"') { - throw unexpectedSyntaxError("'\"'", c); + + /** + * Checks if the current value is null. + * + * @return true if the value is null. + * @throws IOException If an I/O error occurs. + */ + public boolean isNull() throws IOException { + return advanceUpToNextValueChar() == 'n'; } - String name = readString(); - consumeWhitespace(); - c = peek(); - if (c != ':') { - throw unexpectedSyntaxError("':'", c); + + /** + * Reads a boolean value. + * + * @return The boolean value. + * @throws IOException If an I/O error occurs. + */ + public boolean nextBoolean() throws IOException { + char c = advanceUpToNextValueChar(); + String value; + if (c == 't') { + value = readLiteral(4); + if (!"true".equals(value)) { + throw unexpectedSyntaxError("'true'", value); + } + return true; + } else if (c == 'f') { + value = readLiteral(5); + if (!"false".equals(value)) { + throw unexpectedSyntaxError("'false'", value); + } + return false; + } + throw unexpectedSyntaxError("'true' or 'false", c); } - advance(); - this.structure.addName(); - return name; - } - - /** - * Checks if the current value is null. - * - * @return true if the value is null. - * @throws IOException If an I/O error occurs. - */ - public boolean isNull() throws IOException { - return advanceUpToNextValueChar() == 'n'; - } - - /** - * Reads a boolean value. - * - * @return The boolean value. - * @throws IOException If an I/O error occurs. - */ - public boolean nextBoolean() throws IOException { - char c = advanceUpToNextValueChar(); - String value; - if (c == 't') { - value = readLiteral(4); - if (!"true".equals(value)) { - throw unexpectedSyntaxError("'true'", value); - } - return true; - } else if (c == 'f') { - value = readLiteral(5); - if (!"false".equals(value)) { - throw unexpectedSyntaxError("'false'", value); - } - return false; + + /** + * Reads a string value. + * + * @return The string value. + * @throws IOException If an I/O error occurs. + */ + public String nextString() throws IOException { + char c = advanceUpToNextValueChar(); + if (c != '"') { + throw unexpectedSyntaxError("'\"'", c); + } + return readString(); } - throw unexpectedSyntaxError("'true' or 'false", c); - } - - /** - * Reads a string value. - * - * @return The string value. - * @throws IOException If an I/O error occurs. - */ - public String nextString() throws IOException { - char c = advanceUpToNextValueChar(); - if (c != '"') { - throw unexpectedSyntaxError("'\"'", c); + + /** + * Reads a number as an int value. + * + * @return The int value. + * @throws IOException If the value is not an int, or a reader error occurs. + */ + public int nextInt() throws IOException { + String number = readNumber(); + try { + return Integer.parseInt(number); + } catch (NumberFormatException e) { + throw unexpectedSyntaxError("an integer", number); + } } - return readString(); - } - - /** - * Reads a number as an int value. - * - * @return The int value. - * @throws IOException If the value is not an int, or a reader error occurs. - */ - public int nextInt() throws IOException { - String number = readNumber(); - try { - return Integer.parseInt(number); - } catch (NumberFormatException e) { - throw unexpectedSyntaxError("an integer", number); + + /** + * Reads a number as a long value. + * + * @return The long value. + * @throws IOException If the value is not a long, or a reader error occurs. + */ + public long nextLong() throws IOException { + String number = readNumber(); + try { + return Long.parseLong(number); + } catch (NumberFormatException e) { + throw unexpectedSyntaxError("a long", number); + } } - } - - /** - * Reads a number as a long value. - * - * @return The long value. - * @throws IOException If the value is not a long, or a reader error occurs. - */ - public long nextLong() throws IOException { - String number = readNumber(); - try { - return Long.parseLong(number); - } catch (NumberFormatException e) { - throw unexpectedSyntaxError("a long", number); + + /** + * Reads a number as a double value. + * + * @return The double value. + * @throws IOException If an I/O error occurs. + */ + public double nextDouble() throws IOException { + String number = readNumber(); + try { + return Double.parseDouble(number); + } catch (NumberFormatException e) { + throw unexpectedSyntaxError("a double", number); + } } - } - - /** - * Reads a number as a double value. - * - * @return The double value. - * @throws IOException If an I/O error occurs. - */ - public double nextDouble() throws IOException { - String number = readNumber(); - try { - return Double.parseDouble(number); - } catch (NumberFormatException e) { - throw unexpectedSyntaxError("a double", number); + + /** + * Checks if there are more elements in the current array or object. + * + * @return {@code true} if there are more elements, {@code false} otherwise. + * @throws IOException If an I/O error occurs. + */ + public boolean hasNext() throws IOException { + consumeWhitespace(); + char c = peek(); + return c != ']' && c != '}'; } - } - - /** - * Checks if there are more elements in the current array or object. - * - * @return {@code true} if there are more elements, {@code false} otherwise. - * @throws IOException If an I/O error occurs. - */ - public boolean hasNext() throws IOException { - consumeWhitespace(); - char c = peek(); - return c != ']' && c != '}'; - } - - /** - * Reads the next value and automatically detects its type. - * - *

Supported types are: - * - *

    - *
  • {@link String} for quoted values - *
  • {@link Boolean} for {@code true}/{@code false} - *
  • {@link Integer}/{@link Long}/{@link Double} for numbers - *
  • {@link Map} for objects - *
  • {@link List} for arrays - *
- * - * @return The next value as its related Java type. - * @throws IOException If the JSON is invalid, or a reader error occurs. - */ - public Object nextValue() throws IOException { - char c = advanceUpToNextValueChar(); - switch (c) { - case '"': - return nextString(); - case '{': - Map map = new HashMap<>(); - beginObject(); - String name; - while ((name = nextName()) != null) { - map.put(name, nextValue()); + + /** + * Reads the next value and automatically detects its type. + * + *

Supported types are: + * + *

    + *
  • {@link String} for quoted values + *
  • {@link Boolean} for {@code true}/{@code false} + *
  • {@link Integer}/{@link Long}/{@link Double} for numbers + *
  • {@link Map} for objects + *
  • {@link List} for arrays + *
+ * + * @return The next value as its related Java type. + * @throws IOException If the JSON is invalid, or a reader error occurs. + */ + public Object nextValue() throws IOException { + char c = advanceUpToNextValueChar(); + switch (c) { + case '"': + return nextString(); + case '{': + Map map = new HashMap<>(); + beginObject(); + String name; + while ((name = nextName()) != null) { + map.put(name, nextValue()); + } + endObject(); + return map; + case '[': + List list = new ArrayList<>(); + beginArray(); + while (hasNext()) { + list.add(nextValue()); + } + endArray(); + return list; + case 't': + case 'f': + return nextBoolean(); + case 'n': + readLiteral(4); // Skip "null" + return null; + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + String number = readNumber(); + // Try parsing as integer first, then long, then fall back to double + if (!number.contains(".") && !number.contains("e") && !number.contains("E")) { + try { + return Integer.parseInt(number); + } catch (NumberFormatException e) { + try { + return Long.parseLong(number); + } catch (NumberFormatException e2) { + // Fall through to double + } + } + } + return Double.parseDouble(number); + default: + throw syntaxError("Unexpected character: " + c); } - endObject(); - return map; - case '[': - List list = new ArrayList<>(); - beginArray(); - while (hasNext()) { - list.add(nextValue()); + } + + private char advanceUpToNextValueChar() throws IOException { + consumeWhitespace(); + char c = peek(); + // Handle comma between values + if (c == ',') { + advance(); // Skip comma + consumeWhitespace(); + c = peek(); } - endArray(); - return list; - case 't': - case 'f': - return nextBoolean(); - case 'n': - readLiteral(4); // Skip "null" - return null; - case '-': - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - String number = readNumber(); - // Try parsing as integer first, then long, then fall back to double - if (!number.contains(".") && !number.contains("e") && !number.contains("E")) { - try { - return Integer.parseInt(number); - } catch (NumberFormatException e) { - try { - return Long.parseLong(number); - } catch (NumberFormatException e2) { - // Fall through to double + return c; + } + + private void consumeWhitespace() throws IOException { + while (true) { + char c = peek(); + if (c == ' ' || c == '\n' || c == '\r' || c == '\t') { + advance(); + if (c == '\n') { + this.lineNumber++; + this.linePosition = 0; + } + } else { + break; } - } } - return Double.parseDouble(number); - default: - throw syntaxError("Unexpected character: " + c); - } - } - - private char advanceUpToNextValueChar() throws IOException { - consumeWhitespace(); - char c = peek(); - // Handle comma between values - if (c == ',') { - advance(); // Skip comma - consumeWhitespace(); - c = peek(); } - return c; - } - private void consumeWhitespace() throws IOException { - while (true) { - char c = peek(); - if (c == ' ' || c == '\n' || c == '\r' || c == '\t') { - advance(); - if (c == '\n') { - this.lineNumber++; - this.linePosition = 0; + private char peek() throws IOException { + if (this.position >= this.limit) { + fillBuffer(); } - } else { - break; - } + return this.position < this.limit ? this.buffer[this.position] : '\0'; } - } - private char peek() throws IOException { - if (this.position >= this.limit) { - fillBuffer(); + private void advance() throws IOException { + if (this.position >= this.limit) { + fillBuffer(); + } + if (this.position < this.limit) { + this.position++; + this.linePosition++; + } } - return this.position < this.limit ? this.buffer[this.position] : '\0'; - } - private void advance() throws IOException { - if (this.position >= this.limit) { - fillBuffer(); + private void fillBuffer() throws IOException { + this.limit = this.reader.read(this.buffer, 0, this.buffer.length); + this.position = 0; + if (this.limit == -1) { + this.limit = 0; + } } - if (this.position < this.limit) { - this.position++; - this.linePosition++; + + private String readString() throws IOException { + StringBuilder sb = new StringBuilder(); + advance(); // Skip opening quote + while (true) { + char c = peek(); + if (c == '"') { + advance(); + break; + } + if (c == '\\') { + advance(); + c = peek(); + switch (c) { + case '"': + case '\\': + case '/': + sb.append(c); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + sb.append(readUnicodeEscape()); + continue; + default: + throw syntaxError("Invalid escape sequence: \\" + c); + } + } else if (c < ' ') { + throw syntaxError("Unterminated string"); + } else { + sb.append(c); + } + advance(); + } + return sb.toString(); } - } - private void fillBuffer() throws IOException { - this.limit = this.reader.read(this.buffer, 0, this.buffer.length); - this.position = 0; - if (this.limit == -1) { - this.limit = 0; + private char readUnicodeEscape() throws IOException { + advance(); // Skip 'u' + StringBuilder hex = new StringBuilder(4); + for (int i = 0; i < 4; i++) { + hex.append(peek()); + advance(); + } + try { + return (char) Integer.parseInt(hex.toString(), 16); + } catch (NumberFormatException e) { + throw syntaxError("Invalid unicode escape sequence: \\u" + hex); + } } - } - - private String readString() throws IOException { - StringBuilder sb = new StringBuilder(); - advance(); // Skip opening quote - while (true) { - char c = peek(); - if (c == '"') { - advance(); - break; - } - if (c == '\\') { - advance(); + + private String readNumber() throws IOException { + StringBuilder sb = new StringBuilder(); + char c = peek(); + // Optional minus sign + if (c == '-') { + sb.append(c); + advance(); + } + // Integer part + readDigits(sb); + // Fractional part + if (peek() == '.') { + sb.append('.'); + advance(); + readDigits(sb); + } + // Exponent part c = peek(); - switch (c) { - case '"': - case '\\': - case '/': + if (c == 'e' || c == 'E') { sb.append(c); - break; - case 'b': - sb.append('\b'); - break; - case 'f': - sb.append('\f'); - break; - case 'n': - sb.append('\n'); - break; - case 'r': - sb.append('\r'); - break; - case 't': - sb.append('\t'); - break; - case 'u': - sb.append(readUnicodeEscape()); - continue; - default: - throw syntaxError("Invalid escape sequence: \\" + c); + advance(); + c = peek(); + if (c == '+' || c == '-') { + sb.append(c); + advance(); + } + readDigits(sb); } - } else if (c < ' ') { - throw syntaxError("Unterminated string"); - } else { - sb.append(c); - } - advance(); - } - return sb.toString(); - } - - private char readUnicodeEscape() throws IOException { - advance(); // Skip 'u' - StringBuilder hex = new StringBuilder(4); - for (int i = 0; i < 4; i++) { - hex.append(peek()); - advance(); - } - try { - return (char) Integer.parseInt(hex.toString(), 16); - } catch (NumberFormatException e) { - throw syntaxError("Invalid unicode escape sequence: \\u" + hex); + return sb.toString(); } - } - - private String readNumber() throws IOException { - StringBuilder sb = new StringBuilder(); - char c = peek(); - // Optional minus sign - if (c == '-') { - sb.append(c); - advance(); + + private void readDigits(StringBuilder sb) throws IOException { + char c = peek(); + if (c < '0' || c > '9') { + throw unexpectedSyntaxError("digit", c); + } + while (true) { + c = peek(); + if (c < '0' || c > '9') { + break; + } + sb.append(c); + advance(); + } } - // Integer part - readDigits(sb); - // Fractional part - if (peek() == '.') { - sb.append('.'); - advance(); - readDigits(sb); + + private String readLiteral(int length) throws IOException { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(peek()); + advance(); + } + return sb.toString(); } - // Exponent part - c = peek(); - if (c == 'e' || c == 'E') { - sb.append(c); - advance(); - c = peek(); - if (c == '+' || c == '-') { - sb.append(c); - advance(); - } - readDigits(sb); + + private IOException unexpectedSyntaxError(String expected, String found) { + return new IOException(String.format( + "Syntax error at line %d, position %d: expected %s but found '%s'", + this.lineNumber, this.linePosition, expected, found)); } - return sb.toString(); - } - private void readDigits(StringBuilder sb) throws IOException { - char c = peek(); - if (c < '0' || c > '9') { - throw unexpectedSyntaxError("digit", c); + private IOException unexpectedSyntaxError(String expected, char found) { + return new IOException(String.format( + "Syntax error at line %d, position %d: expected %s but found '%s'", + this.lineNumber, this.linePosition, expected, found)); } - while (true) { - c = peek(); - if (c < '0' || c > '9') { - break; - } - sb.append(c); - advance(); + + private IOException syntaxError(String message) { + return new IOException( + String.format("Syntax error at line %d, position %d: %s", this.lineNumber, this.linePosition, message)); } - } - private String readLiteral(int length) throws IOException { - StringBuilder sb = new StringBuilder(length); - for (int i = 0; i < length; i++) { - sb.append(peek()); - advance(); + @Override + public void close() throws IOException { + this.reader.close(); } - return sb.toString(); - } - - private IOException unexpectedSyntaxError(String expected, String found) { - return new IOException( - String.format( - "Syntax error at line %d, position %d: expected %s but found '%s'", - this.lineNumber, this.linePosition, expected, found)); - } - - private IOException unexpectedSyntaxError(String expected, char found) { - return new IOException( - String.format( - "Syntax error at line %d, position %d: expected %s but found '%s'", - this.lineNumber, this.linePosition, expected, found)); - } - - private IOException syntaxError(String message) { - return new IOException( - String.format( - "Syntax error at line %d, position %d: %s", - this.lineNumber, this.linePosition, message)); - } - - @Override - public void close() throws IOException { - this.reader.close(); - } } diff --git a/components/json/src/main/java/datadog/json/JsonStructure.java b/components/json/src/main/java/datadog/json/JsonStructure.java index 72efe81ec31..c06dbf69809 100644 --- a/components/json/src/main/java/datadog/json/JsonStructure.java +++ b/components/json/src/main/java/datadog/json/JsonStructure.java @@ -2,55 +2,55 @@ /** The {@link JsonStructure} keeps track of JSON value being built. */ interface JsonStructure { - /** - * Begins an object. - * - * @throws IllegalStateException if the object can not be started at this position. - */ - void beginObject(); - - /** - * Checks whether the current position is within an object. - * - * @return {@code true} if the current position is within an object, {@code false} otherwise. - */ - boolean objectStarted(); - - /** - * Ends the current object. - * - * @throws IllegalStateException if the current position is not within an object. - */ - void endObject(); - - /** Begins an array. */ - void beginArray(); - - /** - * Checks whether the current position is within an array. - * - * @return {@code true} if the current position is within an array, {@code false} otherwise. - */ - boolean arrayStarted(); - - /** - * Ends the current array. - * - * @throws IllegalStateException if the current position is not within an array. - */ - void endArray(); - - /** - * Adds a name to the current object. - * - * @throws IllegalStateException if the current position is not within an object. - */ - void addName(); - - /** - * Adds a value. - * - * @throws IllegalStateException if the current position can not have a value. - */ - void addValue(); + /** + * Begins an object. + * + * @throws IllegalStateException if the object can not be started at this position. + */ + void beginObject(); + + /** + * Checks whether the current position is within an object. + * + * @return {@code true} if the current position is within an object, {@code false} otherwise. + */ + boolean objectStarted(); + + /** + * Ends the current object. + * + * @throws IllegalStateException if the current position is not within an object. + */ + void endObject(); + + /** Begins an array. */ + void beginArray(); + + /** + * Checks whether the current position is within an array. + * + * @return {@code true} if the current position is within an array, {@code false} otherwise. + */ + boolean arrayStarted(); + + /** + * Ends the current array. + * + * @throws IllegalStateException if the current position is not within an array. + */ + void endArray(); + + /** + * Adds a name to the current object. + * + * @throws IllegalStateException if the current position is not within an object. + */ + void addName(); + + /** + * Adds a value. + * + * @throws IllegalStateException if the current position can not have a value. + */ + void addValue(); } diff --git a/components/json/src/main/java/datadog/json/JsonWriter.java b/components/json/src/main/java/datadog/json/JsonWriter.java index ec476d574c1..19b6cee8da9 100644 --- a/components/json/src/main/java/datadog/json/JsonWriter.java +++ b/components/json/src/main/java/datadog/json/JsonWriter.java @@ -12,344 +12,344 @@ * using the lenient mode. */ public final class JsonWriter implements Flushable, AutoCloseable { - private static final int INITIAL_CAPACITY = 256; - private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray(); - private final ByteArrayOutputStream outputStream; - private final OutputStreamWriter writer; - private final JsonStructure structure; + private static final int INITIAL_CAPACITY = 256; + private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray(); + private final ByteArrayOutputStream outputStream; + private final OutputStreamWriter writer; + private final JsonStructure structure; - private boolean requireComma; + private boolean requireComma; - /** Creates a writer with structure check. */ - public JsonWriter() { - this(true); - } + /** Creates a writer with structure check. */ + public JsonWriter() { + this(true); + } - /** - * Creates a writer. - * - * @param safe {@code true} to use safe structure check, {@code false} for lenient mode. - */ - public JsonWriter(boolean safe) { - this.outputStream = new ByteArrayOutputStream(INITIAL_CAPACITY); - this.writer = new OutputStreamWriter(this.outputStream, UTF_8); - this.structure = safe ? new SafeJsonStructure() : new LenientJsonStructure(); - this.requireComma = false; - } + /** + * Creates a writer. + * + * @param safe {@code true} to use safe structure check, {@code false} for lenient mode. + */ + public JsonWriter(boolean safe) { + this.outputStream = new ByteArrayOutputStream(INITIAL_CAPACITY); + this.writer = new OutputStreamWriter(this.outputStream, UTF_8); + this.structure = safe ? new SafeJsonStructure() : new LenientJsonStructure(); + this.requireComma = false; + } - /** - * Starts a JSON object. - * - * @return This writer instance. - */ - public JsonWriter beginObject() { - this.structure.beginObject(); - injectCommaIfNeeded(); - write('{'); - return this; - } + /** + * Starts a JSON object. + * + * @return This writer instance. + */ + public JsonWriter beginObject() { + this.structure.beginObject(); + injectCommaIfNeeded(); + write('{'); + return this; + } - /** - * * Ends the current JSON object. - * - * @return This writer. - */ - public JsonWriter endObject() { - this.structure.endObject(); - write('}'); - endsValue(); - return this; - } + /** + * * Ends the current JSON object. + * + * @return This writer. + */ + public JsonWriter endObject() { + this.structure.endObject(); + write('}'); + endsValue(); + return this; + } - /** - * Writes an object property name. - * - * @param name The property name. - * @return This writer. - */ - public JsonWriter name(String name) { - if (name == null) { - throw new IllegalArgumentException("name cannot be null"); + /** + * Writes an object property name. + * + * @param name The property name. + * @return This writer. + */ + public JsonWriter name(String name) { + if (name == null) { + throw new IllegalArgumentException("name cannot be null"); + } + this.structure.addName(); + injectCommaIfNeeded(); + writeStringLiteral(name); + write(':'); + return this; } - this.structure.addName(); - injectCommaIfNeeded(); - writeStringLiteral(name); - write(':'); - return this; - } - /** - * Writes a {@code null} value. - * - * @return This writer. - */ - public JsonWriter nullValue() { - this.structure.addValue(); - injectCommaIfNeeded(); - writeStringRaw("null"); - endsValue(); - return this; - } + /** + * Writes a {@code null} value. + * + * @return This writer. + */ + public JsonWriter nullValue() { + this.structure.addValue(); + injectCommaIfNeeded(); + writeStringRaw("null"); + endsValue(); + return this; + } - /** - * Writes JSON value without escaping it. - * - * @param value The JSON value to write. - * @return This writer. - */ - public JsonWriter jsonValue(String value) { - // No structure check here assuming raw JSON is safe to write - injectCommaIfNeeded(); - writeStringRaw(value); - endsValue(); - return this; - } + /** + * Writes JSON value without escaping it. + * + * @param value The JSON value to write. + * @return This writer. + */ + public JsonWriter jsonValue(String value) { + // No structure check here assuming raw JSON is safe to write + injectCommaIfNeeded(); + writeStringRaw(value); + endsValue(); + return this; + } - /** - * Writes a boolean value. - * - * @param value The value to write. - * @return This writer. - */ - public JsonWriter value(boolean value) { - this.structure.addValue(); - injectCommaIfNeeded(); - writeStringRaw(value ? "true" : "false"); - endsValue(); - return this; - } + /** + * Writes a boolean value. + * + * @param value The value to write. + * @return This writer. + */ + public JsonWriter value(boolean value) { + this.structure.addValue(); + injectCommaIfNeeded(); + writeStringRaw(value ? "true" : "false"); + endsValue(); + return this; + } - /** - * Writes a string value. - * - * @param value The value to write. - * @return This writer. - */ - public JsonWriter value(String value) { - if (value == null) { - return nullValue(); + /** + * Writes a string value. + * + * @param value The value to write. + * @return This writer. + */ + public JsonWriter value(String value) { + if (value == null) { + return nullValue(); + } + this.structure.addValue(); + injectCommaIfNeeded(); + writeStringLiteral(value); + endsValue(); + return this; } - this.structure.addValue(); - injectCommaIfNeeded(); - writeStringLiteral(value); - endsValue(); - return this; - } - /** - * Writes an integer as a number value. - * - * @param value The integer to write. - * @return This writer. - */ - public JsonWriter value(int value) { - this.structure.addValue(); - injectCommaIfNeeded(); - writeStringRaw(Integer.toString(value)); - endsValue(); - return this; - } + /** + * Writes an integer as a number value. + * + * @param value The integer to write. + * @return This writer. + */ + public JsonWriter value(int value) { + this.structure.addValue(); + injectCommaIfNeeded(); + writeStringRaw(Integer.toString(value)); + endsValue(); + return this; + } - /** - * Writes a long as a number value. - * - * @param value The long to write. - * @return This writer. - */ - public JsonWriter value(long value) { - this.structure.addValue(); - injectCommaIfNeeded(); - writeStringRaw(Long.toString(value)); - endsValue(); - return this; - } + /** + * Writes a long as a number value. + * + * @param value The long to write. + * @return This writer. + */ + public JsonWriter value(long value) { + this.structure.addValue(); + injectCommaIfNeeded(); + writeStringRaw(Long.toString(value)); + endsValue(); + return this; + } - /** - * Writes a float as a number value. - * - * @param value The float to write. - * @return This writer. - */ - public JsonWriter value(float value) { - if (Float.isNaN(value)) { - return nullValue(); + /** + * Writes a float as a number value. + * + * @param value The float to write. + * @return This writer. + */ + public JsonWriter value(float value) { + if (Float.isNaN(value)) { + return nullValue(); + } + this.structure.addValue(); + injectCommaIfNeeded(); + writeStringRaw(Float.toString(value)); + endsValue(); + return this; } - this.structure.addValue(); - injectCommaIfNeeded(); - writeStringRaw(Float.toString(value)); - endsValue(); - return this; - } - /** - * Writes a double as a number value. - * - * @param value The value to write. - * @return This writer. - */ - public JsonWriter value(double value) { - if (Double.isNaN(value)) { - return nullValue(); + /** + * Writes a double as a number value. + * + * @param value The value to write. + * @return This writer. + */ + public JsonWriter value(double value) { + if (Double.isNaN(value)) { + return nullValue(); + } + this.structure.addValue(); + injectCommaIfNeeded(); + writeStringRaw(Double.toString(value)); + endsValue(); + return this; } - this.structure.addValue(); - injectCommaIfNeeded(); - writeStringRaw(Double.toString(value)); - endsValue(); - return this; - } - /** - * Starts a JSON array. - * - * @return This writer. - */ - public JsonWriter beginArray() { - this.structure.beginArray(); - injectCommaIfNeeded(); - write('['); - return this; - } + /** + * Starts a JSON array. + * + * @return This writer. + */ + public JsonWriter beginArray() { + this.structure.beginArray(); + injectCommaIfNeeded(); + write('['); + return this; + } - /** - * Ends the current JSON array. - * - * @return This writer. - */ - public JsonWriter endArray() { - this.structure.endArray(); - endsValue(); - write(']'); - return this; - } + /** + * Ends the current JSON array. + * + * @return This writer. + */ + public JsonWriter endArray() { + this.structure.endArray(); + endsValue(); + write(']'); + return this; + } - /** - * Gets the JSON String as a UTF-8 byte array. - * - * @return The JSON String as a UTF-8 byte array. - */ - public byte[] toByteArray() { - flush(); - return this.outputStream.toByteArray(); - } + /** + * Gets the JSON String as a UTF-8 byte array. + * + * @return The JSON String as a UTF-8 byte array. + */ + public byte[] toByteArray() { + flush(); + return this.outputStream.toByteArray(); + } - @Override - public String toString() { - return new String(toByteArray(), UTF_8); - } + @Override + public String toString() { + return new String(toByteArray(), UTF_8); + } - @Override - public void flush() { - try { - this.writer.flush(); - } catch (IOException ignored) { + @Override + public void flush() { + try { + this.writer.flush(); + } catch (IOException ignored) { + } } - } - /** - * Returns the current JSON size in bytes. - * - * @return The JSON size in bytes. - * @see #toByteArray() - */ - public int size() { - flush(); - return this.outputStream.size(); - } + /** + * Returns the current JSON size in bytes. + * + * @return The JSON size in bytes. + * @see #toByteArray() + */ + public int size() { + flush(); + return this.outputStream.size(); + } - @Override - public void close() { - try { - this.outputStream.close(); - this.writer.close(); - } catch (IOException ignored) { + @Override + public void close() { + try { + this.outputStream.close(); + this.writer.close(); + } catch (IOException ignored) { + } } - } - private void injectCommaIfNeeded() { - if (this.requireComma) { - write(','); + private void injectCommaIfNeeded() { + if (this.requireComma) { + write(','); + } + this.requireComma = false; } - this.requireComma = false; - } - private void endsValue() { - this.requireComma = true; - } + private void endsValue() { + this.requireComma = true; + } - private void write(char ch) { - try { - this.writer.write(ch); - } catch (IOException ignored) { + private void write(char ch) { + try { + this.writer.write(ch); + } catch (IOException ignored) { + } } - } - private void writeStringLiteral(String str) { - try { - this.writer.write('"'); + private void writeStringLiteral(String str) { + try { + this.writer.write('"'); - for (int i = 0; i < str.length(); ++i) { - char c = str.charAt(i); - // Escape any char outside ASCII to their Unicode equivalent - if (c > 127) { - this.writer.write('\\'); - this.writer.write('u'); - this.writer.write(HEX_DIGITS[(c >>> 12) & 0xF]); - this.writer.write(HEX_DIGITS[(c >>> 8) & 0xF]); - this.writer.write(HEX_DIGITS[(c >>> 4) & 0xF]); - this.writer.write(HEX_DIGITS[c & 0xF]); - } else { - switch (c) { - case '"': // Quotation mark - case '\\': // Reverse solidus - case '/': // Solidus - this.writer.write('\\'); - this.writer.write(c); - break; - case '\b': // Backspace - this.writer.write('\\'); - this.writer.write('b'); - break; - case '\f': // Form feed - this.writer.write('\\'); - this.writer.write('f'); - break; - case '\n': // Line feed - this.writer.write('\\'); - this.writer.write('n'); - break; - case '\r': // Carriage return - this.writer.write('\\'); - this.writer.write('r'); - break; - case '\t': // Horizontal tab - this.writer.write('\\'); - this.writer.write('t'); - break; - default: - if (c < 0x20) { - this.writer.write('\\'); - this.writer.write('u'); - this.writer.write('0'); - this.writer.write('0'); - this.writer.write(HEX_DIGITS[(c >>> 4) & 0xF]); - this.writer.write(HEX_DIGITS[c & 0xF]); - } else { - this.writer.write(c); - } - break; - } - } - } + for (int i = 0; i < str.length(); ++i) { + char c = str.charAt(i); + // Escape any char outside ASCII to their Unicode equivalent + if (c > 127) { + this.writer.write('\\'); + this.writer.write('u'); + this.writer.write(HEX_DIGITS[(c >>> 12) & 0xF]); + this.writer.write(HEX_DIGITS[(c >>> 8) & 0xF]); + this.writer.write(HEX_DIGITS[(c >>> 4) & 0xF]); + this.writer.write(HEX_DIGITS[c & 0xF]); + } else { + switch (c) { + case '"': // Quotation mark + case '\\': // Reverse solidus + case '/': // Solidus + this.writer.write('\\'); + this.writer.write(c); + break; + case '\b': // Backspace + this.writer.write('\\'); + this.writer.write('b'); + break; + case '\f': // Form feed + this.writer.write('\\'); + this.writer.write('f'); + break; + case '\n': // Line feed + this.writer.write('\\'); + this.writer.write('n'); + break; + case '\r': // Carriage return + this.writer.write('\\'); + this.writer.write('r'); + break; + case '\t': // Horizontal tab + this.writer.write('\\'); + this.writer.write('t'); + break; + default: + if (c < 0x20) { + this.writer.write('\\'); + this.writer.write('u'); + this.writer.write('0'); + this.writer.write('0'); + this.writer.write(HEX_DIGITS[(c >>> 4) & 0xF]); + this.writer.write(HEX_DIGITS[c & 0xF]); + } else { + this.writer.write(c); + } + break; + } + } + } - this.writer.write('"'); - } catch (IOException ignored) { + this.writer.write('"'); + } catch (IOException ignored) { + } } - } - private void writeStringRaw(String str) { - try { - this.writer.write(str); - } catch (IOException ignored) { + private void writeStringRaw(String str) { + try { + this.writer.write(str); + } catch (IOException ignored) { + } } - } } diff --git a/components/json/src/main/java/datadog/json/LenientJsonStructure.java b/components/json/src/main/java/datadog/json/LenientJsonStructure.java index ee1cfd7331e..aa12f9a673c 100644 --- a/components/json/src/main/java/datadog/json/LenientJsonStructure.java +++ b/components/json/src/main/java/datadog/json/LenientJsonStructure.java @@ -2,33 +2,33 @@ /** A permissive {@link JsonStructure} that performs no structural checks on the built JSON. */ class LenientJsonStructure implements JsonStructure { - LenientJsonStructure() {} + LenientJsonStructure() {} - @Override - public void beginObject() {} + @Override + public void beginObject() {} - @Override - public boolean objectStarted() { - return true; - } + @Override + public boolean objectStarted() { + return true; + } - @Override - public void endObject() {} + @Override + public void endObject() {} - @Override - public void beginArray() {} + @Override + public void beginArray() {} - @Override - public boolean arrayStarted() { - return true; - } + @Override + public boolean arrayStarted() { + return true; + } - @Override - public void endArray() {} + @Override + public void endArray() {} - @Override - public void addName() {} + @Override + public void addName() {} - @Override - public void addValue() {} + @Override + public void addValue() {} } diff --git a/components/json/src/main/java/datadog/json/SafeJsonStructure.java b/components/json/src/main/java/datadog/json/SafeJsonStructure.java index d6ac0adabf5..ffb733026c9 100644 --- a/components/json/src/main/java/datadog/json/SafeJsonStructure.java +++ b/components/json/src/main/java/datadog/json/SafeJsonStructure.java @@ -7,83 +7,83 @@ * coherent. */ class SafeJsonStructure implements JsonStructure { - private final BitSet structure; - private int depth; - private boolean complete; + private final BitSet structure; + private int depth; + private boolean complete; - SafeJsonStructure() { - this.structure = new BitSet(); - this.depth = -1; - this.complete = false; - } - - @Override - public void beginObject() { - if (this.complete) { - throw new IllegalStateException("Object is complete"); + SafeJsonStructure() { + this.structure = new BitSet(); + this.depth = -1; + this.complete = false; } - this.structure.set(++this.depth); - } - - @Override - public boolean objectStarted() { - return this.depth >= 0 && this.structure.get(this.depth); - } - @Override - public void endObject() { - if (!objectStarted()) { - throw new IllegalStateException("Object not started"); + @Override + public void beginObject() { + if (this.complete) { + throw new IllegalStateException("Object is complete"); + } + this.structure.set(++this.depth); } - this.depth--; - if (this.depth < 0) { - this.complete = true; - } - } - @Override - public void beginArray() { - if (this.complete) { - throw new IllegalStateException("Object is complete"); + @Override + public boolean objectStarted() { + return this.depth >= 0 && this.structure.get(this.depth); } - this.structure.clear(++this.depth); - } - @Override - public boolean arrayStarted() { - return this.depth >= 0 && !this.structure.get(this.depth); - } + @Override + public void endObject() { + if (!objectStarted()) { + throw new IllegalStateException("Object not started"); + } + this.depth--; + if (this.depth < 0) { + this.complete = true; + } + } - @Override - public void endArray() { - if (!arrayStarted()) { - throw new IllegalStateException("Array not started"); + @Override + public void beginArray() { + if (this.complete) { + throw new IllegalStateException("Object is complete"); + } + this.structure.clear(++this.depth); } - this.depth--; - if (this.depth < 0) { - this.complete = true; + + @Override + public boolean arrayStarted() { + return this.depth >= 0 && !this.structure.get(this.depth); } - } - @Override - public void addName() { - if (!objectStarted()) { - throw new IllegalStateException("Object not started"); + @Override + public void endArray() { + if (!arrayStarted()) { + throw new IllegalStateException("Array not started"); + } + this.depth--; + if (this.depth < 0) { + this.complete = true; + } } - } - @Override - public void addValue() { - if (this.complete) { - throw new IllegalStateException("Object is complete"); + @Override + public void addName() { + if (!objectStarted()) { + throw new IllegalStateException("Object not started"); + } } - if (this.depth < 0) { - this.complete = true; + + @Override + public void addValue() { + if (this.complete) { + throw new IllegalStateException("Object is complete"); + } + if (this.depth < 0) { + this.complete = true; + } } - } - @Override - public String toString() { - return (this.complete ? "complete" : "") + this.structure; - } + @Override + public String toString() { + return (this.complete ? "complete" : "") + this.structure; + } } diff --git a/components/json/src/test/java/datadog/json/JsonMapperTest.java b/components/json/src/test/java/datadog/json/JsonMapperTest.java index 46110942275..b7da3046aa1 100644 --- a/components/json/src/test/java/datadog/json/JsonMapperTest.java +++ b/components/json/src/test/java/datadog/json/JsonMapperTest.java @@ -23,202 +23,197 @@ import org.tabletest.junit.TableTest; class JsonMapperTest { - @TableTest({ - "Scenario | Input | Expected ", - "null input | | '{}' ", - "empty map | [:] | '{}' ", - "single entry | [key1: value1] | '{\"key1\":\"value1\"}' ", - "two entries | [key1: value1, key2: value2] | '{\"key1\":\"value1\",\"key2\":\"value2\"}' ", - "quoted entries | [key1: va\"lu\"e1, ke\"y2: value2] | '{\"key1\":\"va\\\"lu\\\"e1\",\"ke\\\"y2\":\"value2\"}'" - }) - @ParameterizedTest(name = "test mapping to JSON object: {0}") - @MethodSource("testMappingToJsonObjectArguments") - void testMappingToJsonObject( - @Scenario String ignoredScenario, Map input, String expected) - throws IOException { - String json = JsonMapper.toJson(input); - assertEquals(expected, json); - - Map parsed = JsonMapper.fromJsonToMap(json); - if (input == null) { - assertEquals(emptyMap(), parsed); - } else { - assertEquals(input.size(), parsed.size()); - for (Map.Entry entry : input.entrySet()) { - String expectedKey = entry.getKey(); - Object expectedValue = entry.getValue(); - assertTrue(parsed.containsKey(expectedKey)); - Object parsedValue = parsed.get(expectedKey); - if (expectedValue instanceof UnsupportedType) { - assertEquals(expectedValue.toString(), parsedValue); - } else if (expectedValue instanceof Float) { - assertInstanceOf(Double.class, parsedValue); - assertEquals((Float) expectedValue, (Double) parsedValue, 0.001); + @TableTest({ + "Scenario | Input | Expected ", + "null input | | '{}' ", + "empty map | [:] | '{}' ", + "single entry | [key1: value1] | '{\"key1\":\"value1\"}' ", + "two entries | [key1: value1, key2: value2] | '{\"key1\":\"value1\",\"key2\":\"value2\"}' ", + "quoted entries | [key1: va\"lu\"e1, ke\"y2: value2] | '{\"key1\":\"va\\\"lu\\\"e1\",\"ke\\\"y2\":\"value2\"}'" + }) + @ParameterizedTest(name = "test mapping to JSON object: {0}") + @MethodSource("testMappingToJsonObjectArguments") + void testMappingToJsonObject(@Scenario String ignoredScenario, Map input, String expected) + throws IOException { + String json = JsonMapper.toJson(input); + assertEquals(expected, json); + + Map parsed = JsonMapper.fromJsonToMap(json); + if (input == null) { + assertEquals(emptyMap(), parsed); } else { - assertEquals(expectedValue, parsedValue); + assertEquals(input.size(), parsed.size()); + for (Map.Entry entry : input.entrySet()) { + String expectedKey = entry.getKey(); + Object expectedValue = entry.getValue(); + assertTrue(parsed.containsKey(expectedKey)); + Object parsedValue = parsed.get(expectedKey); + if (expectedValue instanceof UnsupportedType) { + assertEquals(expectedValue.toString(), parsedValue); + } else if (expectedValue instanceof Float) { + assertInstanceOf(Double.class, parsedValue); + assertEquals((Float) expectedValue, (Double) parsedValue, 0.001); + } else { + assertEquals(expectedValue, parsedValue); + } + } } - } } - } - - static Stream testMappingToJsonObjectArguments() { - Map complexMap = new LinkedHashMap<>(); - complexMap.put("key1", null); - complexMap.put("key2", "bar"); - complexMap.put("key3", 3); - complexMap.put("key4", 3456789123L); - complexMap.put("key5", 3.142f); - complexMap.put("key6", Math.PI); - complexMap.put("key7", true); - complexMap.put("key8", new UnsupportedType()); - - return Stream.of( - arguments( - "complex map", - complexMap, - "{\"key1\":null,\"key2\":\"bar\",\"key3\":3,\"key4\":3456789123,\"key5\":3.142,\"key6\":3.141592653589793,\"key7\":true,\"key8\":\"toString\"}")); - } - - @TableTest({ - "Scenario | Map | Expected ", - "null | | '{}' ", - "empty | [:] | '{}' ", - "default | [key1: value1, key2: value2] | '{\"key1\":\"value1\",\"key2\":\"value2\"}'" - }) - void testWritingAsJsonValue( - @Scenario String ignoredScenario, Map map, String expected) { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - writer.name("map"); - JsonMapper.writeAsJsonValue(writer, (Map) map); - writer.endObject(); - assertEquals("{\"map\":" + expected + "}", writer.toString()); + + static Stream testMappingToJsonObjectArguments() { + Map complexMap = new LinkedHashMap<>(); + complexMap.put("key1", null); + complexMap.put("key2", "bar"); + complexMap.put("key3", 3); + complexMap.put("key4", 3456789123L); + complexMap.put("key5", 3.142f); + complexMap.put("key6", Math.PI); + complexMap.put("key7", true); + complexMap.put("key8", new UnsupportedType()); + + return Stream.of( + arguments( + "complex map", + complexMap, + "{\"key1\":null,\"key2\":\"bar\",\"key3\":3,\"key4\":3456789123,\"key5\":3.142,\"key6\":3.141592653589793,\"key7\":true,\"key8\":\"toString\"}")); + } + + @TableTest({ + "Scenario | Map | Expected ", + "null | | '{}' ", + "empty | [:] | '{}' ", + "default | [key1: value1, key2: value2] | '{\"key1\":\"value1\",\"key2\":\"value2\"}'" + }) + void testWritingAsJsonValue(@Scenario String ignoredScenario, Map map, String expected) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.name("map"); + JsonMapper.writeAsJsonValue(writer, (Map) map); + writer.endObject(); + assertEquals("{\"map\":" + expected + "}", writer.toString()); + } + } + + @ParameterizedTest(name = "test mapping to Map from empty JSON object: {0}") + @NullSource + @ValueSource(strings = {"null", "", "{}"}) + void testMappingToMapFromEmptyJsonObject(String json) throws IOException { + Map parsed = JsonMapper.fromJsonToMap(json); + assertEquals(emptyMap(), parsed); + } + + @ParameterizedTest(name = "test mapping to Map from non-object JSON: {0}") + @ValueSource(strings = {"1", "[1, 2]"}) + void testMappingToMapFromNonObjectJson(String json) { + assertThrows(IOException.class, () -> JsonMapper.fromJsonToMap(json)); + } + + @TableTest({ + "Scenario | Input | Expected ", + "null input | | '[]' ", + "empty list | [] | '[]' ", + "single value | [value1] | '[\"value1\"]' ", + "two values | [value1, value2] | '[\"value1\",\"value2\"]' ", + "quoted values | [va\"lu\"e1, value2] | '[\"va\\\"lu\\\"e1\",\"value2\"]'" + }) + @ParameterizedTest(name = "test mapping iterable to JSON array: {0}") + void testMappingIterableToJsonArray(List input, String expected) throws IOException { + String json = JsonMapper.toJson(input); + assertEquals(expected, json); + + List parsed = JsonMapper.fromJsonToList(json); + assertEquals(input != null ? input : emptyList(), parsed); + } + + @TableTest({ + "Scenario | Collection | Expected ", + "null | | '[]' ", + "empty | [] | '[]' ", + "default | [value1, value2] | '[\"value1\",\"value2\"]'" + }) + void testWritingCollectionAsJsonValue(@Scenario String ignoredScenario, List collection, String expected) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.name("collection"); + JsonMapper.writeAsJsonValue(collection, writer); + writer.endObject(); + assertEquals("{\"collection\":" + expected + "}", writer.toString()); + } + } + + @TableTest({ + "Scenario | Input | Expected ", + "null input | | '[]' ", + "empty array | [] | '[]' ", + "single element | [value1] | '[\"value1\"]' ", + "two elements | [value1, value2] | '[\"value1\",\"value2\"]' ", + "escaped quotes | [va\"lu\"e1, value2] | '[\"va\\\"lu\\\"e1\",\"value2\"]'" + }) + @ParameterizedTest(name = "test mapping array to JSON array: {0}") + void testMappingArrayToJsonArray(String ignoredScenario, String[] input, String expected) throws IOException { + String json = JsonMapper.toJson(input); + assertEquals(expected, json); + + String[] parsed = JsonMapper.fromJsonToList(json).toArray(new String[] {}); + assertArrayEquals(input != null ? input : new String[] {}, parsed); + } + + @TableTest({ + "Scenario | Array | Expected ", + "null | | '[]' ", + "empty | [] | '[]' ", + "default | [value1, value2] | '[\"value1\",\"value2\"]'" + }) + void testWritingArrayAsJsonValue(@Scenario String ignoredScenario, String[] array, String expected) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.name("array"); + JsonMapper.writeAsJsonValue(array, writer); + writer.endObject(); + assertEquals("{\"array\":" + expected + "}", writer.toString()); + } } - } - - @ParameterizedTest(name = "test mapping to Map from empty JSON object: {0}") - @NullSource - @ValueSource(strings = {"null", "", "{}"}) - void testMappingToMapFromEmptyJsonObject(String json) throws IOException { - Map parsed = JsonMapper.fromJsonToMap(json); - assertEquals(emptyMap(), parsed); - } - - @ParameterizedTest(name = "test mapping to Map from non-object JSON: {0}") - @ValueSource(strings = {"1", "[1, 2]"}) - void testMappingToMapFromNonObjectJson(String json) { - assertThrows(IOException.class, () -> JsonMapper.fromJsonToMap(json)); - } - - @TableTest({ - "Scenario | Input | Expected ", - "null input | | '[]' ", - "empty list | [] | '[]' ", - "single value | [value1] | '[\"value1\"]' ", - "two values | [value1, value2] | '[\"value1\",\"value2\"]' ", - "quoted values | [va\"lu\"e1, value2] | '[\"va\\\"lu\\\"e1\",\"value2\"]'" - }) - @ParameterizedTest(name = "test mapping iterable to JSON array: {0}") - void testMappingIterableToJsonArray(List input, String expected) throws IOException { - String json = JsonMapper.toJson(input); - assertEquals(expected, json); - - List parsed = JsonMapper.fromJsonToList(json); - assertEquals(input != null ? input : emptyList(), parsed); - } - - @TableTest({ - "Scenario | Collection | Expected ", - "null | | '[]' ", - "empty | [] | '[]' ", - "default | [value1, value2] | '[\"value1\",\"value2\"]'" - }) - void testWritingCollectionAsJsonValue( - @Scenario String ignoredScenario, List collection, String expected) { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - writer.name("collection"); - JsonMapper.writeAsJsonValue(collection, writer); - writer.endObject(); - assertEquals("{\"collection\":" + expected + "}", writer.toString()); + + @ParameterizedTest(name = "test mapping to List from empty JSON object: {0}") + @NullSource + @ValueSource(strings = {"null", "", "[]"}) + void testMappingToListFromEmptyJsonObject(String json) throws IOException { + List parsed = JsonMapper.fromJsonToList(json); + assertEquals(emptyList(), parsed); + } + + @TableTest({ + "Scenario | input | expected ", + "null value | | '' ", + "empty string | '' | '' ", + "\\b | '\b' | '\"\\b\"'", + "\\t | '\t' | '\"\\t\"'", + "\\f | '\f' | '\"\\f\"'", + "a | 'a' | '\"a\"' ", + "/ | '/' | '\"\\/\"'" + }) + @ParameterizedTest(name = "test mapping to JSON string: {0}") + @MethodSource("testMappingToJsonStringArguments") + void testMappingToJsonString(@Scenario String ignoredScenario, String input, String expected) { + String json = JsonMapper.toJson(input); + assertEquals(expected, json); } - } - - @TableTest({ - "Scenario | Input | Expected ", - "null input | | '[]' ", - "empty array | [] | '[]' ", - "single element | [value1] | '[\"value1\"]' ", - "two elements | [value1, value2] | '[\"value1\",\"value2\"]' ", - "escaped quotes | [va\"lu\"e1, value2] | '[\"va\\\"lu\\\"e1\",\"value2\"]'" - }) - @ParameterizedTest(name = "test mapping array to JSON array: {0}") - void testMappingArrayToJsonArray(String ignoredScenario, String[] input, String expected) - throws IOException { - String json = JsonMapper.toJson(input); - assertEquals(expected, json); - - String[] parsed = JsonMapper.fromJsonToList(json).toArray(new String[] {}); - assertArrayEquals(input != null ? input : new String[] {}, parsed); - } - - @TableTest({ - "Scenario | Array | Expected ", - "null | | '[]' ", - "empty | [] | '[]' ", - "default | [value1, value2] | '[\"value1\",\"value2\"]'" - }) - void testWritingArrayAsJsonValue( - @Scenario String ignoredScenario, String[] array, String expected) { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - writer.name("array"); - JsonMapper.writeAsJsonValue(array, writer); - writer.endObject(); - assertEquals("{\"array\":" + expected + "}", writer.toString()); + + static Stream testMappingToJsonStringArguments() { + return Stream.of( + arguments("char #4096", String.valueOf((char) 4096), "\"\\u1000\""), + arguments("char #256", String.valueOf((char) 256), "\"\\u0100\""), + arguments("char #128", String.valueOf((char) 128), "\"\\u0080\""), + arguments("\\n", "\n", "\"\\n\""), + arguments("\\r", "\r", "\"\\r\""), + arguments("\"", "\"", "\"\\\"\""), + arguments("\\", "\\", "\"\\\\\"")); } - } - - @ParameterizedTest(name = "test mapping to List from empty JSON object: {0}") - @NullSource - @ValueSource(strings = {"null", "", "[]"}) - void testMappingToListFromEmptyJsonObject(String json) throws IOException { - List parsed = JsonMapper.fromJsonToList(json); - assertEquals(emptyList(), parsed); - } - - @TableTest({ - "Scenario | input | expected ", - "null value | | '' ", - "empty string | '' | '' ", - "\\b | '\b' | '\"\\b\"'", - "\\t | '\t' | '\"\\t\"'", - "\\f | '\f' | '\"\\f\"'", - "a | 'a' | '\"a\"' ", - "/ | '/' | '\"\\/\"'" - }) - @ParameterizedTest(name = "test mapping to JSON string: {0}") - @MethodSource("testMappingToJsonStringArguments") - void testMappingToJsonString(@Scenario String ignoredScenario, String input, String expected) { - String json = JsonMapper.toJson(input); - assertEquals(expected, json); - } - - static Stream testMappingToJsonStringArguments() { - return Stream.of( - arguments("char #4096", String.valueOf((char) 4096), "\"\\u1000\""), - arguments("char #256", String.valueOf((char) 256), "\"\\u0100\""), - arguments("char #128", String.valueOf((char) 128), "\"\\u0080\""), - arguments("\\n", "\n", "\"\\n\""), - arguments("\\r", "\r", "\"\\r\""), - arguments("\"", "\"", "\"\\\"\""), - arguments("\\", "\\", "\"\\\\\"")); - } - - private static class UnsupportedType { - @Override - public String toString() { - return "toString"; + + private static class UnsupportedType { + @Override + public String toString() { + return "toString"; + } } - } } diff --git a/components/json/src/test/java/datadog/json/JsonReaderTest.java b/components/json/src/test/java/datadog/json/JsonReaderTest.java index e716a625103..cc7bf05676d 100644 --- a/components/json/src/test/java/datadog/json/JsonReaderTest.java +++ b/components/json/src/test/java/datadog/json/JsonReaderTest.java @@ -17,335 +17,300 @@ import org.junit.jupiter.params.provider.ValueSource; class JsonReaderTest { - @Test - void testReadObject() { - String json = - "{\"string\":\"bar\",\"int\":3,\"long\":3456789123,\"float\":3.142,\"double\":3.141592653589793,\"true\":true,\"false\":false,\"null\":null}"; - try (JsonReader reader = new JsonReader(json)) { - reader.beginObject(); - assertEquals("string", reader.nextName()); - assertEquals("bar", reader.nextString()); - assertEquals("int", reader.nextName()); - assertEquals(3, reader.nextInt()); - assertEquals("long", reader.nextName()); - assertEquals(3456789123L, reader.nextLong()); - assertEquals("float", reader.nextName()); - assertEquals(3.142, reader.nextDouble(), 0.001); - assertEquals("double", reader.nextName()); - assertEquals(3.141592653589793, reader.nextDouble(), 0.000000000000001); - assertEquals("true", reader.nextName()); - assertTrue(reader.nextBoolean()); - assertEquals("false", reader.nextName()); - assertFalse(reader.nextBoolean()); - assertEquals("null", reader.nextName()); - assertTrue(reader.isNull()); - assertNull(reader.nextValue()); - assertFalse(reader.hasNext()); - reader.endObject(); - } catch (IOException e) { - fail("Failed to read JSON object", e); + @Test + void testReadObject() { + String json = + "{\"string\":\"bar\",\"int\":3,\"long\":3456789123,\"float\":3.142,\"double\":3.141592653589793,\"true\":true,\"false\":false,\"null\":null}"; + try (JsonReader reader = new JsonReader(json)) { + reader.beginObject(); + assertEquals("string", reader.nextName()); + assertEquals("bar", reader.nextString()); + assertEquals("int", reader.nextName()); + assertEquals(3, reader.nextInt()); + assertEquals("long", reader.nextName()); + assertEquals(3456789123L, reader.nextLong()); + assertEquals("float", reader.nextName()); + assertEquals(3.142, reader.nextDouble(), 0.001); + assertEquals("double", reader.nextName()); + assertEquals(3.141592653589793, reader.nextDouble(), 0.000000000000001); + assertEquals("true", reader.nextName()); + assertTrue(reader.nextBoolean()); + assertEquals("false", reader.nextName()); + assertFalse(reader.nextBoolean()); + assertEquals("null", reader.nextName()); + assertTrue(reader.isNull()); + assertNull(reader.nextValue()); + assertFalse(reader.hasNext()); + reader.endObject(); + } catch (IOException e) { + fail("Failed to read JSON object", e); + } } - } - @Test - void testReadEmptyObject() { - String json = "{}"; - try (JsonReader reader = new JsonReader(json)) { - reader.beginObject(); - reader.endObject(); - } catch (IOException e) { - fail("Failed to read JSON empty object", e); + @Test + void testReadEmptyObject() { + String json = "{}"; + try (JsonReader reader = new JsonReader(json)) { + reader.beginObject(); + reader.endObject(); + } catch (IOException e) { + fail("Failed to read JSON empty object", e); + } } - } - @ParameterizedTest - @ValueSource(strings = {"", "null", "1", "[]", "true", "false"}) - void testInvalidObjectStart(String json) { - assertThrows( - IOException.class, - () -> { - try (JsonReader reader = new JsonReader(json)) { - reader.beginObject(); - } + @ParameterizedTest + @ValueSource(strings = {"", "null", "1", "[]", "true", "false"}) + void testInvalidObjectStart(String json) { + assertThrows(IOException.class, () -> { + try (JsonReader reader = new JsonReader(json)) { + reader.beginObject(); + } }); - } + } - @ParameterizedTest - @ValueSource(strings = {"{", "{\"key\":\"value\"}", "{null}", "{]"}) - void testInvalidObjectEnd(String json) { - assertThrows( - IOException.class, - () -> { - try (JsonReader reader = new JsonReader(json)) { - reader.beginObject(); - reader.endObject(); - } + @ParameterizedTest + @ValueSource(strings = {"{", "{\"key\":\"value\"}", "{null}", "{]"}) + void testInvalidObjectEnd(String json) { + assertThrows(IOException.class, () -> { + try (JsonReader reader = new JsonReader(json)) { + reader.beginObject(); + reader.endObject(); + } }); - } + } - @ParameterizedTest - @ValueSource(strings = {"{\"key\"}", "{\"key\"\"value\"}", "{key:\"value\"}"}) - void testInvalidObjectNames(String json) { - assertThrows( - IOException.class, - () -> { - try (JsonReader reader = new JsonReader(json)) { - reader.beginObject(); - reader.nextName(); - } + @ParameterizedTest + @ValueSource(strings = {"{\"key\"}", "{\"key\"\"value\"}", "{key:\"value\"}"}) + void testInvalidObjectNames(String json) { + assertThrows(IOException.class, () -> { + try (JsonReader reader = new JsonReader(json)) { + reader.beginObject(); + reader.nextName(); + } }); - } + } - @ParameterizedTest - @ValueSource(strings = {"{\"key\":value}"}) - void testInvalidObjectValue(String json) { - assertThrows( - IOException.class, - () -> { - try (JsonReader reader = new JsonReader(json)) { - reader.beginObject(); - reader.nextName(); - reader.nextValue(); - } + @ParameterizedTest + @ValueSource(strings = {"{\"key\":value}"}) + void testInvalidObjectValue(String json) { + assertThrows(IOException.class, () -> { + try (JsonReader reader = new JsonReader(json)) { + reader.beginObject(); + reader.nextName(); + reader.nextValue(); + } }); - } - - @Test - void testReadArray() { - String json = "[\"foo\",\"baz\",\"bar\",\"quux\"]"; - try (JsonReader reader = new JsonReader(json)) { - reader.beginArray(); - assertEquals("foo", reader.nextString()); - assertEquals("baz", reader.nextString()); - assertEquals("bar", reader.nextString()); - assertEquals("quux", reader.nextString()); - assertFalse(reader.hasNext()); - reader.endArray(); - } catch (IOException e) { - fail("Failed to read JSON array", e); } - } - @ParameterizedTest - @ValueSource(strings = {"", "null", "1", "{}", "true", "false"}) - void testInvalidArrayStart(String json) { - assertThrows( - IOException.class, - () -> { - try (JsonReader reader = new JsonReader(json)) { - reader.beginArray(); - } - }, - "Failed to detect invalid array start"); - } - - @ParameterizedTest - @ValueSource(strings = {"[", "[\"value\"]", "[null", "[}"}) - void testInvalidArrayEnd(String json) { - assertThrows( - IOException.class, - () -> { - try (JsonReader reader = new JsonReader(json)) { + @Test + void testReadArray() { + String json = "[\"foo\",\"baz\",\"bar\",\"quux\"]"; + try (JsonReader reader = new JsonReader(json)) { reader.beginArray(); + assertEquals("foo", reader.nextString()); + assertEquals("baz", reader.nextString()); + assertEquals("bar", reader.nextString()); + assertEquals("quux", reader.nextString()); + assertFalse(reader.hasNext()); reader.endArray(); - } - }, - "Failed to detect invalid array end"); - } + } catch (IOException e) { + fail("Failed to read JSON array", e); + } + } + + @ParameterizedTest + @ValueSource(strings = {"", "null", "1", "{}", "true", "false"}) + void testInvalidArrayStart(String json) { + assertThrows( + IOException.class, + () -> { + try (JsonReader reader = new JsonReader(json)) { + reader.beginArray(); + } + }, + "Failed to detect invalid array start"); + } - @Test - void testIsNull() { - try (JsonReader reader = new JsonReader("null")) { - assertTrue(reader.isNull()); - } catch (IOException e) { - fail("Failed to read null value JSON", e); + @ParameterizedTest + @ValueSource(strings = {"[", "[\"value\"]", "[null", "[}"}) + void testInvalidArrayEnd(String json) { + assertThrows( + IOException.class, + () -> { + try (JsonReader reader = new JsonReader(json)) { + reader.beginArray(); + reader.endArray(); + } + }, + "Failed to detect invalid array end"); } - } - @ParameterizedTest - @ValueSource(strings = {"\"bar\"", "3", "3456789123", "3.142", "true", "false", "{}", "[]"}) - void testIsNotNull(String json) { - try (JsonReader reader = new JsonReader(json)) { - assertFalse(reader.isNull()); - } catch (IOException e) { - fail("Failed to read non-null value JSON", e); + @Test + void testIsNull() { + try (JsonReader reader = new JsonReader("null")) { + assertTrue(reader.isNull()); + } catch (IOException e) { + fail("Failed to read null value JSON", e); + } } - } - @Test - void testReadBoolean() { - assertDoesNotThrow( - () -> { - assertTrue(readBoolean("true")); - assertFalse(readBoolean("false")); - }, - "Failed to read boolean value"); - } + @ParameterizedTest + @ValueSource(strings = {"\"bar\"", "3", "3456789123", "3.142", "true", "false", "{}", "[]"}) + void testIsNotNull(String json) { + try (JsonReader reader = new JsonReader(json)) { + assertFalse(reader.isNull()); + } catch (IOException e) { + fail("Failed to read non-null value JSON", e); + } + } - @ParameterizedTest - @ValueSource( - strings = { - "ttrue", - "ffalse", - "TRUE", - "FALSE", - "null", - "\"bar\"", - "3", - "3456789123", - "3.142", - "{}", - "[]" - }) - void testInvalidBoolean(String json) { - assertThrows( - IOException.class, () -> readBoolean(json), "Failed to detect invalid boolean value"); - } + @Test + void testReadBoolean() { + assertDoesNotThrow( + () -> { + assertTrue(readBoolean("true")); + assertFalse(readBoolean("false")); + }, + "Failed to read boolean value"); + } - @Test - void testStringEscaping() { - String json = "[\"\\\"\",\"\\\\\",\"\\/\",\"\\b\",\"\\f\",\"\\n\",\"\\r\",\"\\t\",\"\\u00C9\"]"; - try (JsonReader reader = new JsonReader(json)) { - reader.beginArray(); - assertEquals("\"", reader.nextString()); - assertEquals("\\", reader.nextString()); - assertEquals("/", reader.nextString()); - assertEquals("\b", reader.nextString()); - assertEquals("\f", reader.nextString()); - assertEquals("\n", reader.nextString()); - assertEquals("\r", reader.nextString()); - assertEquals("\t", reader.nextString()); - assertEquals("É", reader.nextString()); - reader.endArray(); - } catch (IOException e) { - fail("Failed to read escaped JSON strings", e); + @ParameterizedTest + @ValueSource( + strings = {"ttrue", "ffalse", "TRUE", "FALSE", "null", "\"bar\"", "3", "3456789123", "3.142", "{}", "[]"}) + void testInvalidBoolean(String json) { + assertThrows(IOException.class, () -> readBoolean(json), "Failed to detect invalid boolean value"); } - } - @ParameterizedTest - @ValueSource( - strings = { - "bar", - "true", - "false", - "null", - "3", - "3456789123", - "3.142", - "{}", - "[]", - "\"\\uGHIJ\"" - }) - void testInvalidString(String json) { - assertThrows( - IOException.class, - () -> { - try (JsonReader reader = new JsonReader(json)) { - reader.nextString(); - } - }, - "Failed to detect invalid string value"); - } + @Test + void testStringEscaping() { + String json = "[\"\\\"\",\"\\\\\",\"\\/\",\"\\b\",\"\\f\",\"\\n\",\"\\r\",\"\\t\",\"\\u00C9\"]"; + try (JsonReader reader = new JsonReader(json)) { + reader.beginArray(); + assertEquals("\"", reader.nextString()); + assertEquals("\\", reader.nextString()); + assertEquals("/", reader.nextString()); + assertEquals("\b", reader.nextString()); + assertEquals("\f", reader.nextString()); + assertEquals("\n", reader.nextString()); + assertEquals("\r", reader.nextString()); + assertEquals("\t", reader.nextString()); + assertEquals("É", reader.nextString()); + reader.endArray(); + } catch (IOException e) { + fail("Failed to read escaped JSON strings", e); + } + } - @Test - void testReadInt() { - assertDoesNotThrow( - () -> { - assertEquals(1, readInt("1")); - assertEquals(0, readInt("0")); - assertEquals(-1, readInt("-1")); - assertEquals(Integer.MAX_VALUE, readInt("2147483647")); - assertEquals(Integer.MIN_VALUE, readInt("-2147483648")); - }, - "Failed to read int value"); - } + @ParameterizedTest + @ValueSource(strings = {"bar", "true", "false", "null", "3", "3456789123", "3.142", "{}", "[]", "\"\\uGHIJ\""}) + void testInvalidString(String json) { + assertThrows( + IOException.class, + () -> { + try (JsonReader reader = new JsonReader(json)) { + reader.nextString(); + } + }, + "Failed to detect invalid string value"); + } - @ParameterizedTest - @ValueSource( - strings = {"\"bar\"", "true", "false", "null", "3456789123", "3.142", "1e100", "{}", "[]"}) - void testInvalidInt(String json) { - assertThrows(IOException.class, () -> readInt(json), "Failed to detect invalid int value"); - } + @Test + void testReadInt() { + assertDoesNotThrow( + () -> { + assertEquals(1, readInt("1")); + assertEquals(0, readInt("0")); + assertEquals(-1, readInt("-1")); + assertEquals(Integer.MAX_VALUE, readInt("2147483647")); + assertEquals(Integer.MIN_VALUE, readInt("-2147483648")); + }, + "Failed to read int value"); + } - @Test - void testReadLong() { - assertDoesNotThrow( - () -> { - assertEquals(1L, readLong("1")); - assertEquals(0L, readLong("0")); - assertEquals(-1L, readLong("-1")); - assertEquals(Long.MAX_VALUE, readLong("9223372036854775807")); - assertEquals(Long.MIN_VALUE, readLong("-9223372036854775808")); - }, - "Failed to read long value"); - } + @ParameterizedTest + @ValueSource(strings = {"\"bar\"", "true", "false", "null", "3456789123", "3.142", "1e100", "{}", "[]"}) + void testInvalidInt(String json) { + assertThrows(IOException.class, () -> readInt(json), "Failed to detect invalid int value"); + } - @ParameterizedTest - @ValueSource(strings = {"\"bar\"", "true", "false", "null", "3.142", "1e100", "{}", "[]"}) - void testInvalidLong(String json) { - assertThrows(IOException.class, () -> readLong(json), "Failed to detect invalid long value"); - } + @Test + void testReadLong() { + assertDoesNotThrow( + () -> { + assertEquals(1L, readLong("1")); + assertEquals(0L, readLong("0")); + assertEquals(-1L, readLong("-1")); + assertEquals(Long.MAX_VALUE, readLong("9223372036854775807")); + assertEquals(Long.MIN_VALUE, readLong("-9223372036854775808")); + }, + "Failed to read long value"); + } + + @ParameterizedTest + @ValueSource(strings = {"\"bar\"", "true", "false", "null", "3.142", "1e100", "{}", "[]"}) + void testInvalidLong(String json) { + assertThrows(IOException.class, () -> readLong(json), "Failed to detect invalid long value"); + } - @Test - void testReadDouble() { - assertDoesNotThrow( - () -> { - assertEquals(3.14, readDouble("3.14"), 0.01); - assertEquals(-3.14, readDouble("-3.14"), 0.01); - assertEquals(-3.14, readDouble("-3.14e0"), 0.01); - assertEquals(314, readDouble("3.14e2"), 1); - assertEquals(0.0314, readDouble("3.14e-2"), 0.0001); - }, - "Failed to read double value"); - } + @Test + void testReadDouble() { + assertDoesNotThrow( + () -> { + assertEquals(3.14, readDouble("3.14"), 0.01); + assertEquals(-3.14, readDouble("-3.14"), 0.01); + assertEquals(-3.14, readDouble("-3.14e0"), 0.01); + assertEquals(314, readDouble("3.14e2"), 1); + assertEquals(0.0314, readDouble("3.14e-2"), 0.0001); + }, + "Failed to read double value"); + } - @ParameterizedTest - @ValueSource(strings = {"\"bar\"", "true", "false", "null", "1ee1", "1e", "{}", "[]"}) - void testInvalidDouble(String json) { - assertThrows( - IOException.class, () -> readDouble(json), "Failed to detect invalid double value"); - } + @ParameterizedTest + @ValueSource(strings = {"\"bar\"", "true", "false", "null", "1ee1", "1e", "{}", "[]"}) + void testInvalidDouble(String json) { + assertThrows(IOException.class, () -> readDouble(json), "Failed to detect invalid double value"); + } - @Test - void testReaderOnHugePayload() { - try (InputStream stream = JsonReader.class.getResourceAsStream("/lorem-ipsum.json"); - JsonReader reader = new JsonReader(new InputStreamReader(requireNonNull(stream)))) { - reader.beginArray(); - while (reader.hasNext()) { - reader.nextString(); - } - reader.endArray(); - } catch (IOException e) { - fail("Failed to read JSON stream", e); + @Test + void testReaderOnHugePayload() { + try (InputStream stream = JsonReader.class.getResourceAsStream("/lorem-ipsum.json"); + JsonReader reader = new JsonReader(new InputStreamReader(requireNonNull(stream)))) { + reader.beginArray(); + while (reader.hasNext()) { + reader.nextString(); + } + reader.endArray(); + } catch (IOException e) { + fail("Failed to read JSON stream", e); + } } - } - @Test - void testConstructor() { - assertThrows(IllegalArgumentException.class, () -> new JsonReader(null, false).close()); - } + @Test + void testConstructor() { + assertThrows(IllegalArgumentException.class, () -> new JsonReader(null, false).close()); + } - private boolean readBoolean(String json) throws IOException { - try (JsonReader reader = new JsonReader(json)) { - return reader.nextBoolean(); + private boolean readBoolean(String json) throws IOException { + try (JsonReader reader = new JsonReader(json)) { + return reader.nextBoolean(); + } } - } - private int readInt(String json) throws IOException { - try (JsonReader reader = new JsonReader(json)) { - return reader.nextInt(); + private int readInt(String json) throws IOException { + try (JsonReader reader = new JsonReader(json)) { + return reader.nextInt(); + } } - } - private long readLong(String json) throws IOException { - try (JsonReader reader = new JsonReader(json)) { - return reader.nextLong(); + private long readLong(String json) throws IOException { + try (JsonReader reader = new JsonReader(json)) { + return reader.nextLong(); + } } - } - private double readDouble(String json) throws IOException { - try (JsonReader reader = new JsonReader(json)) { - return reader.nextDouble(); + private double readDouble(String json) throws IOException { + try (JsonReader reader = new JsonReader(json)) { + return reader.nextDouble(); + } } - } } diff --git a/components/json/src/test/java/datadog/json/JsonStructureTest.java b/components/json/src/test/java/datadog/json/JsonStructureTest.java index e3fb9d16802..03e68e5ffb9 100644 --- a/components/json/src/test/java/datadog/json/JsonStructureTest.java +++ b/components/json/src/test/java/datadog/json/JsonStructureTest.java @@ -7,27 +7,27 @@ import org.junit.jupiter.api.Test; class JsonStructureTest { - // toString is only for debug purpose and has no behavior to cover - @Test - void coverToString() { - SafeJsonStructure safeJsonStructure = new SafeJsonStructure(); - assertNotNull(safeJsonStructure.toString()); - safeJsonStructure.beginObject(); - safeJsonStructure.endObject(); - assertNotNull(safeJsonStructure.toString()); - } + // toString is only for debug purpose and has no behavior to cover + @Test + void coverToString() { + SafeJsonStructure safeJsonStructure = new SafeJsonStructure(); + assertNotNull(safeJsonStructure.toString()); + safeJsonStructure.beginObject(); + safeJsonStructure.endObject(); + assertNotNull(safeJsonStructure.toString()); + } - // Lenient is a stub and has no behavior to cover - @Test - void lenientStructure() { - LenientJsonStructure lenientJsonStructure = new LenientJsonStructure(); - lenientJsonStructure.beginObject(); - assertTrue(lenientJsonStructure.objectStarted()); - lenientJsonStructure.endObject(); - lenientJsonStructure.beginArray(); - assertTrue(lenientJsonStructure.arrayStarted()); - lenientJsonStructure.endArray(); - assertDoesNotThrow(lenientJsonStructure::addName); - assertDoesNotThrow(lenientJsonStructure::addValue); - } + // Lenient is a stub and has no behavior to cover + @Test + void lenientStructure() { + LenientJsonStructure lenientJsonStructure = new LenientJsonStructure(); + lenientJsonStructure.beginObject(); + assertTrue(lenientJsonStructure.objectStarted()); + lenientJsonStructure.endObject(); + lenientJsonStructure.beginArray(); + assertTrue(lenientJsonStructure.arrayStarted()); + lenientJsonStructure.endArray(); + assertDoesNotThrow(lenientJsonStructure::addName); + assertDoesNotThrow(lenientJsonStructure::addValue); + } } diff --git a/components/json/src/test/java/datadog/json/JsonWriterTest.java b/components/json/src/test/java/datadog/json/JsonWriterTest.java index 5740fef29f5..be8aaded72f 100644 --- a/components/json/src/test/java/datadog/json/JsonWriterTest.java +++ b/components/json/src/test/java/datadog/json/JsonWriterTest.java @@ -7,221 +7,218 @@ import org.junit.jupiter.api.Test; class JsonWriterTest { - @Test - void testObject() { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginObject() - .name("string") - .value("bar") - .name("int") - .value(3) - .name("long") - .value(3456789123L) - .name("float") - .value(3.142) - .name("double") - .value(PI) - .name("true") - .value(true) - .name("false") - .value(false) - .name("null") - .nullValue() - .endObject(); - - assertEquals( - "{\"string\":\"bar\",\"int\":3,\"long\":3456789123,\"float\":3.142,\"double\":3.141592653589793,\"true\":true,\"false\":false,\"null\":null}", - writer.toString(), - "Check object writer"); + @Test + void testObject() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject() + .name("string") + .value("bar") + .name("int") + .value(3) + .name("long") + .value(3456789123L) + .name("float") + .value(3.142) + .name("double") + .value(PI) + .name("true") + .value(true) + .name("false") + .value(false) + .name("null") + .nullValue() + .endObject(); + + assertEquals( + "{\"string\":\"bar\",\"int\":3,\"long\":3456789123,\"float\":3.142,\"double\":3.141592653589793,\"true\":true,\"false\":false,\"null\":null}", + writer.toString(), + "Check object writer"); + } } - } - - @Test - void testNullName() { - assertThrows( - IllegalArgumentException.class, - () -> { - try (JsonWriter writer = new JsonWriter()) { + + @Test + void testNullName() { + assertThrows( + IllegalArgumentException.class, + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.name(null); + } + }, + "Check null name"); + } + + @Test + void testNullValue() { + try (JsonWriter writer = new JsonWriter()) { writer.beginObject(); - writer.name(null); - } - }, - "Check null name"); - } - - @Test - void testNullValue() { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - writer.name("string").value(null); - writer.endObject(); - assertEquals("{\"string\":null}", writer.toString(), "Check null string"); + writer.name("string").value(null); + writer.endObject(); + assertEquals("{\"string\":null}", writer.toString(), "Check null string"); + } } - } - - @Test - void testNaNValues() { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - writer.name("float").value(Float.NaN); - writer.name("double").value(Double.NaN); - writer.endObject(); - assertEquals("{\"float\":null,\"double\":null}", writer.toString(), "Check NaN values"); + + @Test + void testNaNValues() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.name("float").value(Float.NaN); + writer.name("double").value(Double.NaN); + writer.endObject(); + assertEquals("{\"float\":null,\"double\":null}", writer.toString(), "Check NaN values"); + } } - } - @Test - void testArray() { - try (JsonWriter writer = new JsonWriter()) { - writer.beginArray().value("foo").value("baz").value("bar").value("quux").endArray(); - assertEquals("[\"foo\",\"baz\",\"bar\",\"quux\"]", writer.toString(), "Check array writer"); + @Test + void testArray() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray() + .value("foo") + .value("baz") + .value("bar") + .value("quux") + .endArray(); + assertEquals("[\"foo\",\"baz\",\"bar\",\"quux\"]", writer.toString(), "Check array writer"); + } } - } - - @Test - void testStringEscaping() { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginArray() - .value("\"") - .value("\\") - .value("/") - .value("\b") - .value("\f") - .value("\n") - .value("\r") - .value("\t") - .endArray(); - - assertEquals( - "[\"\\\"\",\"\\\\\",\"\\/\",\"\\b\",\"\\f\",\"\\n\",\"\\r\",\"\\t\"]", - writer.toString(), - "Check string escaping"); + + @Test + void testStringEscaping() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray() + .value("\"") + .value("\\") + .value("/") + .value("\b") + .value("\f") + .value("\n") + .value("\r") + .value("\t") + .endArray(); + + assertEquals( + "[\"\\\"\",\"\\\\\",\"\\/\",\"\\b\",\"\\f\",\"\\n\",\"\\r\",\"\\t\"]", + writer.toString(), + "Check string escaping"); + } } - } - @Test - void testControlCharacterEscaping() { - try (JsonWriter writer = new JsonWriter()) { - writer.beginArray().value("\u0001").value("\u001F").endArray(); + @Test + void testControlCharacterEscaping() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray().value("\u0001").value("\u001F").endArray(); - assertEquals( - "[\"\\u0001\",\"\\u001F\"]", writer.toString(), "Check control character escaping"); + assertEquals("[\"\\u0001\",\"\\u001F\"]", writer.toString(), "Check control character escaping"); + } } - } - - @Test - void testArrayObjectNesting() { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginObject() - .name("array") - .beginArray() - .value("true") - .value("false") - .endArray() - .endObject(); - - assertEquals( - "{\"array\":[\"true\",\"false\"]}", writer.toString(), "Check array / object nesting"); + + @Test + void testArrayObjectNesting() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject() + .name("array") + .beginArray() + .value("true") + .value("false") + .endArray() + .endObject(); + + assertEquals("{\"array\":[\"true\",\"false\"]}", writer.toString(), "Check array / object nesting"); + } } - } - - @Test - void testObjectArrayNesting() { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginArray() - .beginObject() - .name("true") - .value(true) - .endObject() - .beginObject() - .name("false") - .value(false) - .endObject() - .endArray(); - - assertEquals( - "[{\"true\":true},{\"false\":false}]", writer.toString(), "Check object / array nesting"); + + @Test + void testObjectArrayNesting() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray() + .beginObject() + .name("true") + .value(true) + .endObject() + .beginObject() + .name("false") + .value(false) + .endObject() + .endArray(); + + assertEquals("[{\"true\":true},{\"false\":false}]", writer.toString(), "Check object / array nesting"); + } } - } - @Test - void testNameOnlyInObject() { - try (JsonWriter writer = new JsonWriter()) { - assertThrows( - IllegalStateException.class, () -> writer.name("key"), "Check name only in object"); + @Test + void testNameOnlyInObject() { + try (JsonWriter writer = new JsonWriter()) { + assertThrows(IllegalStateException.class, () -> writer.name("key"), "Check name only in object"); + } } - } - - @Test - void testCompleteObject() { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - writer.endObject(); - assertThrows(IllegalStateException.class, writer::beginObject, "Check complete object"); - assertThrows(IllegalStateException.class, writer::beginArray, "Check complete object"); + + @Test + void testCompleteObject() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.endObject(); + assertThrows(IllegalStateException.class, writer::beginObject, "Check complete object"); + assertThrows(IllegalStateException.class, writer::beginArray, "Check complete object"); + } } - } - @Test - void testSize() { - try (JsonWriter writer = new JsonWriter()) { - assertEquals(0, writer.size(), "Check empty writer size"); + @Test + void testSize() { + try (JsonWriter writer = new JsonWriter()) { + assertEquals(0, writer.size(), "Check empty writer size"); - writer.beginArray(); - assertSizeInBytes(writer, "Check size after plain ASCII string"); - writer.value("bar"); + writer.beginArray(); + assertSizeInBytes(writer, "Check size after plain ASCII string"); + writer.value("bar"); - assertSizeInBytes(writer, "Check size after escaped characters"); - writer.value("\"\\/"); + assertSizeInBytes(writer, "Check size after escaped characters"); + writer.value("\"\\/"); - assertSizeInBytes(writer, "Check size after named escapes"); - writer.value("\b\f\n\r\t"); + assertSizeInBytes(writer, "Check size after named escapes"); + writer.value("\b\f\n\r\t"); - assertSizeInBytes(writer, "Check size after control character escape"); - writer.value("\u0001"); + assertSizeInBytes(writer, "Check size after control character escape"); + writer.value("\u0001"); - assertSizeInBytes(writer, "Check size after non-ASCII character escape"); - writer.value("café"); + assertSizeInBytes(writer, "Check size after non-ASCII character escape"); + writer.value("café"); - assertSizeInBytes(writer, "Check size after int value"); - writer.value(3); + assertSizeInBytes(writer, "Check size after int value"); + writer.value(3); - assertSizeInBytes(writer, "Check size after long value"); - writer.value(3456789123L); + assertSizeInBytes(writer, "Check size after long value"); + writer.value(3456789123L); - assertSizeInBytes(writer, "Check size after float value"); - writer.value(3.142f); + assertSizeInBytes(writer, "Check size after float value"); + writer.value(3.142f); - assertSizeInBytes(writer, "Check size after double value"); - writer.value(PI); + assertSizeInBytes(writer, "Check size after double value"); + writer.value(PI); - assertSizeInBytes(writer, "Check size after boolean value"); - writer.value(true); + assertSizeInBytes(writer, "Check size after boolean value"); + writer.value(true); - assertSizeInBytes(writer, "Check size after null value"); - writer.nullValue(); + assertSizeInBytes(writer, "Check size after null value"); + writer.nullValue(); - writer.endArray(); - assertSizeInBytes(writer, "Check final size matches written bytes"); + writer.endArray(); + assertSizeInBytes(writer, "Check final size matches written bytes"); + } } - } - - private static void assertSizeInBytes(JsonWriter writer, String message) { - int size = writer.size(); - assertEquals(writer.toByteArray().length, size, message); - } - - @Test - void testCompleteArray() { - try (JsonWriter writer = new JsonWriter()) { - writer.beginArray(); - writer.endArray(); - assertThrows(IllegalStateException.class, writer::beginObject, "Check complete array"); - assertThrows(IllegalStateException.class, writer::beginArray, "Check complete array"); + + private static void assertSizeInBytes(JsonWriter writer, String message) { + int size = writer.size(); + assertEquals(writer.toByteArray().length, size, message); + } + + @Test + void testCompleteArray() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray(); + writer.endArray(); + assertThrows(IllegalStateException.class, writer::beginObject, "Check complete array"); + assertThrows(IllegalStateException.class, writer::beginArray, "Check complete array"); + } } - } } diff --git a/components/json/src/test/java/datadog/json/SafeJsonWriterTest.java b/components/json/src/test/java/datadog/json/SafeJsonWriterTest.java index 9bdd7960d05..14ffb0519b7 100644 --- a/components/json/src/test/java/datadog/json/SafeJsonWriterTest.java +++ b/components/json/src/test/java/datadog/json/SafeJsonWriterTest.java @@ -6,128 +6,126 @@ import org.junit.jupiter.api.Test; class SafeJsonWriterTest { - @Test - void testRootElement() { - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject().endObject(); - } - }, - "Check object allowed as root element"); - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject().beginArray(); - } - }, - "Check array allowed as root element"); - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.value("string"); - } - }, - "Check string allowed as root element"); - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.value(1); - } - }, - "Check number allowed as root element"); - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.value(true); - } - }, - "Check boolean allowed as root element"); - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.nullValue(); - } - }, - "Check null value allowed as root element"); - } + @Test + void testRootElement() { + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject().endObject(); + } + }, + "Check object allowed as root element"); + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject().beginArray(); + } + }, + "Check array allowed as root element"); + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.value("string"); + } + }, + "Check string allowed as root element"); + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.value(1); + } + }, + "Check number allowed as root element"); + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.value(true); + } + }, + "Check boolean allowed as root element"); + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.nullValue(); + } + }, + "Check null value allowed as root element"); + } - @Test - void testNestedElements() { - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginObject() - .beginObject() - .beginObject() - .endObject() - .beginObject() - .endObject() - .endObject() - .endObject(); - } - }, - "Check nested objects"); - assertDoesNotThrow( - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer - .beginArray() - .beginArray() - .beginArray() - .endArray() - .beginArray() - .endArray() - .endArray() - .endArray(); - } - }, - "Check nested arrays"); + @Test + void testNestedElements() { + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject() + .beginObject() + .beginObject() + .endObject() + .beginObject() + .endObject() + .endObject() + .endObject(); + } + }, + "Check nested objects"); + assertDoesNotThrow( + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray() + .beginArray() + .beginArray() + .endArray() + .beginArray() + .endArray() + .endArray() + .endArray(); + } + }, + "Check nested arrays"); - assertThrows( - IllegalStateException.class, - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject().beginObject().endObject().endArray(); - } - }, - "Check invalid array end"); - assertThrows( - IllegalStateException.class, - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.beginArray().beginArray().endArray().endObject(); - } - }, - "Check invalid object end"); - } + assertThrows( + IllegalStateException.class, + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject().beginObject().endObject().endArray(); + } + }, + "Check invalid array end"); + assertThrows( + IllegalStateException.class, + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray().beginArray().endArray().endObject(); + } + }, + "Check invalid object end"); + } - @Test - void testCompleteJson() { - assertThrows( - IllegalStateException.class, - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject().endObject().value("invalid"); - } - }, - "Check complete object"); - assertThrows( - IllegalStateException.class, - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.beginArray().endArray().value("invalid"); - } - }, - "Check complete array"); - assertThrows( - IllegalStateException.class, - () -> { - try (JsonWriter writer = new JsonWriter()) { - writer.value("string").value("invalid"); - } - }, - "Check complete value"); - } + @Test + void testCompleteJson() { + assertThrows( + IllegalStateException.class, + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject().endObject().value("invalid"); + } + }, + "Check complete object"); + assertThrows( + IllegalStateException.class, + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.beginArray().endArray().value("invalid"); + } + }, + "Check complete array"); + assertThrows( + IllegalStateException.class, + () -> { + try (JsonWriter writer = new JsonWriter()) { + writer.value("string").value("invalid"); + } + }, + "Check complete value"); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/ClassLoaderResourcePathLocator.java b/components/native-loader/src/main/java/datadog/nativeloader/ClassLoaderResourcePathLocator.java index 1aef0ff3ca8..88d0158e883 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/ClassLoaderResourcePathLocator.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/ClassLoaderResourcePathLocator.java @@ -5,31 +5,29 @@ /** ClassLoaderResourcePathLocator locates library paths inside a {@link ClassLoader} */ final class ClassLoaderResourcePathLocator implements PathLocator { - private final ClassLoader classLoader; - private final String baseResource; + private final ClassLoader classLoader; + private final String baseResource; - public ClassLoaderResourcePathLocator(final ClassLoader classLoader, final String baseResource) { - this.classLoader = classLoader; - this.baseResource = baseResource; - } + public ClassLoaderResourcePathLocator(final ClassLoader classLoader, final String baseResource) { + this.classLoader = classLoader; + this.baseResource = baseResource; + } - @Override - public URL locate(String optionalComponent, String path) { - return this.classLoader.getResource( - PathUtils.concatPath(optionalComponent, this.baseResource, path)); - } + @Override + public URL locate(String optionalComponent, String path) { + return this.classLoader.getResource(PathUtils.concatPath(optionalComponent, this.baseResource, path)); + } - @Override - public int hashCode() { - return Objects.hash(this.classLoader, this.baseResource); - } + @Override + public int hashCode() { + return Objects.hash(this.classLoader, this.baseResource); + } - @Override - public boolean equals(Object obj) { - if (!(obj instanceof ClassLoaderResourcePathLocator)) return false; + @Override + public boolean equals(Object obj) { + if (!(obj instanceof ClassLoaderResourcePathLocator)) return false; - ClassLoaderResourcePathLocator that = (ClassLoaderResourcePathLocator) obj; - return this.classLoader.equals(that.classLoader) - && Objects.equals(this.baseResource, that.baseResource); - } + ClassLoaderResourcePathLocator that = (ClassLoaderResourcePathLocator) obj; + return this.classLoader.equals(that.classLoader) && Objects.equals(this.baseResource, that.baseResource); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/CompositeLibraryLoadingListener.java b/components/native-loader/src/main/java/datadog/nativeloader/CompositeLibraryLoadingListener.java index 6f87121b6d2..4d6039c0c75 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/CompositeLibraryLoadingListener.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/CompositeLibraryLoadingListener.java @@ -7,134 +7,120 @@ import java.util.Collection; final class CompositeLibraryLoadingListener extends SafeLibraryLoadingListener { - private final Collection listeners; + private final Collection listeners; - CompositeLibraryLoadingListener(LibraryLoadingListener... listeners) { - this(Arrays.asList(listeners)); - } + CompositeLibraryLoadingListener(LibraryLoadingListener... listeners) { + this(Arrays.asList(listeners)); + } - CompositeLibraryLoadingListener(Collection listeners) { - this.listeners = listeners; - } + CompositeLibraryLoadingListener(Collection listeners) { + this.listeners = listeners; + } - @Override - public boolean isNop() { - return this.listeners.isEmpty(); - } + @Override + public boolean isNop() { + return this.listeners.isEmpty(); + } - int size() { - return this.listeners.size(); - } + int size() { + return this.listeners.size(); + } - @Override - public void onResolveDynamic( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - URL optionalUrl) { - for (LibraryLoadingListener listener : this.listeners) { - try { - listener.onResolveDynamic( - platformSpec, optionalComponent, libName, isPreloaded, optionalUrl); - } catch (Throwable ignored) { - } + @Override + public void onResolveDynamic( + PlatformSpec platformSpec, String optionalComponent, String libName, boolean isPreloaded, URL optionalUrl) { + for (LibraryLoadingListener listener : this.listeners) { + try { + listener.onResolveDynamic(platformSpec, optionalComponent, libName, isPreloaded, optionalUrl); + } catch (Throwable ignored) { + } + } } - } - @Override - public void onResolveDynamicFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - for (LibraryLoadingListener listener : this.listeners) { - try { - listener.onResolveDynamicFailure(platformSpec, optionalComponent, libName, optionalCause); - } catch (Throwable ignored) { - } + @Override + public void onResolveDynamicFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + for (LibraryLoadingListener listener : this.listeners) { + try { + listener.onResolveDynamicFailure(platformSpec, optionalComponent, libName, optionalCause); + } catch (Throwable ignored) { + } + } } - } - @Override - public void onLoad( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - Path optionalLibPath) { - for (LibraryLoadingListener listener : this.listeners) { - try { - listener.onLoad(platformSpec, optionalComponent, libName, isPreloaded, optionalLibPath); - } catch (Throwable ignored) { - } + @Override + public void onLoad( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + Path optionalLibPath) { + for (LibraryLoadingListener listener : this.listeners) { + try { + listener.onLoad(platformSpec, optionalComponent, libName, isPreloaded, optionalLibPath); + } catch (Throwable ignored) { + } + } } - } - @Override - public void onLoadFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - for (LibraryLoadingListener listener : this.listeners) { - try { - listener.onLoadFailure(platformSpec, optionalComponent, libName, optionalCause); - } catch (Throwable ignored) { - } + @Override + public void onLoadFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + for (LibraryLoadingListener listener : this.listeners) { + try { + listener.onLoadFailure(platformSpec, optionalComponent, libName, optionalCause); + } catch (Throwable ignored) { + } + } } - } - @Override - public void onTempFileCreated( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - for (LibraryLoadingListener listener : this.listeners) { - try { - listener.onTempFileCreated(platformSpec, optionalComponent, libName, tempFile); - } catch (Throwable ignored) { - } + @Override + public void onTempFileCreated(PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + for (LibraryLoadingListener listener : this.listeners) { + try { + listener.onTempFileCreated(platformSpec, optionalComponent, libName, tempFile); + } catch (Throwable ignored) { + } + } } - } - @Override - public void onTempFileCreationFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Path tempDir, - String libExt, - Throwable optionalCause) { - for (LibraryLoadingListener listener : this.listeners) { - try { - listener.onTempFileCreationFailure( - platformSpec, optionalComponent, libName, tempDir, libExt, optionalCause); - } catch (Throwable ignored) { - } + @Override + public void onTempFileCreationFailure( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + Path tempDir, + String libExt, + Throwable optionalCause) { + for (LibraryLoadingListener listener : this.listeners) { + try { + listener.onTempFileCreationFailure( + platformSpec, optionalComponent, libName, tempDir, libExt, optionalCause); + } catch (Throwable ignored) { + } + } } - } - @Override - public void onTempFileCleanup( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempPath) { - for (LibraryLoadingListener listener : this.listeners) { - try { - listener.onTempFileCleanup(platformSpec, optionalComponent, libName, tempPath); - } catch (Throwable ignored) { - } + @Override + public void onTempFileCleanup(PlatformSpec platformSpec, String optionalComponent, String libName, Path tempPath) { + for (LibraryLoadingListener listener : this.listeners) { + try { + listener.onTempFileCleanup(platformSpec, optionalComponent, libName, tempPath); + } catch (Throwable ignored) { + } + } } - } - @Override - public CompositeLibraryLoadingListener join(LibraryLoadingListener... listeners) { - ArrayList combinedListeners = - new ArrayList<>(this.listeners.size() + listeners.length); - combinedListeners.addAll(this.listeners); - combinedListeners.addAll(Arrays.asList(listeners)); - return new CompositeLibraryLoadingListener(combinedListeners); - } + @Override + public CompositeLibraryLoadingListener join(LibraryLoadingListener... listeners) { + ArrayList combinedListeners = new ArrayList<>(this.listeners.size() + listeners.length); + combinedListeners.addAll(this.listeners); + combinedListeners.addAll(Arrays.asList(listeners)); + return new CompositeLibraryLoadingListener(combinedListeners); + } - @Override - public String toString() { - return this.getClass().getSimpleName() + ":" + this.listeners.toString(); - } + @Override + public String toString() { + return this.getClass().getSimpleName() + ":" + this.listeners.toString(); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/FlatDirLibraryResolver.java b/components/native-loader/src/main/java/datadog/nativeloader/FlatDirLibraryResolver.java index 9730b8de7f5..38f465b9235 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/FlatDirLibraryResolver.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/FlatDirLibraryResolver.java @@ -7,49 +7,49 @@ * {os}-{arch}-{libc/musl} */ public final class FlatDirLibraryResolver implements LibraryResolver { - public static final FlatDirLibraryResolver INSTANCE = new FlatDirLibraryResolver(); + public static final FlatDirLibraryResolver INSTANCE = new FlatDirLibraryResolver(); - private FlatDirLibraryResolver() {} + private FlatDirLibraryResolver() {} - @Override - public final URL resolve( - PathLocator pathLocator, PlatformSpec platformSpec, String optionalComponent, String libName) - throws Exception { - PathLocatorHelper pathLocatorHelper = new PathLocatorHelper(libName, pathLocator); + @Override + public final URL resolve( + PathLocator pathLocator, PlatformSpec platformSpec, String optionalComponent, String libName) + throws Exception { + PathLocatorHelper pathLocatorHelper = new PathLocatorHelper(libName, pathLocator); - String libFileName = PathUtils.libFileName(platformSpec, libName); + String libFileName = PathUtils.libFileName(platformSpec, libName); - String osPath = PathUtils.osPartOf(platformSpec); - String archPath = PathUtils.archPartOf(platformSpec); - String libcPath = PathUtils.libcPartOf(platformSpec); + String osPath = PathUtils.osPartOf(platformSpec); + String archPath = PathUtils.archPartOf(platformSpec); + String libcPath = PathUtils.libcPartOf(platformSpec); - URL url; - String regularPath = osPath + "-" + archPath; + URL url; + String regularPath = osPath + "-" + archPath; - if (libcPath != null) { - String specializedPath = regularPath + "-" + libcPath; - url = pathLocatorHelper.locate(optionalComponent, specializedPath + "/" + libFileName); - if (url != null) return url; - } + if (libcPath != null) { + String specializedPath = regularPath + "-" + libcPath; + url = pathLocatorHelper.locate(optionalComponent, specializedPath + "/" + libFileName); + if (url != null) return url; + } - url = pathLocatorHelper.locate(optionalComponent, regularPath + "/" + libFileName); - if (url != null) return url; + url = pathLocatorHelper.locate(optionalComponent, regularPath + "/" + libFileName); + if (url != null) return url; - url = pathLocatorHelper.locate(optionalComponent, osPath + "/" + libFileName); - if (url != null) return url; + url = pathLocatorHelper.locate(optionalComponent, osPath + "/" + libFileName); + if (url != null) return url; - // fallback to searching at top-level, mostly concession to good out-of-box behavior - // with java.library.path - url = pathLocatorHelper.locate(optionalComponent, libFileName); - if (url != null) return url; + // fallback to searching at top-level, mostly concession to good out-of-box behavior + // with java.library.path + url = pathLocatorHelper.locate(optionalComponent, libFileName); + if (url != null) return url; - if (optionalComponent != null) { - url = pathLocatorHelper.locate(null, libFileName); - if (url != null) return url; - } + if (optionalComponent != null) { + url = pathLocatorHelper.locate(null, libFileName); + if (url != null) return url; + } - pathLocatorHelper.tryThrow(); + pathLocatorHelper.tryThrow(); - return null; - } + return null; + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/IntrospectPlatformSpec.java b/components/native-loader/src/main/java/datadog/nativeloader/IntrospectPlatformSpec.java index 00ef82d6d69..f21601200f9 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/IntrospectPlatformSpec.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/IntrospectPlatformSpec.java @@ -7,59 +7,59 @@ * Default PlatformSpec used in dd-trace-java -- wraps detection code in component:environment */ final class IntrospectPlatformSpec extends PlatformSpec { - static final PlatformSpec INSTANCE = new IntrospectPlatformSpec(); + static final PlatformSpec INSTANCE = new IntrospectPlatformSpec(); - @Override - public boolean isLinux() { - return OperatingSystem.isLinux(); - } + @Override + public boolean isLinux() { + return OperatingSystem.isLinux(); + } - @Override - public boolean isMac() { - return OperatingSystem.isMacOs(); - } + @Override + public boolean isMac() { + return OperatingSystem.isMacOs(); + } - @Override - public boolean isWindows() { - return OperatingSystem.isWindows(); - } + @Override + public boolean isWindows() { + return OperatingSystem.isWindows(); + } - @Override - public boolean isMusl() { - return OperatingSystem.isMusl(); - } + @Override + public boolean isMusl() { + return OperatingSystem.isMusl(); + } - @Override - public boolean isAarch64() { - return isArch(Architecture.ARM64); - } + @Override + public boolean isAarch64() { + return isArch(Architecture.ARM64); + } - @Override - public boolean isArm32() { - return isArch(Architecture.ARM); - } + @Override + public boolean isArm32() { + return isArch(Architecture.ARM); + } - @Override - public boolean isX86_32() { - return isArch(Architecture.X86); - } + @Override + public boolean isX86_32() { + return isArch(Architecture.X86); + } - @Override - public boolean isX86_64() { - return isArch(Architecture.X64); - } + @Override + public boolean isX86_64() { + return isArch(Architecture.X64); + } - static final boolean isArch(OperatingSystem.Architecture arch) { - return (OperatingSystem.architecture() == arch); - } + static final boolean isArch(OperatingSystem.Architecture arch) { + return (OperatingSystem.architecture() == arch); + } - @Override - public int hashCode() { - return IntrospectPlatformSpec.class.hashCode(); - } + @Override + public int hashCode() { + return IntrospectPlatformSpec.class.hashCode(); + } - @Override - public boolean equals(Object obj) { - return (obj instanceof IntrospectPlatformSpec); - } + @Override + public boolean equals(Object obj) { + return (obj instanceof IntrospectPlatformSpec); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/LibDirBasedPathLocator.java b/components/native-loader/src/main/java/datadog/nativeloader/LibDirBasedPathLocator.java index 2ed0528c161..4ce77ba2104 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/LibDirBasedPathLocator.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/LibDirBasedPathLocator.java @@ -7,43 +7,43 @@ /** LibDirBasedPathLocator locates libraries inside a list of library directories */ final class LibDirBasedPathLocator implements PathLocator { - private final File[] libDirs; + private final File[] libDirs; - public LibDirBasedPathLocator(File... libDirs) { - this.libDirs = libDirs; - } + public LibDirBasedPathLocator(File... libDirs) { + this.libDirs = libDirs; + } - @Override - public URL locate(String optionalComponent, String path) { - String fullPath = PathUtils.concatPath(optionalComponent, path); + @Override + public URL locate(String optionalComponent, String path) { + String fullPath = PathUtils.concatPath(optionalComponent, path); - for (File libDir : this.libDirs) { - File libFile = new File(libDir, fullPath); - if (libFile.exists()) return toUrl(libFile); - } + for (File libDir : this.libDirs) { + File libFile = new File(libDir, fullPath); + if (libFile.exists()) return toUrl(libFile); + } - return null; - } + return null; + } - @SuppressWarnings("deprecation") - private static final URL toUrl(File file) { - try { - return file.toURL(); - } catch (MalformedURLException e) { - return null; + @SuppressWarnings("deprecation") + private static final URL toUrl(File file) { + try { + return file.toURL(); + } catch (MalformedURLException e) { + return null; + } } - } - @Override - public int hashCode() { - return Arrays.hashCode(this.libDirs); - } + @Override + public int hashCode() { + return Arrays.hashCode(this.libDirs); + } - @Override - public boolean equals(Object obj) { - if (!(obj instanceof LibDirBasedPathLocator)) return false; + @Override + public boolean equals(Object obj) { + if (!(obj instanceof LibDirBasedPathLocator)) return false; - LibDirBasedPathLocator that = (LibDirBasedPathLocator) obj; - return Arrays.equals(this.libDirs, that.libDirs); - } + LibDirBasedPathLocator that = (LibDirBasedPathLocator) obj; + return Arrays.equals(this.libDirs, that.libDirs); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/LibFile.java b/components/native-loader/src/main/java/datadog/nativeloader/LibFile.java index 80a1957e5e8..bbade5dfb94 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/LibFile.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/LibFile.java @@ -13,117 +13,108 @@ * */ public final class LibFile implements AutoCloseable { - static final boolean NO_CLEAN_UP = false; - static final boolean CLEAN_UP = true; - - static final LibFile preloaded( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - SafeLibraryLoadingListener listeners) { - return new LibFile(platformSpec, optionalComponent, libName, null, NO_CLEAN_UP, listeners); - } - - static final LibFile fromFile( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - File optionalFile, - SafeLibraryLoadingListener listeners) { - return new LibFile( - platformSpec, optionalComponent, libName, optionalFile, NO_CLEAN_UP, listeners); - } - - static final LibFile fromTempFile( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - File optionalFile, - SafeLibraryLoadingListener listeners) { - return new LibFile(platformSpec, optionalComponent, libName, optionalFile, CLEAN_UP, listeners); - } - - final PlatformSpec platformSpec; - final String optionalComponent; - final String libName; - - final File optionalFile; - final boolean needsCleanup; - - final SafeLibraryLoadingListener listeners; - - LibFile( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - File optionalFile, - boolean needsCleanup, - SafeLibraryLoadingListener listeners) { - this.platformSpec = platformSpec; - this.optionalComponent = optionalComponent; - this.libName = libName; - - this.optionalFile = optionalFile; - this.needsCleanup = needsCleanup; - - this.listeners = listeners; - } - - /** Indicates if this library was "preloaded" */ - public boolean isPreloaded() { - return (this.optionalFile == null); - } - - /** Loads the underlying library into the JVM */ - public void load() throws LibraryLoadException { - boolean isPreloaded = this.isPreloaded(); - if (isPreloaded) { - this.listeners.onLoad( - this.platformSpec, this.optionalComponent, this.libName, isPreloaded, null); - return; + static final boolean NO_CLEAN_UP = false; + static final boolean CLEAN_UP = true; + + static final LibFile preloaded( + PlatformSpec platformSpec, String optionalComponent, String libName, SafeLibraryLoadingListener listeners) { + return new LibFile(platformSpec, optionalComponent, libName, null, NO_CLEAN_UP, listeners); + } + + static final LibFile fromFile( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + File optionalFile, + SafeLibraryLoadingListener listeners) { + return new LibFile(platformSpec, optionalComponent, libName, optionalFile, NO_CLEAN_UP, listeners); + } + + static final LibFile fromTempFile( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + File optionalFile, + SafeLibraryLoadingListener listeners) { + return new LibFile(platformSpec, optionalComponent, libName, optionalFile, CLEAN_UP, listeners); } - try { - Runtime.getRuntime().load(this.getAbsolutePath()); + final PlatformSpec platformSpec; + final String optionalComponent; + final String libName; + + final File optionalFile; + final boolean needsCleanup; + + final SafeLibraryLoadingListener listeners; + + LibFile( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + File optionalFile, + boolean needsCleanup, + SafeLibraryLoadingListener listeners) { + this.platformSpec = platformSpec; + this.optionalComponent = optionalComponent; + this.libName = libName; + + this.optionalFile = optionalFile; + this.needsCleanup = needsCleanup; + + this.listeners = listeners; + } + + /** Indicates if this library was "preloaded" */ + public boolean isPreloaded() { + return (this.optionalFile == null); + } + + /** Loads the underlying library into the JVM */ + public void load() throws LibraryLoadException { + boolean isPreloaded = this.isPreloaded(); + if (isPreloaded) { + this.listeners.onLoad(this.platformSpec, this.optionalComponent, this.libName, isPreloaded, null); + return; + } + + try { + Runtime.getRuntime().load(this.getAbsolutePath()); + + if (true) throw new RuntimeException("real load - worked?"); + } catch (Throwable t) { + this.listeners.onLoadFailure(this.platformSpec, this.optionalComponent, this.libName, t); + throw new LibraryLoadException(this.libName, t); + } + + this.listeners.onLoad( + this.platformSpec, this.optionalComponent, this.libName, isPreloaded, this.optionalFile.toPath()); + } + + /** Provides a File to the library -- returns null for pre-loaded libraries */ + public final File toFile() { + return this.optionalFile; + } + + /** Provides a Path to the library -- return null for pre-loaded libraries */ + public final Path toPath() { + return this.optionalFile == null ? null : this.optionalFile.toPath(); + } - if (true) throw new RuntimeException("real load - worked?"); - } catch (Throwable t) { - this.listeners.onLoadFailure(this.platformSpec, this.optionalComponent, this.libName, t); - throw new LibraryLoadException(this.libName, t); + /** Provides the an absolute path to the library -- returns null for pre-loaded libraries */ + public final String getAbsolutePath() { + return this.optionalFile == null ? null : this.optionalFile.getAbsolutePath(); } - this.listeners.onLoad( - this.platformSpec, - this.optionalComponent, - this.libName, - isPreloaded, - this.optionalFile.toPath()); - } - - /** Provides a File to the library -- returns null for pre-loaded libraries */ - public final File toFile() { - return this.optionalFile; - } - - /** Provides a Path to the library -- return null for pre-loaded libraries */ - public final Path toPath() { - return this.optionalFile == null ? null : this.optionalFile.toPath(); - } - - /** Provides the an absolute path to the library -- returns null for pre-loaded libraries */ - public final String getAbsolutePath() { - return this.optionalFile == null ? null : this.optionalFile.getAbsolutePath(); - } - - /** Schedules clean-up of underlying optionalFile -- if the file is a temp file */ - @Override - public void close() { - if (this.needsCleanup) { - boolean deleted = NativeLoader.delete(this.optionalFile); - if (deleted) { - this.listeners.onTempFileCleanup( - this.platformSpec, this.optionalComponent, this.libName, this.optionalFile.toPath()); - } + /** Schedules clean-up of underlying optionalFile -- if the file is a temp file */ + @Override + public void close() { + if (this.needsCleanup) { + boolean deleted = NativeLoader.delete(this.optionalFile); + if (deleted) { + this.listeners.onTempFileCleanup( + this.platformSpec, this.optionalComponent, this.libName, this.optionalFile.toPath()); + } + } } - } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadException.java b/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadException.java index 12a8779958e..3ea43483ae1 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadException.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadException.java @@ -2,28 +2,28 @@ /** Exception raised when NativeLoader fails to resolve or load a library */ public class LibraryLoadException extends Exception { - static final String UNSUPPORTED_OS = "Unsupported OS"; - static final String UNSUPPORTED_ARCH = "Unsupported arch"; + static final String UNSUPPORTED_OS = "Unsupported OS"; + static final String UNSUPPORTED_ARCH = "Unsupported arch"; - private static final long serialVersionUID = 1L; + private static final long serialVersionUID = 1L; - public LibraryLoadException(String libName) { - super(message(libName)); - } + public LibraryLoadException(String libName) { + super(message(libName)); + } - public LibraryLoadException(String libName, Throwable cause) { - this(message(libName), cause.getMessage(), cause); - } + public LibraryLoadException(String libName, Throwable cause) { + this(message(libName), cause.getMessage(), cause); + } - public LibraryLoadException(String libName, String message) { - super(message(libName) + " - " + message); - } + public LibraryLoadException(String libName, String message) { + super(message(libName) + " - " + message); + } - public LibraryLoadException(String libName, String message, Throwable cause) { - super(message(libName) + " - " + message, cause); - } + public LibraryLoadException(String libName, String message, Throwable cause) { + super(message(libName) + " - " + message, cause); + } - static final String message(String libName) { - return "Unable to resolve library " + libName; - } + static final String message(String libName) { + return "Unable to resolve library " + libName; + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadingListener.java b/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadingListener.java index 8fa68a4feb0..6be889a1c3d 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadingListener.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/LibraryLoadingListener.java @@ -4,63 +4,57 @@ import java.nio.file.Path; public interface LibraryLoadingListener { - /** - * Called when a dynamic library is resolved. This includes resolving a pre-loaded or already - * loaded library - * - *

If the library is pre-loaded optionalUrl will be null - */ - default void onResolveDynamic( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - URL optionalUrl) {} + /** + * Called when a dynamic library is resolved. This includes resolving a pre-loaded or already + * loaded library + * + *

If the library is pre-loaded optionalUrl will be null + */ + default void onResolveDynamic( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + URL optionalUrl) {} - /** - * Called when a dynamic library fails to resolve. This can occur because the library was not - * found -- or an exception occurred during resolution - */ - default void onResolveDynamicFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) {} + /** + * Called when a dynamic library fails to resolve. This can occur because the library was not + * found -- or an exception occurred during resolution + */ + default void onResolveDynamicFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) {} - /** - * Called when a dynamic library loads successfully This includes loading a pre-loaded or already - * loaded library - */ - default void onLoad( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - Path optionalLibPath) {} + /** + * Called when a dynamic library loads successfully This includes loading a pre-loaded or already + * loaded library + */ + default void onLoad( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + Path optionalLibPath) {} - /** Called when a dynamic library fails to load */ - default void onLoadFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) {} + /** Called when a dynamic library fails to load */ + default void onLoadFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) {} - /** Called when a temp file is successfully created to hold the library */ - default void onTempFileCreated( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) {} + /** Called when a temp file is successfully created to hold the library */ + default void onTempFileCreated( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) {} - /** Called when a temp file could not be created */ - default void onTempFileCreationFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Path tempDir, - String libExt, - Throwable optionalCause) {} + /** Called when a temp file could not be created */ + default void onTempFileCreationFailure( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + Path tempDir, + String libExt, + Throwable optionalCause) {} - /** Called when a temp file is cleaned up */ - default void onTempFileCleanup( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) {} + /** Called when a temp file is cleaned up */ + default void onTempFileCleanup( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) {} } /** @@ -70,9 +64,9 @@ default void onTempFileCleanup( * listeners and {@link NopLibraryLoadingListener} used to optimize the nop case. */ abstract class SafeLibraryLoadingListener implements LibraryLoadingListener { - /** Used to create a new safe listener with the provided listeners append onto this one */ - public abstract SafeLibraryLoadingListener join(LibraryLoadingListener... listeners); + /** Used to create a new safe listener with the provided listeners append onto this one */ + public abstract SafeLibraryLoadingListener join(LibraryLoadingListener... listeners); - /** Indicates if all listener operates are nops */ - public abstract boolean isNop(); + /** Indicates if all listener operates are nops */ + public abstract boolean isNop(); } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolver.java b/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolver.java index 66ab56c0169..34dd6303e79 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolver.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolver.java @@ -11,11 +11,10 @@ */ @FunctionalInterface public interface LibraryResolver { - default boolean isPreloaded(PlatformSpec platform, String libName) { - return false; - } + default boolean isPreloaded(PlatformSpec platform, String libName) { + return false; + } - URL resolve( - PathLocator pathLocator, PlatformSpec platformSpec, String optionalComponent, String libName) - throws Exception; + URL resolve(PathLocator pathLocator, PlatformSpec platformSpec, String optionalComponent, String libName) + throws Exception; } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolvers.java b/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolvers.java index bcbb434a3a1..77eb746950a 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolvers.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/LibraryResolvers.java @@ -6,42 +6,37 @@ import java.util.Set; public final class LibraryResolvers { - private LibraryResolvers() {} - - public static final LibraryResolver defaultLibraryResolver() { - return flatDirs(); - } - - public static final LibraryResolver withPreloaded( - LibraryResolver baseResolver, String... preloadedLibNames) { - return withPreloaded(baseResolver, new HashSet<>(Arrays.asList(preloadedLibNames))); - } - - public static final LibraryResolver withPreloaded( - LibraryResolver baseResolver, Set preloadedLibNames) { - return new LibraryResolver() { - @Override - public boolean isPreloaded(PlatformSpec platform, String libName) { - return preloadedLibNames.contains(libName); - } - - @Override - public URL resolve( - PathLocator pathLocator, - PlatformSpec platformSpec, - String optionalComponent, - String libName) - throws Exception { - return baseResolver.resolve(pathLocator, platformSpec, optionalComponent, libName); - } - }; - } - - public static final LibraryResolver flatDirs() { - return FlatDirLibraryResolver.INSTANCE; - } - - public static final LibraryResolver nestedDirs() { - return NestedDirLibraryResolver.INSTANCE; - } + private LibraryResolvers() {} + + public static final LibraryResolver defaultLibraryResolver() { + return flatDirs(); + } + + public static final LibraryResolver withPreloaded(LibraryResolver baseResolver, String... preloadedLibNames) { + return withPreloaded(baseResolver, new HashSet<>(Arrays.asList(preloadedLibNames))); + } + + public static final LibraryResolver withPreloaded(LibraryResolver baseResolver, Set preloadedLibNames) { + return new LibraryResolver() { + @Override + public boolean isPreloaded(PlatformSpec platform, String libName) { + return preloadedLibNames.contains(libName); + } + + @Override + public URL resolve( + PathLocator pathLocator, PlatformSpec platformSpec, String optionalComponent, String libName) + throws Exception { + return baseResolver.resolve(pathLocator, platformSpec, optionalComponent, libName); + } + }; + } + + public static final LibraryResolver flatDirs() { + return FlatDirLibraryResolver.INSTANCE; + } + + public static final LibraryResolver nestedDirs() { + return NestedDirLibraryResolver.INSTANCE; + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java b/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java index 8a55eabe570..ae1d7e99edf 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java @@ -23,380 +23,370 @@ * LibraryResolver} and path resolution {@link PathLocator} */ public final class NativeLoader { - public static final class Builder { - private PlatformSpec platformSpec; - private Path tempDir; - private String[] preloadedLibNames; - private LibraryResolver libResolver; - private PathLocator pathLocator; - private List listeners = new ArrayList<>(); - - Builder() {} - - /** Sets the default {@link PlatformSpec} used by the {@link NativeLoader} */ - public Builder platformSpec(PlatformSpec platform) { - this.platformSpec = platform; - return this; - } + public static final class Builder { + private PlatformSpec platformSpec; + private Path tempDir; + private String[] preloadedLibNames; + private LibraryResolver libResolver; + private PathLocator pathLocator; + private List listeners = new ArrayList<>(); + + Builder() {} + + /** Sets the default {@link PlatformSpec} used by the {@link NativeLoader} */ + public Builder platformSpec(PlatformSpec platform) { + this.platformSpec = platform; + return this; + } + + /** Uses a nested directory layout -- {@link LibraryResolvers#nestedDirs()} */ + public Builder nestedLayout() { + return this.libResolver(LibraryResolvers.nestedDirs()); + } + + /** Uses a flat directory layout -- {@link LibraryResolvers#flatDirs()} */ + public Builder flatLayout() { + return this.libResolver(LibraryResolvers.flatDirs()); + } + + /** + * Indicates that libraries (or signatures from those libraries) are already loaded into the JVM + * {@link LibraryResolver#isPreloaded} + * + * @param libNames - lib names + */ + public Builder preloaded(String... libNames) { + this.preloadedLibNames = libNames; + return this; + } + + /** + * Uses the specified {@link LibraryResolver} can be used to implement an alternate file layout + */ + public Builder libResolver(LibraryResolver libResolver) { + this.libResolver = libResolver; + return this; + } + + /** Searches for the native libraries in the provided {@link ClassLoader} */ + public Builder fromClassLoader(ClassLoader classLoader) { + return this.pathLocator(PathLocators.fromClassLoader(classLoader)); + } + + /** + * Searches for the native libraries in the provided {@link ClassLoader} using the specified + * baseResource + */ + public Builder fromClassLoader(ClassLoader classLoader, String baseResource) { + return this.pathLocator(PathLocators.fromClassLoader(classLoader, baseResource)); + } + + /** Searches for the native libraries in the specified directory */ + public Builder fromDir(String includeDir) { + return this.pathLocator(PathLocators.fromLibDirs(includeDir)); + } + + /** Searches for the native libraries in the specified directories */ + public Builder fromDirs(String... includeDirs) { + return this.pathLocator(PathLocators.fromLibDirs(includeDirs)); + } + + /** Searches for the native libraries in the specified directory */ + public Builder fromDir(File includeDir) { + return this.pathLocator(PathLocators.fromLibDirs(includeDir)); + } + + /** Searches for the native libraries in the specified directories */ + public Builder fromDirs(File... includeDirs) { + return this.pathLocator(PathLocators.fromLibDirs(includeDirs)); + } + + /** Searches for the native libraries in the specified directory */ + public Builder fromDir(Path includeDir) { + return this.pathLocator(PathLocators.fromLibDirs(includeDir)); + } + + /** Searches for the native libraries in the specified directories */ + public Builder fromDirs(Path... paths) { + return this.pathLocator(PathLocators.fromLibDirs(paths)); + } + + /** Searches for the native libraries using the provided {@link PathLocator} */ + public Builder pathLocator(PathLocator pathLocator) { + this.pathLocator = pathLocator; + return this; + } + + /** + * Specifies temporary directory where native libraries are copied if the {@link PathLocator} + * returns a non-file {@link URL} + */ + public Builder tempDir(File tempDir) { + return this.tempDir(tempDir.toPath()); + } + + /** + * Specifies temporary directory where native libraries are copied if the {@link PathLocator} + * returns a non-file {@link URL} + */ + public Builder tempDir(Path tempPath) { + this.tempDir = tempPath; + return this; + } + + /** + * Specifies temporary directory where native libraries are copied if the {@link PathLocator} + * returns a non-file {@link URL} + */ + public Builder tempDir(String tmpPath) { + return this.tempDir(Paths.get(tmpPath)); + } + + public Builder addListener(LibraryLoadingListener listener) { + this.listeners.add(listener); + return this; + } + + public Builder addListeners(LibraryLoadingListener... listeners) { + this.listeners.addAll(Arrays.asList(listeners)); + return this; + } + + /** Constructs and returns the {@link NativeLoader} */ + public NativeLoader build() { + return new NativeLoader(this); + } + + PlatformSpec platformSpec() { + return (this.platformSpec == null) ? PlatformSpec.defaultPlatformSpec() : this.platformSpec; + } - /** Uses a nested directory layout -- {@link LibraryResolvers#nestedDirs()} */ - public Builder nestedLayout() { - return this.libResolver(LibraryResolvers.nestedDirs()); - } + PathLocator pathLocator() { + return (this.pathLocator == null) ? PathLocators.defaultPathLocator() : this.pathLocator; + } - /** Uses a flat directory layout -- {@link LibraryResolvers#flatDirs()} */ - public Builder flatLayout() { - return this.libResolver(LibraryResolvers.flatDirs()); - } + SafeLibraryLoadingListener listeners() { + return this.listeners.isEmpty() + ? NopLibraryLoadingListener.INSTANCE + : new CompositeLibraryLoadingListener(this.listeners); + } - /** - * Indicates that libraries (or signatures from those libraries) are already loaded into the JVM - * {@link LibraryResolver#isPreloaded} - * - * @param libNames - lib names - */ - public Builder preloaded(String... libNames) { - this.preloadedLibNames = libNames; - return this; - } + LibraryResolver libResolver() { + LibraryResolver baseResolver = + (this.libResolver == null) ? LibraryResolvers.defaultLibraryResolver() : this.libResolver; - /** - * Uses the specified {@link LibraryResolver} can be used to implement an alternate file layout - */ - public Builder libResolver(LibraryResolver libResolver) { - this.libResolver = libResolver; - return this; - } + return (this.preloadedLibNames == null) + ? baseResolver + : LibraryResolvers.withPreloaded(baseResolver, this.preloadedLibNames); + } - /** Searches for the native libraries in the provided {@link ClassLoader} */ - public Builder fromClassLoader(ClassLoader classLoader) { - return this.pathLocator(PathLocators.fromClassLoader(classLoader)); + Path tempDir() { + return this.tempDir; + } } - /** - * Searches for the native libraries in the provided {@link ClassLoader} using the specified - * baseResource - */ - public Builder fromClassLoader(ClassLoader classLoader, String baseResource) { - return this.pathLocator(PathLocators.fromClassLoader(classLoader, baseResource)); + public static final Builder builder() { + return new Builder(); } - /** Searches for the native libraries in the specified directory */ - public Builder fromDir(String includeDir) { - return this.pathLocator(PathLocators.fromLibDirs(includeDir)); - } + private static final URL NO_URL = null; + private static final Throwable NO_CAUSE = null; + private static final LibraryLoadingListener[] EMPTY_LISTENERS = {}; - /** Searches for the native libraries in the specified directories */ - public Builder fromDirs(String... includeDirs) { - return this.pathLocator(PathLocators.fromLibDirs(includeDirs)); - } + private final PlatformSpec defaultPlatformSpec; + private final LibraryResolver libResolver; + private final PathLocator pathResolver; + private final SafeLibraryLoadingListener listeners; + private final Path tempDir; - /** Searches for the native libraries in the specified directory */ - public Builder fromDir(File includeDir) { - return this.pathLocator(PathLocators.fromLibDirs(includeDir)); + private NativeLoader(Builder builder) { + this.defaultPlatformSpec = builder.platformSpec(); + this.libResolver = builder.libResolver(); + this.pathResolver = builder.pathLocator(); + this.listeners = builder.listeners(); + this.tempDir = builder.tempDir(); } - /** Searches for the native libraries in the specified directories */ - public Builder fromDirs(File... includeDirs) { - return this.pathLocator(PathLocators.fromLibDirs(includeDirs)); + public boolean isPlatformSupported() { + if (this.defaultPlatformSpec.isUnknownOs()) return false; + if (this.defaultPlatformSpec.isUnknownArch()) return false; + return true; } - /** Searches for the native libraries in the specified directory */ - public Builder fromDir(Path includeDir) { - return this.pathLocator(PathLocators.fromLibDirs(includeDir)); + /** Indicates if a library is considered "pre-loaded" */ + public boolean isPreloaded(String libName) { + return this.libResolver.isPreloaded(this.defaultPlatformSpec, libName); } - /** Searches for the native libraries in the specified directories */ - public Builder fromDirs(Path... paths) { - return this.pathLocator(PathLocators.fromLibDirs(paths)); + /** Indicates if a library is considered "pre-loaded" for the specified {@link PlatformSpec} */ + public boolean isPreloaded(PlatformSpec platformSpec, String libName) { + return this.libResolver.isPreloaded(platformSpec, libName); } - /** Searches for the native libraries using the provided {@link PathLocator} */ - public Builder pathLocator(PathLocator pathLocator) { - this.pathLocator = pathLocator; - return this; + /** Loads a library */ + public void load(String libName) throws LibraryLoadException { + this.loadImpl(null, libName, EMPTY_LISTENERS); } - /** - * Specifies temporary directory where native libraries are copied if the {@link PathLocator} - * returns a non-file {@link URL} - */ - public Builder tempDir(File tempDir) { - return this.tempDir(tempDir.toPath()); + public void load(String libName, LibraryLoadingListener... scopedListeners) throws LibraryLoadException { + this.loadImpl(null, libName, scopedListeners); } - /** - * Specifies temporary directory where native libraries are copied if the {@link PathLocator} - * returns a non-file {@link URL} - */ - public Builder tempDir(Path tempPath) { - this.tempDir = tempPath; - return this; + /** Loads a library associated with an associated component */ + public void load(String component, String libName) throws LibraryLoadException { + this.loadImpl(component, libName, EMPTY_LISTENERS); } - /** - * Specifies temporary directory where native libraries are copied if the {@link PathLocator} - * returns a non-file {@link URL} - */ - public Builder tempDir(String tmpPath) { - return this.tempDir(Paths.get(tmpPath)); + public void load(String component, String libName, LibraryLoadingListener... scopedListeners) + throws LibraryLoadException { + this.loadImpl(component, libName, scopedListeners); } - public Builder addListener(LibraryLoadingListener listener) { - this.listeners.add(listener); - return this; - } + private void loadImpl(String component, String libName, LibraryLoadingListener... scopedListeners) + throws LibraryLoadException { - public Builder addListeners(LibraryLoadingListener... listeners) { - this.listeners.addAll(Arrays.asList(listeners)); - return this; + // scopedListeners are attached to the LibFile by resolveDynamicImpl + try (LibFile libFile = this.resolveDynamicImpl(this.defaultPlatformSpec, component, libName, scopedListeners)) { + libFile.load(); + } } - /** Constructs and returns the {@link NativeLoader} */ - public NativeLoader build() { - return new NativeLoader(this); + /** Resolves a library to a LibFile - creating a temporary file if necessary */ + public LibFile resolveDynamic(String libName) throws LibraryLoadException { + return this.resolveDynamicImpl(this.defaultPlatformSpec, null, libName, EMPTY_LISTENERS); } - PlatformSpec platformSpec() { - return (this.platformSpec == null) ? PlatformSpec.defaultPlatformSpec() : this.platformSpec; + public LibFile resolveDynamic(String libName, LibraryLoadingListener... scopedListeners) + throws LibraryLoadException { + return this.resolveDynamicImpl(this.defaultPlatformSpec, null, libName, scopedListeners); } - PathLocator pathLocator() { - return (this.pathLocator == null) ? PathLocators.defaultPathLocator() : this.pathLocator; + /** Resolves a library with an associated component */ + public LibFile resolveDynamic(String component, String libName) throws LibraryLoadException { + return this.resolveDynamicImpl(this.defaultPlatformSpec, component, libName, EMPTY_LISTENERS); } - SafeLibraryLoadingListener listeners() { - return this.listeners.isEmpty() - ? NopLibraryLoadingListener.INSTANCE - : new CompositeLibraryLoadingListener(this.listeners); - } - - LibraryResolver libResolver() { - LibraryResolver baseResolver = - (this.libResolver == null) ? LibraryResolvers.defaultLibraryResolver() : this.libResolver; - - return (this.preloadedLibNames == null) - ? baseResolver - : LibraryResolvers.withPreloaded(baseResolver, this.preloadedLibNames); - } - - Path tempDir() { - return this.tempDir; - } - } - - public static final Builder builder() { - return new Builder(); - } - - private static final URL NO_URL = null; - private static final Throwable NO_CAUSE = null; - private static final LibraryLoadingListener[] EMPTY_LISTENERS = {}; - - private final PlatformSpec defaultPlatformSpec; - private final LibraryResolver libResolver; - private final PathLocator pathResolver; - private final SafeLibraryLoadingListener listeners; - private final Path tempDir; - - private NativeLoader(Builder builder) { - this.defaultPlatformSpec = builder.platformSpec(); - this.libResolver = builder.libResolver(); - this.pathResolver = builder.pathLocator(); - this.listeners = builder.listeners(); - this.tempDir = builder.tempDir(); - } - - public boolean isPlatformSupported() { - if (this.defaultPlatformSpec.isUnknownOs()) return false; - if (this.defaultPlatformSpec.isUnknownArch()) return false; - return true; - } - - /** Indicates if a library is considered "pre-loaded" */ - public boolean isPreloaded(String libName) { - return this.libResolver.isPreloaded(this.defaultPlatformSpec, libName); - } - - /** Indicates if a library is considered "pre-loaded" for the specified {@link PlatformSpec} */ - public boolean isPreloaded(PlatformSpec platformSpec, String libName) { - return this.libResolver.isPreloaded(platformSpec, libName); - } - - /** Loads a library */ - public void load(String libName) throws LibraryLoadException { - this.loadImpl(null, libName, EMPTY_LISTENERS); - } - - public void load(String libName, LibraryLoadingListener... scopedListeners) - throws LibraryLoadException { - this.loadImpl(null, libName, scopedListeners); - } - - /** Loads a library associated with an associated component */ - public void load(String component, String libName) throws LibraryLoadException { - this.loadImpl(component, libName, EMPTY_LISTENERS); - } - - public void load(String component, String libName, LibraryLoadingListener... scopedListeners) - throws LibraryLoadException { - this.loadImpl(component, libName, scopedListeners); - } - - private void loadImpl(String component, String libName, LibraryLoadingListener... scopedListeners) - throws LibraryLoadException { - - // scopedListeners are attached to the LibFile by resolveDynamicImpl - try (LibFile libFile = - this.resolveDynamicImpl(this.defaultPlatformSpec, component, libName, scopedListeners)) { - libFile.load(); - } - } - - /** Resolves a library to a LibFile - creating a temporary file if necessary */ - public LibFile resolveDynamic(String libName) throws LibraryLoadException { - return this.resolveDynamicImpl(this.defaultPlatformSpec, null, libName, EMPTY_LISTENERS); - } - - public LibFile resolveDynamic(String libName, LibraryLoadingListener... scopedListeners) - throws LibraryLoadException { - return this.resolveDynamicImpl(this.defaultPlatformSpec, null, libName, scopedListeners); - } - - /** Resolves a library with an associated component */ - public LibFile resolveDynamic(String component, String libName) throws LibraryLoadException { - return this.resolveDynamicImpl(this.defaultPlatformSpec, component, libName, EMPTY_LISTENERS); - } - - /** - * Resolves a library using a different {@link PlatformSpec} than the default for this {@link - * NativeLoader} - */ - public LibFile resolveDynamic(PlatformSpec platformSpec, String libName) - throws LibraryLoadException { - return this.resolveDynamicImpl(platformSpec, null, libName, EMPTY_LISTENERS); - } - - public LibFile resolveDynamic( - PlatformSpec platformSpec, String libName, LibraryLoadingListener... scopedListeners) - throws LibraryLoadException { - return this.resolveDynamicImpl(platformSpec, null, libName, scopedListeners); - } - - /** - * Resolves a library with an associated component with a different {@link PlatformSpec} than the - * default - */ - public LibFile resolveDynamic(PlatformSpec platformSpec, String component, String libName) - throws LibraryLoadException { - return this.resolveDynamicImpl(platformSpec, component, libName, EMPTY_LISTENERS); - } - - private LibFile resolveDynamicImpl( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - LibraryLoadingListener[] scopedListeners) - throws LibraryLoadException { - SafeLibraryLoadingListener allListeners = - (scopedListeners == null - || scopedListeners == EMPTY_LISTENERS - || scopedListeners.length == 0) - ? this.listeners - : this.listeners.join(scopedListeners); - - if (platformSpec.isUnknownOs() || platformSpec.isUnknownArch()) { - allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, NO_CAUSE); - throw new LibraryLoadException(libName, "Unsupported platform"); - } - - boolean isPreloaded = this.isPreloaded(platformSpec, libName); - if (isPreloaded) { - allListeners.onResolveDynamic(platformSpec, optionalComponent, libName, isPreloaded, NO_URL); - return LibFile.preloaded(platformSpec, optionalComponent, libName, allListeners); - } - - URL url; - try { - url = this.libResolver.resolve(this.pathResolver, platformSpec, optionalComponent, libName); - } catch (LibraryLoadException e) { - // don't wrap if it is already a LibraryLoadException - allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, e.getCause()); - throw e; - } catch (Throwable t) { - allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, t); - throw new LibraryLoadException(libName, t); + /** + * Resolves a library using a different {@link PlatformSpec} than the default for this {@link + * NativeLoader} + */ + public LibFile resolveDynamic(PlatformSpec platformSpec, String libName) throws LibraryLoadException { + return this.resolveDynamicImpl(platformSpec, null, libName, EMPTY_LISTENERS); } - if (url == null) { - allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, NO_CAUSE); - throw new LibraryLoadException(libName); + public LibFile resolveDynamic(PlatformSpec platformSpec, String libName, LibraryLoadingListener... scopedListeners) + throws LibraryLoadException { + return this.resolveDynamicImpl(platformSpec, null, libName, scopedListeners); } - // For listener purposes - at this point resolution completed successfully - // Although, the resolveDynamic method can still fail if we need a temp file and cannot create - // it - allListeners.onResolveDynamic(platformSpec, optionalComponent, libName, isPreloaded, url); - - if (url.getProtocol().equals("file")) { - return LibFile.fromFile( - platformSpec, optionalComponent, libName, new File(url.getPath()), allListeners); - } else { - Path tempFile; - try { - tempFile = createTempFile(this.tempDir, platformSpec, libName, url); - allListeners.onTempFileCreated(platformSpec, optionalComponent, libName, tempFile); - - return LibFile.fromTempFile( - platformSpec, optionalComponent, libName, tempFile.toFile(), allListeners); - } catch (Throwable t) { - allListeners.onTempFileCreationFailure( - platformSpec, optionalComponent, libName, this.tempDir, libName, t); - - throw new LibraryLoadException(libName, t); - } + /** + * Resolves a library with an associated component with a different {@link PlatformSpec} than the + * default + */ + public LibFile resolveDynamic(PlatformSpec platformSpec, String component, String libName) + throws LibraryLoadException { + return this.resolveDynamicImpl(platformSpec, component, libName, EMPTY_LISTENERS); + } + + private LibFile resolveDynamicImpl( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + LibraryLoadingListener[] scopedListeners) + throws LibraryLoadException { + SafeLibraryLoadingListener allListeners = + (scopedListeners == null || scopedListeners == EMPTY_LISTENERS || scopedListeners.length == 0) + ? this.listeners + : this.listeners.join(scopedListeners); + + if (platformSpec.isUnknownOs() || platformSpec.isUnknownArch()) { + allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, NO_CAUSE); + throw new LibraryLoadException(libName, "Unsupported platform"); + } + + boolean isPreloaded = this.isPreloaded(platformSpec, libName); + if (isPreloaded) { + allListeners.onResolveDynamic(platformSpec, optionalComponent, libName, isPreloaded, NO_URL); + return LibFile.preloaded(platformSpec, optionalComponent, libName, allListeners); + } + + URL url; + try { + url = this.libResolver.resolve(this.pathResolver, platformSpec, optionalComponent, libName); + } catch (LibraryLoadException e) { + // don't wrap if it is already a LibraryLoadException + allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, e.getCause()); + throw e; + } catch (Throwable t) { + allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, t); + throw new LibraryLoadException(libName, t); + } + + if (url == null) { + allListeners.onResolveDynamicFailure(platformSpec, optionalComponent, libName, NO_CAUSE); + throw new LibraryLoadException(libName); + } + + // For listener purposes - at this point resolution completed successfully + // Although, the resolveDynamic method can still fail if we need a temp file and cannot create + // it + allListeners.onResolveDynamic(platformSpec, optionalComponent, libName, isPreloaded, url); + + if (url.getProtocol().equals("file")) { + return LibFile.fromFile(platformSpec, optionalComponent, libName, new File(url.getPath()), allListeners); + } else { + Path tempFile; + try { + tempFile = createTempFile(this.tempDir, platformSpec, libName, url); + allListeners.onTempFileCreated(platformSpec, optionalComponent, libName, tempFile); + + return LibFile.fromTempFile(platformSpec, optionalComponent, libName, tempFile.toFile(), allListeners); + } catch (Throwable t) { + allListeners.onTempFileCreationFailure( + platformSpec, optionalComponent, libName, this.tempDir, libName, t); + + throw new LibraryLoadException(libName, t); + } + } + } + + private static Path createTempFile(Path tempDir, PlatformSpec platformSpec, String libName, URL url) + throws IOException { + String libExt = PathUtils.dynamicLibExtension(platformSpec); + + Path tempFile = TempFileHelper.createTempFile(tempDir, libName, libExt); + try (InputStream in = url.openStream()) { + Files.copy(in, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + + return tempFile; } - } - - private static Path createTempFile( - Path tempDir, PlatformSpec platformSpec, String libName, URL url) throws IOException { - String libExt = PathUtils.dynamicLibExtension(platformSpec); - Path tempFile = TempFileHelper.createTempFile(tempDir, libName, libExt); - try (InputStream in = url.openStream()) { - Files.copy(in, tempFile, StandardCopyOption.REPLACE_EXISTING); + static boolean delete(File tempFile) { + return TempFileHelper.delete(tempFile); } - return tempFile; - } - - static boolean delete(File tempFile) { - return TempFileHelper.delete(tempFile); - } + static final class TempFileHelper { + private TempFileHelper() {} - static final class TempFileHelper { - private TempFileHelper() {} + static Path createTempFile(Path tempDir, String libname, String libExt) throws IOException, SecurityException { + FileAttribute> permAttrs = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); - static Path createTempFile(Path tempDir, String libname, String libExt) - throws IOException, SecurityException { - FileAttribute> permAttrs = - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); + if (tempDir == null) { + return Files.createTempFile(libname, "." + libExt, permAttrs); + } else { + Files.createDirectories( + tempDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - if (tempDir == null) { - return Files.createTempFile(libname, "." + libExt, permAttrs); - } else { - Files.createDirectories( - tempDir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + return Files.createTempFile(tempDir, libname, "." + libExt, permAttrs); + } + } - return Files.createTempFile(tempDir, libname, "." + libExt, permAttrs); - } - } - - static boolean delete(File tempFile) { - boolean deleted = tempFile.delete(); - if (!deleted) tempFile.deleteOnExit(); - return deleted; + static boolean delete(File tempFile) { + boolean deleted = tempFile.delete(); + if (!deleted) tempFile.deleteOnExit(); + return deleted; + } } - } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/NestedDirLibraryResolver.java b/components/native-loader/src/main/java/datadog/nativeloader/NestedDirLibraryResolver.java index 0137ca1d07e..f57eed6e37d 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/NestedDirLibraryResolver.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/NestedDirLibraryResolver.java @@ -7,48 +7,48 @@ * {os} / {arch} / {libc} */ final class NestedDirLibraryResolver implements LibraryResolver { - public static final NestedDirLibraryResolver INSTANCE = new NestedDirLibraryResolver(); + public static final NestedDirLibraryResolver INSTANCE = new NestedDirLibraryResolver(); - @Override - public final URL resolve( - PathLocator pathLocator, PlatformSpec platformSpec, String optionalComponent, String libName) - throws Exception { - PathLocatorHelper pathLocatorHelper = new PathLocatorHelper(libName, pathLocator); + @Override + public final URL resolve( + PathLocator pathLocator, PlatformSpec platformSpec, String optionalComponent, String libName) + throws Exception { + PathLocatorHelper pathLocatorHelper = new PathLocatorHelper(libName, pathLocator); - String libFileName = PathUtils.libFileName(platformSpec, libName); + String libFileName = PathUtils.libFileName(platformSpec, libName); - String osPath = PathUtils.osPartOf(platformSpec); - String archPath = PathUtils.archPartOf(platformSpec); + String osPath = PathUtils.osPartOf(platformSpec); + String archPath = PathUtils.archPartOf(platformSpec); - String libcPath = PathUtils.libcPartOf(platformSpec); + String libcPath = PathUtils.libcPartOf(platformSpec); - URL url; - String regularPath = osPath + "/" + archPath; + URL url; + String regularPath = osPath + "/" + archPath; - if (libcPath != null) { - String specializedPath = regularPath + "/" + libcPath; - url = pathLocatorHelper.locate(optionalComponent, specializedPath + "/" + libFileName); - if (url != null) return url; - } + if (libcPath != null) { + String specializedPath = regularPath + "/" + libcPath; + url = pathLocatorHelper.locate(optionalComponent, specializedPath + "/" + libFileName); + if (url != null) return url; + } - url = pathLocatorHelper.locate(optionalComponent, regularPath + "/" + libFileName); - if (url != null) return url; + url = pathLocatorHelper.locate(optionalComponent, regularPath + "/" + libFileName); + if (url != null) return url; - url = pathLocatorHelper.locate(optionalComponent, osPath + "/" + libFileName); - if (url != null) return url; + url = pathLocatorHelper.locate(optionalComponent, osPath + "/" + libFileName); + if (url != null) return url; - // fallback to searching at top-level, mostly concession to good out-of-box behavior - // with java.library.path - url = pathLocatorHelper.locate(optionalComponent, libFileName); - if (url != null) return url; + // fallback to searching at top-level, mostly concession to good out-of-box behavior + // with java.library.path + url = pathLocatorHelper.locate(optionalComponent, libFileName); + if (url != null) return url; - if (optionalComponent != null) { - url = pathLocatorHelper.locate(null, libFileName); - if (url != null) return url; - } + if (optionalComponent != null) { + url = pathLocatorHelper.locate(null, libFileName); + if (url != null) return url; + } - pathLocatorHelper.tryThrow(); + pathLocatorHelper.tryThrow(); - return null; - } + return null; + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/NopLibraryLoadingListener.java b/components/native-loader/src/main/java/datadog/nativeloader/NopLibraryLoadingListener.java index afec8dc1b1f..2cb7b6aa5e8 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/NopLibraryLoadingListener.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/NopLibraryLoadingListener.java @@ -3,17 +3,17 @@ import java.util.Arrays; final class NopLibraryLoadingListener extends SafeLibraryLoadingListener { - static final NopLibraryLoadingListener INSTANCE = new NopLibraryLoadingListener(); + static final NopLibraryLoadingListener INSTANCE = new NopLibraryLoadingListener(); - private NopLibraryLoadingListener() {} + private NopLibraryLoadingListener() {} - @Override - public boolean isNop() { - return true; - } + @Override + public boolean isNop() { + return true; + } - @Override - public SafeLibraryLoadingListener join(LibraryLoadingListener... listeners) { - return new CompositeLibraryLoadingListener(Arrays.asList(listeners)); - } + @Override + public SafeLibraryLoadingListener join(LibraryLoadingListener... listeners) { + return new CompositeLibraryLoadingListener(Arrays.asList(listeners)); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/PathLocator.java b/components/native-loader/src/main/java/datadog/nativeloader/PathLocator.java index aae3c2e0b5a..a181e937e0a 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/PathLocator.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/PathLocator.java @@ -5,14 +5,14 @@ /** Resolves a component / path pair to a {@link URL} - called by a {@link LibraryResolver} */ @FunctionalInterface public interface PathLocator { - /** - * URL to the requested resource -- or null if the resource could not be found - * - *

If the returned URL uses file protocol, then {@link NativeLoader} will provide direct access - * to the file - * - *

If the returned URL uses a non-file protocol, then {@link NativeLoader} will call {@link - * URL#openStream()} and copy the contents to a temporary file - */ - URL locate(String optionalComponent, String path) throws Exception; + /** + * URL to the requested resource -- or null if the resource could not be found + * + *

If the returned URL uses file protocol, then {@link NativeLoader} will provide direct access + * to the file + * + *

If the returned URL uses a non-file protocol, then {@link NativeLoader} will call {@link + * URL#openStream()} and copy the contents to a temporary file + */ + URL locate(String optionalComponent, String path) throws Exception; } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/PathLocatorHelper.java b/components/native-loader/src/main/java/datadog/nativeloader/PathLocatorHelper.java index 8842d642afb..d861d4048b1 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/PathLocatorHelper.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/PathLocatorHelper.java @@ -7,32 +7,32 @@ * be used to wrap another {@link PathLocator} and make the exception handling easier. */ public final class PathLocatorHelper implements PathLocator { - final String libName; - final PathLocator locator; + final String libName; + final PathLocator locator; - private Throwable firstCause; + private Throwable firstCause; - public PathLocatorHelper(String libName, PathLocator locator) { - this.libName = libName; - this.locator = locator; - } + public PathLocatorHelper(String libName, PathLocator locator) { + this.libName = libName; + this.locator = locator; + } - @Override - public URL locate(String optionalComponent, String path) { - try { - return this.locator.locate(optionalComponent, path); - } catch (Throwable t) { - if (this.firstCause == null) this.firstCause = t; - return null; + @Override + public URL locate(String optionalComponent, String path) { + try { + return this.locator.locate(optionalComponent, path); + } catch (Throwable t) { + if (this.firstCause == null) this.firstCause = t; + return null; + } } - } - /** Raises a LibraryLoadException if an exception occurred during a prior call to locate */ - public void tryThrow() throws LibraryLoadException { - if (this.firstCause instanceof LibraryLoadException) { - throw (LibraryLoadException) this.firstCause; - } else if (this.firstCause != null) { - throw new LibraryLoadException(this.libName, this.firstCause); + /** Raises a LibraryLoadException if an exception occurred during a prior call to locate */ + public void tryThrow() throws LibraryLoadException { + if (this.firstCause instanceof LibraryLoadException) { + throw (LibraryLoadException) this.firstCause; + } else if (this.firstCause != null) { + throw new LibraryLoadException(this.libName, this.firstCause); + } } - } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/PathLocators.java b/components/native-loader/src/main/java/datadog/nativeloader/PathLocators.java index f3b368f98ea..c336e772b64 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/PathLocators.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/PathLocators.java @@ -6,53 +6,53 @@ /** Helper factory class for common {@link PathLocator} */ public final class PathLocators { - private PathLocators() {} - - public static final PathLocator defaultPathLocator() { - return fromJavaLibraryPath(); - } - - public static final PathLocator fromLibDirs(Path... libDirs) { - File[] libDirFiles = new File[libDirs.length]; - for (int i = 0; i < libDirs.length; ++i) { - libDirFiles[i] = libDirs[i].toFile(); - } - return fromLibDirs(libDirFiles); - } - - public static final PathLocator fromLibDirs(String... libDirs) { - File[] libDirFiles = new File[libDirs.length]; - for (int i = 0; i < libDirs.length; ++i) { - libDirFiles[i] = new File(libDirs[i]); - } - return fromLibDirs(libDirFiles); - } - - public static final PathLocator fromLibDirs(File... libDirs) { - return new LibDirBasedPathLocator(libDirs); - } - - public static final PathLocator fromJavaLibraryPath() { - String libPaths; - try { - libPaths = System.getProperty("java.library.path"); - } catch (SecurityException e) { - return new LibDirBasedPathLocator(); - } - return fromLibPathString(libPaths); - } - - public static final PathLocator fromLibPathString(String javaLibPath) { - // Typically, this method will be called at most once per run, - // so not storing pattern in a static because we don't want memory consumed forever - return fromLibDirs(Pattern.compile("\\:").split(javaLibPath)); - } - - public static final PathLocator fromClassLoader(ClassLoader classLoader) { - return new ClassLoaderResourcePathLocator(classLoader, null); - } - - public static final PathLocator fromClassLoader(ClassLoader classLoader, String baseResource) { - return new ClassLoaderResourcePathLocator(classLoader, baseResource); - } + private PathLocators() {} + + public static final PathLocator defaultPathLocator() { + return fromJavaLibraryPath(); + } + + public static final PathLocator fromLibDirs(Path... libDirs) { + File[] libDirFiles = new File[libDirs.length]; + for (int i = 0; i < libDirs.length; ++i) { + libDirFiles[i] = libDirs[i].toFile(); + } + return fromLibDirs(libDirFiles); + } + + public static final PathLocator fromLibDirs(String... libDirs) { + File[] libDirFiles = new File[libDirs.length]; + for (int i = 0; i < libDirs.length; ++i) { + libDirFiles[i] = new File(libDirs[i]); + } + return fromLibDirs(libDirFiles); + } + + public static final PathLocator fromLibDirs(File... libDirs) { + return new LibDirBasedPathLocator(libDirs); + } + + public static final PathLocator fromJavaLibraryPath() { + String libPaths; + try { + libPaths = System.getProperty("java.library.path"); + } catch (SecurityException e) { + return new LibDirBasedPathLocator(); + } + return fromLibPathString(libPaths); + } + + public static final PathLocator fromLibPathString(String javaLibPath) { + // Typically, this method will be called at most once per run, + // so not storing pattern in a static because we don't want memory consumed forever + return fromLibDirs(Pattern.compile("\\:").split(javaLibPath)); + } + + public static final PathLocator fromClassLoader(ClassLoader classLoader) { + return new ClassLoaderResourcePathLocator(classLoader, null); + } + + public static final PathLocator fromClassLoader(ClassLoader classLoader, String baseResource) { + return new ClassLoaderResourcePathLocator(classLoader, baseResource); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/PathUtils.java b/components/native-loader/src/main/java/datadog/nativeloader/PathUtils.java index 66e69ed357f..e2e304aca6c 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/PathUtils.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/PathUtils.java @@ -5,94 +5,94 @@ /** Utilities for generating library file paths to be requested from a {@link PathLocator} */ public final class PathUtils { - private PathUtils() {} + private PathUtils() {} - public static final String libPrefix(PlatformSpec platformSpec) { - if (platformSpec.isMac() || platformSpec.isLinux()) { - return "lib"; - } else if (platformSpec.isWindows()) { - return ""; - } else { - throw new IllegalArgumentException(UNSUPPORTED_OS); + public static final String libPrefix(PlatformSpec platformSpec) { + if (platformSpec.isMac() || platformSpec.isLinux()) { + return "lib"; + } else if (platformSpec.isWindows()) { + return ""; + } else { + throw new IllegalArgumentException(UNSUPPORTED_OS); + } } - } - public static final String libFileName(PlatformSpec platformSpec, String libName) { - return libPrefix(platformSpec) + libName + "." + dynamicLibExtension(platformSpec); - } + public static final String libFileName(PlatformSpec platformSpec, String libName) { + return libPrefix(platformSpec) + libName + "." + dynamicLibExtension(platformSpec); + } - public static final String dynamicLibExtension(PlatformSpec platformSpec) { - if (platformSpec.isLinux()) { - return "so"; - } else if (platformSpec.isWindows()) { - return "dll"; - } else if (platformSpec.isMac()) { - return "dylib"; - } else { - throw new IllegalArgumentException(UNSUPPORTED_OS); + public static final String dynamicLibExtension(PlatformSpec platformSpec) { + if (platformSpec.isLinux()) { + return "so"; + } else if (platformSpec.isWindows()) { + return "dll"; + } else if (platformSpec.isMac()) { + return "dylib"; + } else { + throw new IllegalArgumentException(UNSUPPORTED_OS); + } } - } - public static final String osPartOf(PlatformSpec platformSpec) { - if (platformSpec.isLinux()) { - return "linux"; - } else if (platformSpec.isWindows()) { - return "win"; - } else if (platformSpec.isMac()) { - return "macos"; - } else { - throw new IllegalArgumentException(UNSUPPORTED_OS); + public static final String osPartOf(PlatformSpec platformSpec) { + if (platformSpec.isLinux()) { + return "linux"; + } else if (platformSpec.isWindows()) { + return "win"; + } else if (platformSpec.isMac()) { + return "macos"; + } else { + throw new IllegalArgumentException(UNSUPPORTED_OS); + } } - } - public static final String archPartOf(PlatformSpec platformSpec) { - if (platformSpec.isX86_64()) { - return "x86_64"; - } else if (platformSpec.isAarch64()) { - return "aarch64"; - } else if (platformSpec.isX86_32()) { - return "x86_32"; - } else if (platformSpec.isArm32()) { - return "arm32"; - } else { - throw new IllegalArgumentException(UNSUPPORTED_ARCH); + public static final String archPartOf(PlatformSpec platformSpec) { + if (platformSpec.isX86_64()) { + return "x86_64"; + } else if (platformSpec.isAarch64()) { + return "aarch64"; + } else if (platformSpec.isX86_32()) { + return "x86_32"; + } else if (platformSpec.isArm32()) { + return "arm32"; + } else { + throw new IllegalArgumentException(UNSUPPORTED_ARCH); + } } - } - public static final String libcPartOf(PlatformSpec platformSpec) { - if (!platformSpec.isLinux()) { - return null; - } else if (platformSpec.isMusl()) { - return "musl"; - } else { - return "libc"; + public static final String libcPartOf(PlatformSpec platformSpec) { + if (!platformSpec.isLinux()) { + return null; + } else if (platformSpec.isMusl()) { + return "musl"; + } else { + return "libc"; + } } - } - /** Helper for concatenating paths with / Handles null & empty for both parts */ - public static final String concatPath(String pathPart1, String pathPart2) { - if (isEmpty(pathPart1)) { - return pathPart2; - } else if (isEmpty(pathPart2)) { - return pathPart1; - } else { - return pathPart1 + "/" + pathPart2; + /** Helper for concatenating paths with / Handles null & empty for both parts */ + public static final String concatPath(String pathPart1, String pathPart2) { + if (isEmpty(pathPart1)) { + return pathPart2; + } else if (isEmpty(pathPart2)) { + return pathPart1; + } else { + return pathPart1 + "/" + pathPart2; + } } - } - /** Helper for concatenating parts with / Handles null & empty anywhere in the var-arg array */ - public static final String concatPath(String... pathParts) { - StringBuilder builder = new StringBuilder(); - for (String pathPart : pathParts) { - if (isEmpty(pathPart)) continue; + /** Helper for concatenating parts with / Handles null & empty anywhere in the var-arg array */ + public static final String concatPath(String... pathParts) { + StringBuilder builder = new StringBuilder(); + for (String pathPart : pathParts) { + if (isEmpty(pathPart)) continue; - if (builder.length() != 0) builder.append('/'); - builder.append(pathPart); + if (builder.length() != 0) builder.append('/'); + builder.append(pathPart); + } + return builder.toString(); } - return builder.toString(); - } - static final boolean isEmpty(String pathPart) { - return (pathPart == null) || pathPart.isEmpty(); - } + static final boolean isEmpty(String pathPart) { + return (pathPart == null) || pathPart.isEmpty(); + } } diff --git a/components/native-loader/src/main/java/datadog/nativeloader/PlatformSpec.java b/components/native-loader/src/main/java/datadog/nativeloader/PlatformSpec.java index c8e0a9e5902..783b520daa6 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/PlatformSpec.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/PlatformSpec.java @@ -5,41 +5,41 @@ * and libc variation */ public abstract class PlatformSpec { - public static final PlatformSpec defaultPlatformSpec() { - return IntrospectPlatformSpec.INSTANCE; - } + public static final PlatformSpec defaultPlatformSpec() { + return IntrospectPlatformSpec.INSTANCE; + } - /** Is the target OS MacOS? */ - public abstract boolean isMac(); + /** Is the target OS MacOS? */ + public abstract boolean isMac(); - /** Is the target OS Windows? */ - public abstract boolean isWindows(); + /** Is the target OS Windows? */ + public abstract boolean isWindows(); - /** Is the target OS Linux? */ - public abstract boolean isLinux(); + /** Is the target OS Linux? */ + public abstract boolean isLinux(); - /** Is the target OS unknown / not handled by {@link NativeLoader}? */ - public final boolean isUnknownOs() { - return !this.isLinux() && !this.isMac() && !this.isWindows(); - } + /** Is the target OS unknown / not handled by {@link NativeLoader}? */ + public final boolean isUnknownOs() { + return !this.isLinux() && !this.isMac() && !this.isWindows(); + } - /** Is the target architecture x86-32 bit? */ - public abstract boolean isX86_32(); + /** Is the target architecture x86-32 bit? */ + public abstract boolean isX86_32(); - /** Is the target architecture x86-64 bit? */ - public abstract boolean isX86_64(); + /** Is the target architecture x86-64 bit? */ + public abstract boolean isX86_64(); - /** Is the target architecture ARM 32-bit? */ - public abstract boolean isArm32(); + /** Is the target architecture ARM 32-bit? */ + public abstract boolean isArm32(); - /** Is the target architecture ARM 64-bit? */ - public abstract boolean isAarch64(); + /** Is the target architecture ARM 64-bit? */ + public abstract boolean isAarch64(); - /** Is the target architecture unknown / not handled by {@link NativeLoader}? */ - public final boolean isUnknownArch() { - return !this.isX86_64() && !this.isAarch64() && !this.isX86_32() && !this.isArm32(); - } + /** Is the target architecture unknown / not handled by {@link NativeLoader}? */ + public final boolean isUnknownArch() { + return !this.isX86_64() && !this.isAarch64() && !this.isX86_32() && !this.isArm32(); + } - /** Is the target using MUSL libc? */ - public abstract boolean isMusl(); + /** Is the target using MUSL libc? */ + public abstract boolean isMusl(); } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/CapturingPathLocator.java b/components/native-loader/src/test/java/datadog/nativeloader/CapturingPathLocator.java index b002b2c144d..c5fe2c3aafc 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/CapturingPathLocator.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/CapturingPathLocator.java @@ -8,136 +8,133 @@ import java.util.LinkedList; public final class CapturingPathLocator implements PathLocator { - public static final boolean WITH_OMIT_COMP_FALLBACK = true; - public static final boolean WITHOUT_OMIT_COMP_FALLBACK = false; - - public static final void testFailOnExceptions( - LibraryResolver resolver, - PlatformSpec platformSpec, - boolean withSkipCompFallback, - String... expectedPaths) { - try { - test(resolver, platformSpec, withSkipCompFallback, expectedPaths); - } catch (Exception e) { - throw new IllegalStateException("unexpected exception", e); - } - } - - public static final void test( - LibraryResolver resolver, - PlatformSpec platformSpec, - boolean withSkipCompFallback, - String... expectedPaths) - throws Exception { - String comp = "comp"; - - CapturingPathLocator fullCaptureLocator = new CapturingPathLocator(Integer.MAX_VALUE); - resolver.resolve(fullCaptureLocator, platformSpec, comp, "test"); - - for (int i = 0; !fullCaptureLocator.isEmpty(); ++i) { - if (i >= expectedPaths.length) { - // checking the final fallback here was confusing when debugging tests - - if (!withSkipCompFallback) { - fullCaptureLocator.assertDone(); - } else { - // checked at at the end of the method - fullCaptureLocator.nextRequest(); - fullCaptureLocator.assertDone(); + public static final boolean WITH_OMIT_COMP_FALLBACK = true; + public static final boolean WITHOUT_OMIT_COMP_FALLBACK = false; + + public static final void testFailOnExceptions( + LibraryResolver resolver, + PlatformSpec platformSpec, + boolean withSkipCompFallback, + String... expectedPaths) { + try { + test(resolver, platformSpec, withSkipCompFallback, expectedPaths); + } catch (Exception e) { + throw new IllegalStateException("unexpected exception", e); } - } else { - fullCaptureLocator.assertRequested(comp, expectedPaths[i]); - } } - for (int i = 0; i < expectedPaths.length; ++i) { - CapturingPathLocator fallbackLocator = new CapturingPathLocator(i); - resolver.resolve(fallbackLocator, platformSpec, comp, "test"); + public static final void test( + LibraryResolver resolver, PlatformSpec platformSpec, boolean withSkipCompFallback, String... expectedPaths) + throws Exception { + String comp = "comp"; + + CapturingPathLocator fullCaptureLocator = new CapturingPathLocator(Integer.MAX_VALUE); + resolver.resolve(fullCaptureLocator, platformSpec, comp, "test"); + + for (int i = 0; !fullCaptureLocator.isEmpty(); ++i) { + if (i >= expectedPaths.length) { + // checking the final fallback here was confusing when debugging tests + + if (!withSkipCompFallback) { + fullCaptureLocator.assertDone(); + } else { + // checked at at the end of the method + fullCaptureLocator.nextRequest(); + fullCaptureLocator.assertDone(); + } + } else { + fullCaptureLocator.assertRequested(comp, expectedPaths[i]); + } + } - for (int j = 0; j <= i; ++j) { - fallbackLocator.assertRequested(comp, expectedPaths[j]); - } - fallbackLocator.assertDone(); - } + for (int i = 0; i < expectedPaths.length; ++i) { + CapturingPathLocator fallbackLocator = new CapturingPathLocator(i); + resolver.resolve(fallbackLocator, platformSpec, comp, "test"); + + for (int j = 0; j <= i; ++j) { + fallbackLocator.assertRequested(comp, expectedPaths[j]); + } + fallbackLocator.assertDone(); + } - if (withSkipCompFallback) { - CapturingPathLocator fallbackLocator = new CapturingPathLocator(expectedPaths.length); - resolver.resolve(fallbackLocator, platformSpec, comp, "test"); + if (withSkipCompFallback) { + CapturingPathLocator fallbackLocator = new CapturingPathLocator(expectedPaths.length); + resolver.resolve(fallbackLocator, platformSpec, comp, "test"); - for (int j = 0; j < expectedPaths.length; ++j) { - fallbackLocator.assertRequested(comp, expectedPaths[j]); - } - fallbackLocator.assertRequested(null, expectedPaths[expectedPaths.length - 1]); - fallbackLocator.assertDone(); + for (int j = 0; j < expectedPaths.length; ++j) { + fallbackLocator.assertRequested(comp, expectedPaths[j]); + } + fallbackLocator.assertRequested(null, expectedPaths[expectedPaths.length - 1]); + fallbackLocator.assertDone(); + } } - } - final int simulateNotFoundCount; - int numRequests; + final int simulateNotFoundCount; + int numRequests; - final Deque locateRequests = new LinkedList<>(); + final Deque locateRequests = new LinkedList<>(); - public CapturingPathLocator() { - this(0); - } + public CapturingPathLocator() { + this(0); + } - public CapturingPathLocator(int simulateNotFoundCount) { - this.numRequests = 0; - this.simulateNotFoundCount = simulateNotFoundCount; - } + public CapturingPathLocator(int simulateNotFoundCount) { + this.numRequests = 0; + this.simulateNotFoundCount = simulateNotFoundCount; + } - @Override - public URL locate(String optionalComponent, String path) { - this.locateRequests.addLast(new LocateRequest(optionalComponent, path)); + @Override + public URL locate(String optionalComponent, String path) { + this.locateRequests.addLast(new LocateRequest(optionalComponent, path)); - if (this.numRequests++ < this.simulateNotFoundCount) return null; - try { - return new URL("http://localhost"); - } catch (MalformedURLException e) { - throw new IllegalStateException(e); + if (this.numRequests++ < this.simulateNotFoundCount) return null; + try { + return new URL("http://localhost"); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } } - } - int numLocateRequests() { - return this.locateRequests.size(); - } + int numLocateRequests() { + return this.locateRequests.size(); + } - boolean isEmpty() { - return this.locateRequests.isEmpty(); - } + boolean isEmpty() { + return this.locateRequests.isEmpty(); + } - LocateRequest nextRequest() { - return this.locateRequests.removeFirst(); - } + LocateRequest nextRequest() { + return this.locateRequests.removeFirst(); + } - void assertRequested(LocateRequest request) { - this.assertRequested(request.requestedComponent, request.requestedPath); - } + void assertRequested(LocateRequest request) { + this.assertRequested(request.requestedComponent, request.requestedPath); + } - void assertRequested(String expectedComponent, String expectedPath) { - this.nextRequest().assertRequested(expectedComponent, expectedPath); - } + void assertRequested(String expectedComponent, String expectedPath) { + this.nextRequest().assertRequested(expectedComponent, expectedPath); + } - void assertDone() { - // written this way to aid in debugging and fleshing out the test - if (!this.isEmpty()) { - this.assertRequested("", ""); + void assertDone() { + // written this way to aid in debugging and fleshing out the test + if (!this.isEmpty()) { + this.assertRequested("", ""); + } } - } - final class LocateRequest { - private final String requestedComponent; - private final String requestedPath; + final class LocateRequest { + private final String requestedComponent; + private final String requestedPath; - LocateRequest(String requestedComponent, String requestedPath) { - this.requestedComponent = requestedComponent; - this.requestedPath = requestedPath; - } + LocateRequest(String requestedComponent, String requestedPath) { + this.requestedComponent = requestedComponent; + this.requestedPath = requestedPath; + } - void assertRequested(String expectedComponent, String expectedPath) { - // order is inverted, since usually test writer is worrying about comp directly - assertEquals(expectedPath, this.requestedPath); - assertEquals(expectedComponent, this.requestedComponent); + void assertRequested(String expectedComponent, String expectedPath) { + // order is inverted, since usually test writer is worrying about comp directly + assertEquals(expectedPath, this.requestedPath); + assertEquals(expectedComponent, this.requestedComponent); + } } - } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/CompositeLibraryLoadingListenerTest.java b/components/native-loader/src/test/java/datadog/nativeloader/CompositeLibraryLoadingListenerTest.java index 019f09c2138..4c8673f4df7 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/CompositeLibraryLoadingListenerTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/CompositeLibraryLoadingListenerTest.java @@ -14,163 +14,150 @@ import org.junit.jupiter.api.Test; public final class CompositeLibraryLoadingListenerTest { - @Test - public void onResolveDynamic() throws MalformedURLException { - TestLibraryLoadingListener listener1 = - new TestLibraryLoadingListener().expectResolveDynamic("foo"); + @Test + public void onResolveDynamic() throws MalformedURLException { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectResolveDynamic("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - listeners(listener1, listener2) - .onResolveDynamic( - PlatformSpec.defaultPlatformSpec(), null, "foo", false, new URL("http://localhost")); + listeners(listener1, listener2) + .onResolveDynamic(PlatformSpec.defaultPlatformSpec(), null, "foo", false, new URL("http://localhost")); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void onResolveDynamicFailure() { - TestLibraryLoadingListener listener1 = - new TestLibraryLoadingListener().expectResolveDynamicFailure("foo"); + @Test + public void onResolveDynamicFailure() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectResolveDynamicFailure("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - listeners(listener1, listener2) - .onResolveDynamicFailure( - PlatformSpec.defaultPlatformSpec(), null, "foo", new Exception("foo")); + listeners(listener1, listener2) + .onResolveDynamicFailure(PlatformSpec.defaultPlatformSpec(), null, "foo", new Exception("foo")); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void onLoad() { - TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); + @Test + public void onLoad() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - listeners(listener1, listener2) - .onLoad(PlatformSpec.defaultPlatformSpec(), null, "foo", false, null); + listeners(listener1, listener2).onLoad(PlatformSpec.defaultPlatformSpec(), null, "foo", false, null); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void onLoadFailure() { - TestLibraryLoadingListener listener1 = - new TestLibraryLoadingListener().expectLoadFailure("foo"); + @Test + public void onLoadFailure() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoadFailure("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - listeners(listener1, listener2) - .onLoadFailure(PlatformSpec.defaultPlatformSpec(), null, "foo", null); + listeners(listener1, listener2).onLoadFailure(PlatformSpec.defaultPlatformSpec(), null, "foo", null); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void onTempFileCreated() { - TestLibraryLoadingListener listener1 = - new TestLibraryLoadingListener().expectTempFileCreated("foo"); + @Test + public void onTempFileCreated() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectTempFileCreated("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - listeners(listener1, listener2) - .onTempFileCreated( - PlatformSpec.defaultPlatformSpec(), null, "foo", Paths.get("/tmp/foo.dll")); + listeners(listener1, listener2) + .onTempFileCreated(PlatformSpec.defaultPlatformSpec(), null, "foo", Paths.get("/tmp/foo.dll")); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void onTempFileCreationFailure() { - TestLibraryLoadingListener listener1 = - new TestLibraryLoadingListener().expectTempFileCreationFailure("foo"); + @Test + public void onTempFileCreationFailure() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectTempFileCreationFailure("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - listeners(listener1, listener2) - .onTempFileCreationFailure( - PlatformSpec.defaultPlatformSpec(), - null, - "foo", - Paths.get("/tmp"), - "dylib", - new IOException("perm")); + listeners(listener1, listener2) + .onTempFileCreationFailure( + PlatformSpec.defaultPlatformSpec(), + null, + "foo", + Paths.get("/tmp"), + "dylib", + new IOException("perm")); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void onTempFileCleanup() { - TestLibraryLoadingListener listener1 = - new TestLibraryLoadingListener().expectTempFileCleanup("foo"); + @Test + public void onTempFileCleanup() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectTempFileCleanup("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - listeners(listener1, listener2) - .onTempFileCleanup( - PlatformSpec.defaultPlatformSpec(), null, "foo", Paths.get("/tmp/foo.dll")); + listeners(listener1, listener2) + .onTempFileCleanup(PlatformSpec.defaultPlatformSpec(), null, "foo", Paths.get("/tmp/foo.dll")); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void join() { - TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); + @Test + public void join() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); - CompositeLibraryLoadingListener composite = new CompositeLibraryLoadingListener(listener1); - assertEquals(1, composite.size()); + CompositeLibraryLoadingListener composite = new CompositeLibraryLoadingListener(listener1); + assertEquals(1, composite.size()); - TestLibraryLoadingListener listener2 = listener1.copy(); + TestLibraryLoadingListener listener2 = listener1.copy(); - CompositeLibraryLoadingListener composite2 = composite.join(listener2); - assertEquals(2, composite2.size()); + CompositeLibraryLoadingListener composite2 = composite.join(listener2); + assertEquals(2, composite2.size()); - TestLibraryLoadingListener listener3 = listener1.copy(); - TestLibraryLoadingListener listener4 = listener1.copy(); + TestLibraryLoadingListener listener3 = listener1.copy(); + TestLibraryLoadingListener listener4 = listener1.copy(); - CompositeLibraryLoadingListener finalComposite = composite2.join(listener3, listener4); - assertEquals(4, finalComposite.size()); + CompositeLibraryLoadingListener finalComposite = composite2.join(listener3, listener4); + assertEquals(4, finalComposite.size()); - finalComposite.onLoad( - PlatformSpec.defaultPlatformSpec(), null, "foo", false, Paths.get("/tmp/foo.dll")); + finalComposite.onLoad(PlatformSpec.defaultPlatformSpec(), null, "foo", false, Paths.get("/tmp/foo.dll")); - listener1.assertDone(); - listener2.assertDone(); - listener3.assertDone(); - listener4.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + listener3.assertDone(); + listener4.assertDone(); + } - @Test - public void _toString() { - // just for coverage - assertNotNull(new CompositeLibraryLoadingListener().toString()); - } - - /* - * Constructs a composite listener that includes the provided listeners - * To test robustness... - * - adds in additional failing listeners - * - adds in additional default listeners - * - shuffles the order of the listeners - */ - static CompositeLibraryLoadingListener listeners(LibraryLoadingListener... listeners) { - List shuffledListeners = new ArrayList<>(listeners.length * 3); - shuffledListeners.addAll(Arrays.asList(listeners)); - - for (int i = 0; i < listeners.length; ++i) { - shuffledListeners.add(new LibraryLoadingListener() {}); - shuffledListeners.add(new ThrowingLibraryLoadingListener()); + @Test + public void _toString() { + // just for coverage + assertNotNull(new CompositeLibraryLoadingListener().toString()); } - Collections.shuffle(shuffledListeners); - return new CompositeLibraryLoadingListener(shuffledListeners); - } + /* + * Constructs a composite listener that includes the provided listeners + * To test robustness... + * - adds in additional failing listeners + * - adds in additional default listeners + * - shuffles the order of the listeners + */ + static CompositeLibraryLoadingListener listeners(LibraryLoadingListener... listeners) { + List shuffledListeners = new ArrayList<>(listeners.length * 3); + shuffledListeners.addAll(Arrays.asList(listeners)); + + for (int i = 0; i < listeners.length; ++i) { + shuffledListeners.add(new LibraryLoadingListener() {}); + shuffledListeners.add(new ThrowingLibraryLoadingListener()); + } + + Collections.shuffle(shuffledListeners); + return new CompositeLibraryLoadingListener(shuffledListeners); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/FlatDirLibraryResolverTest.java b/components/native-loader/src/test/java/datadog/nativeloader/FlatDirLibraryResolverTest.java index 2438770288e..01d5d6d8ff1 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/FlatDirLibraryResolverTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/FlatDirLibraryResolverTest.java @@ -11,54 +11,46 @@ import org.junit.jupiter.api.Test; public class FlatDirLibraryResolverTest { - @Test - public void linux_x86_64_libc() { - test( - TestPlatformSpec.of(LINUX, X86_64, GLIBC), - "linux-x86_64-libc/libtest.so", - "linux-x86_64/libtest.so", - "linux/libtest.so", - "libtest.so"); - } - - @Test - public void linux_x86_64_musl() { - test( - TestPlatformSpec.of(LINUX, X86_64, MUSL), - "linux-x86_64-musl/libtest.so", - "linux-x86_64/libtest.so", - "linux/libtest.so", - "libtest.so"); - } - - @Test - public void osx_x86_64() { - test( - TestPlatformSpec.of(MAC, X86_64), - "macos-x86_64/libtest.dylib", - "macos/libtest.dylib", - "libtest.dylib"); - } - - @Test - public void osx_aarch64() { - test( - TestPlatformSpec.of(MAC, AARCH64), - "macos-aarch64/libtest.dylib", - "macos/libtest.dylib", - "libtest.dylib"); - } - - @Test - public void windows_x86_64() { - test(TestPlatformSpec.of(WINDOWS, X86_64), "win-x86_64/test.dll", "win/test.dll", "test.dll"); - } - - static final void test(PlatformSpec platformSpec, String... expectedPaths) { - CapturingPathLocator.testFailOnExceptions( - FlatDirLibraryResolver.INSTANCE, - platformSpec, - CapturingPathLocator.WITH_OMIT_COMP_FALLBACK, - expectedPaths); - } + @Test + public void linux_x86_64_libc() { + test( + TestPlatformSpec.of(LINUX, X86_64, GLIBC), + "linux-x86_64-libc/libtest.so", + "linux-x86_64/libtest.so", + "linux/libtest.so", + "libtest.so"); + } + + @Test + public void linux_x86_64_musl() { + test( + TestPlatformSpec.of(LINUX, X86_64, MUSL), + "linux-x86_64-musl/libtest.so", + "linux-x86_64/libtest.so", + "linux/libtest.so", + "libtest.so"); + } + + @Test + public void osx_x86_64() { + test(TestPlatformSpec.of(MAC, X86_64), "macos-x86_64/libtest.dylib", "macos/libtest.dylib", "libtest.dylib"); + } + + @Test + public void osx_aarch64() { + test(TestPlatformSpec.of(MAC, AARCH64), "macos-aarch64/libtest.dylib", "macos/libtest.dylib", "libtest.dylib"); + } + + @Test + public void windows_x86_64() { + test(TestPlatformSpec.of(WINDOWS, X86_64), "win-x86_64/test.dll", "win/test.dll", "test.dll"); + } + + static final void test(PlatformSpec platformSpec, String... expectedPaths) { + CapturingPathLocator.testFailOnExceptions( + FlatDirLibraryResolver.INSTANCE, + platformSpec, + CapturingPathLocator.WITH_OMIT_COMP_FALLBACK, + expectedPaths); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/IntrospectPlatformSpecTest.java b/components/native-loader/src/test/java/datadog/nativeloader/IntrospectPlatformSpecTest.java index 75da5ae18cd..972e12babd4 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/IntrospectPlatformSpecTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/IntrospectPlatformSpecTest.java @@ -9,42 +9,42 @@ import org.junit.jupiter.api.Test; public class IntrospectPlatformSpecTest { - @Test - public void os() { - PlatformSpec platformSpec = IntrospectPlatformSpec.INSTANCE; - - // a bit silly since this just mirrors the underlying implementation - assertEquals(OperatingSystem.isMacOs(), platformSpec.isMac()); - assertEquals(OperatingSystem.isLinux(), platformSpec.isLinux()); - assertEquals(OperatingSystem.isWindows(), platformSpec.isWindows()); - } - - @Test - public void arch() { - PlatformSpec platformSpec = IntrospectPlatformSpec.INSTANCE; - - assertEquals(OperatingSystem.architecture() == Architecture.X86, platformSpec.isX86_32()); - assertEquals(OperatingSystem.architecture() == Architecture.X64, platformSpec.isX86_64()); - assertEquals(OperatingSystem.architecture() == Architecture.ARM, platformSpec.isArm32()); - assertEquals(OperatingSystem.architecture() == Architecture.ARM64, platformSpec.isAarch64()); - } - - @Test - public void musl() { - PlatformSpec platformSpec = IntrospectPlatformSpec.INSTANCE; - - assertEquals(OperatingSystem.isMusl(), platformSpec.isMusl()); - } - - @Test - public void equals() { - // just a sanity check, since assertEquals is used in other tests - assertTrue(IntrospectPlatformSpec.INSTANCE.equals(IntrospectPlatformSpec.INSTANCE)); - } - - @Test - public void notEquals_diffType() { - // just a sanity check, since assertEquals is used in other tests - assertFalse(IntrospectPlatformSpec.INSTANCE.equals(TestPlatformSpec.unsupportedOs())); - } + @Test + public void os() { + PlatformSpec platformSpec = IntrospectPlatformSpec.INSTANCE; + + // a bit silly since this just mirrors the underlying implementation + assertEquals(OperatingSystem.isMacOs(), platformSpec.isMac()); + assertEquals(OperatingSystem.isLinux(), platformSpec.isLinux()); + assertEquals(OperatingSystem.isWindows(), platformSpec.isWindows()); + } + + @Test + public void arch() { + PlatformSpec platformSpec = IntrospectPlatformSpec.INSTANCE; + + assertEquals(OperatingSystem.architecture() == Architecture.X86, platformSpec.isX86_32()); + assertEquals(OperatingSystem.architecture() == Architecture.X64, platformSpec.isX86_64()); + assertEquals(OperatingSystem.architecture() == Architecture.ARM, platformSpec.isArm32()); + assertEquals(OperatingSystem.architecture() == Architecture.ARM64, platformSpec.isAarch64()); + } + + @Test + public void musl() { + PlatformSpec platformSpec = IntrospectPlatformSpec.INSTANCE; + + assertEquals(OperatingSystem.isMusl(), platformSpec.isMusl()); + } + + @Test + public void equals() { + // just a sanity check, since assertEquals is used in other tests + assertTrue(IntrospectPlatformSpec.INSTANCE.equals(IntrospectPlatformSpec.INSTANCE)); + } + + @Test + public void notEquals_diffType() { + // just a sanity check, since assertEquals is used in other tests + assertFalse(IntrospectPlatformSpec.INSTANCE.equals(TestPlatformSpec.unsupportedOs())); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderBuilderTest.java b/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderBuilderTest.java index db5a25b4541..3068bb2f92b 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderBuilderTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderBuilderTest.java @@ -10,172 +10,170 @@ import org.junit.jupiter.api.Test; public class NativeLoaderBuilderTest { - @Test - public void defaultPlatformSpec() { - NativeLoader.Builder builder = NativeLoader.builder(); + @Test + public void defaultPlatformSpec() { + NativeLoader.Builder builder = NativeLoader.builder(); - assertEquals(PlatformSpec.defaultPlatformSpec(), builder.platformSpec()); - } + assertEquals(PlatformSpec.defaultPlatformSpec(), builder.platformSpec()); + } - @Test - public void customPlatformSpec() { - PlatformSpec platformSpec = - TestPlatformSpec.of(TestPlatformSpec.LINUX, TestPlatformSpec.ARM32, TestPlatformSpec.GLIBC); + @Test + public void customPlatformSpec() { + PlatformSpec platformSpec = + TestPlatformSpec.of(TestPlatformSpec.LINUX, TestPlatformSpec.ARM32, TestPlatformSpec.GLIBC); - NativeLoader.Builder builder = NativeLoader.builder().platformSpec(platformSpec); - assertSame(platformSpec, builder.platformSpec()); - } + NativeLoader.Builder builder = NativeLoader.builder().platformSpec(platformSpec); + assertSame(platformSpec, builder.platformSpec()); + } - @Test - public void defaultListeners() { - NativeLoader.Builder builder = NativeLoader.builder(); + @Test + public void defaultListeners() { + NativeLoader.Builder builder = NativeLoader.builder(); - assertTrue(builder.listeners().isNop()); - } + assertTrue(builder.listeners().isNop()); + } - @Test - public void addListener() { - TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + @Test + public void addListener() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); + TestLibraryLoadingListener listener2 = listener1.copy(); - NativeLoader.Builder builder = - NativeLoader.builder().addListener(listener1).addListener(listener2); + NativeLoader.Builder builder = + NativeLoader.builder().addListener(listener1).addListener(listener2); - SafeLibraryLoadingListener listener = builder.listeners(); - assertFalse(listener.isNop()); + SafeLibraryLoadingListener listener = builder.listeners(); + assertFalse(listener.isNop()); - listener.onLoad(builder.platformSpec(), null, "foo", false, Paths.get("/tmp/foo.dylib")); + listener.onLoad(builder.platformSpec(), null, "foo", false, Paths.get("/tmp/foo.dylib")); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void addListeners() { - TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); - TestLibraryLoadingListener listener2 = listener1.copy(); + @Test + public void addListeners() { + TestLibraryLoadingListener listener1 = new TestLibraryLoadingListener().expectLoad("foo"); + TestLibraryLoadingListener listener2 = listener1.copy(); - NativeLoader.Builder builder = NativeLoader.builder().addListeners(listener1, listener2); + NativeLoader.Builder builder = NativeLoader.builder().addListeners(listener1, listener2); - SafeLibraryLoadingListener listener = builder.listeners(); - assertFalse(listener.isNop()); + SafeLibraryLoadingListener listener = builder.listeners(); + assertFalse(listener.isNop()); - listener.onLoad(builder.platformSpec(), null, "foo", false, Paths.get("/tmp/foo.dylib")); + listener.onLoad(builder.platformSpec(), null, "foo", false, Paths.get("/tmp/foo.dylib")); - listener1.assertDone(); - listener2.assertDone(); - } + listener1.assertDone(); + listener2.assertDone(); + } - @Test - public void defaultLibraryResolver() { - NativeLoader.Builder builder = NativeLoader.builder(); + @Test + public void defaultLibraryResolver() { + NativeLoader.Builder builder = NativeLoader.builder(); - assertEquals(LibraryResolvers.defaultLibraryResolver(), builder.libResolver()); - } + assertEquals(LibraryResolvers.defaultLibraryResolver(), builder.libResolver()); + } - @Test - public void flatLayout() { - NativeLoader.Builder builder = NativeLoader.builder().flatLayout(); + @Test + public void flatLayout() { + NativeLoader.Builder builder = NativeLoader.builder().flatLayout(); - assertEquals(LibraryResolvers.flatDirs(), builder.libResolver()); - } + assertEquals(LibraryResolvers.flatDirs(), builder.libResolver()); + } - @Test - public void nestedLayout() { - NativeLoader.Builder builder = NativeLoader.builder().nestedLayout(); + @Test + public void nestedLayout() { + NativeLoader.Builder builder = NativeLoader.builder().nestedLayout(); - assertEquals(LibraryResolvers.nestedDirs(), builder.libResolver()); - } + assertEquals(LibraryResolvers.nestedDirs(), builder.libResolver()); + } - @Test - public void preloaded() { - PlatformSpec platformSpec = - TestPlatformSpec.of(TestPlatformSpec.LINUX, TestPlatformSpec.ARM32, TestPlatformSpec.GLIBC); + @Test + public void preloaded() { + PlatformSpec platformSpec = + TestPlatformSpec.of(TestPlatformSpec.LINUX, TestPlatformSpec.ARM32, TestPlatformSpec.GLIBC); - NativeLoader.Builder builder = - NativeLoader.builder().platformSpec(platformSpec).preloaded("foo", "bar"); + NativeLoader.Builder builder = + NativeLoader.builder().platformSpec(platformSpec).preloaded("foo", "bar"); - LibraryResolver libResolver = builder.libResolver(); - assertTrue(libResolver.isPreloaded(platformSpec, "foo")); - assertTrue(libResolver.isPreloaded(platformSpec, "bar")); + LibraryResolver libResolver = builder.libResolver(); + assertTrue(libResolver.isPreloaded(platformSpec, "foo")); + assertTrue(libResolver.isPreloaded(platformSpec, "bar")); - assertFalse(libResolver.isPreloaded(platformSpec, "not-preloaded")); - } + assertFalse(libResolver.isPreloaded(platformSpec, "not-preloaded")); + } - @Test - public void defaultPathLocator() { - NativeLoader.Builder builder = NativeLoader.builder(); + @Test + public void defaultPathLocator() { + NativeLoader.Builder builder = NativeLoader.builder(); - assertEquals(PathLocators.defaultPathLocator(), builder.pathLocator()); - } + assertEquals(PathLocators.defaultPathLocator(), builder.pathLocator()); + } - @Test - public void dirBasedLocator_string() { - NativeLoader.Builder builder = NativeLoader.builder().fromDir("libs"); + @Test + public void dirBasedLocator_string() { + NativeLoader.Builder builder = NativeLoader.builder().fromDir("libs"); - assertEquals(PathLocators.fromLibDirs("libs"), builder.pathLocator()); - } + assertEquals(PathLocators.fromLibDirs("libs"), builder.pathLocator()); + } - @Test - public void dirBasedLocator_strings_multiple() { - NativeLoader.Builder builder = NativeLoader.builder().fromDirs("libs1", "libs2"); + @Test + public void dirBasedLocator_strings_multiple() { + NativeLoader.Builder builder = NativeLoader.builder().fromDirs("libs1", "libs2"); - assertEquals(PathLocators.fromLibDirs("libs1", "libs2"), builder.pathLocator()); - } + assertEquals(PathLocators.fromLibDirs("libs1", "libs2"), builder.pathLocator()); + } - @Test - public void dirBasedLocator_file() { - NativeLoader.Builder builder = NativeLoader.builder().fromDir(new File("libs")); + @Test + public void dirBasedLocator_file() { + NativeLoader.Builder builder = NativeLoader.builder().fromDir(new File("libs")); - assertEquals(PathLocators.fromLibDirs("libs"), builder.pathLocator()); - } + assertEquals(PathLocators.fromLibDirs("libs"), builder.pathLocator()); + } - @Test - public void dirBasedLocator_files_multiple() { - NativeLoader.Builder builder = - NativeLoader.builder().fromDirs(new File("libs1"), new File("libs2")); + @Test + public void dirBasedLocator_files_multiple() { + NativeLoader.Builder builder = NativeLoader.builder().fromDirs(new File("libs1"), new File("libs2")); - assertEquals(PathLocators.fromLibDirs("libs1", "libs2"), builder.pathLocator()); - } + assertEquals(PathLocators.fromLibDirs("libs1", "libs2"), builder.pathLocator()); + } - @Test - public void dirBasedLocator_path() { - NativeLoader.Builder builder = NativeLoader.builder().fromDir(Paths.get("libs")); + @Test + public void dirBasedLocator_path() { + NativeLoader.Builder builder = NativeLoader.builder().fromDir(Paths.get("libs")); - assertEquals(PathLocators.fromLibDirs("libs"), builder.pathLocator()); - } + assertEquals(PathLocators.fromLibDirs("libs"), builder.pathLocator()); + } - @Test - public void dirBasedLocator_paths_multiple() { - NativeLoader.Builder builder = - NativeLoader.builder().fromDirs(Paths.get("libs1"), Paths.get("libs2")); + @Test + public void dirBasedLocator_paths_multiple() { + NativeLoader.Builder builder = NativeLoader.builder().fromDirs(Paths.get("libs1"), Paths.get("libs2")); - assertEquals(PathLocators.fromLibDirs("libs1", "libs2"), builder.pathLocator()); - } + assertEquals(PathLocators.fromLibDirs("libs1", "libs2"), builder.pathLocator()); + } - @Test - public void classLoaderBasedLocator() { - ClassLoader sysClassLoader = ClassLoader.getSystemClassLoader(); - NativeLoader.Builder builder = NativeLoader.builder().fromClassLoader(sysClassLoader); + @Test + public void classLoaderBasedLocator() { + ClassLoader sysClassLoader = ClassLoader.getSystemClassLoader(); + NativeLoader.Builder builder = NativeLoader.builder().fromClassLoader(sysClassLoader); - assertEquals(PathLocators.fromClassLoader(sysClassLoader), builder.pathLocator()); - } + assertEquals(PathLocators.fromClassLoader(sysClassLoader), builder.pathLocator()); + } - @Test - public void tempDir_string() { - NativeLoader.Builder builder = NativeLoader.builder().tempDir("tmp"); - assertEquals(builder.tempDir(), Paths.get("tmp")); - } + @Test + public void tempDir_string() { + NativeLoader.Builder builder = NativeLoader.builder().tempDir("tmp"); + assertEquals(builder.tempDir(), Paths.get("tmp")); + } - @Test - public void tempDir_file() { - NativeLoader.Builder builder = NativeLoader.builder().tempDir(new File("tmp")); - assertEquals(builder.tempDir(), Paths.get("tmp")); - } + @Test + public void tempDir_file() { + NativeLoader.Builder builder = NativeLoader.builder().tempDir(new File("tmp")); + assertEquals(builder.tempDir(), Paths.get("tmp")); + } - @Test - public void tempDir_path() { - NativeLoader.Builder builder = NativeLoader.builder().tempDir(Paths.get("tmp")); - assertEquals(builder.tempDir(), Paths.get("tmp")); - } + @Test + public void tempDir_path() { + NativeLoader.Builder builder = NativeLoader.builder().tempDir(Paths.get("tmp")); + assertEquals(builder.tempDir(), Paths.get("tmp")); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java b/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java index 0a44b91f912..91b27f3ae07 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java @@ -29,577 +29,581 @@ import org.junit.jupiter.api.Test; public class NativeLoaderTest { - @Test - public void preloaded() throws LibraryLoadException { - NativeLoader loader = NativeLoader.builder().preloaded("preloaded1", "preloaded2").build(); + @Test + public void preloaded() throws LibraryLoadException { + NativeLoader loader = + NativeLoader.builder().preloaded("preloaded1", "preloaded2").build(); - assertTrue(loader.isPreloaded("preloaded1")); - assertTrue(loader.isPreloaded("preloaded2")); + assertTrue(loader.isPreloaded("preloaded1")); + assertTrue(loader.isPreloaded("preloaded2")); - assertFalse(loader.isPreloaded("dne")); + assertFalse(loader.isPreloaded("dne")); - try (LibFile lib = loader.resolveDynamic("preloaded1")) { - assertPreloaded(lib); + try (LibFile lib = loader.resolveDynamic("preloaded1")) { + assertPreloaded(lib); - // already considered loaded -- so this is a nop - lib.load(); - } + // already considered loaded -- so this is a nop + lib.load(); + } - // already considered loaded -- so this is a nop - loader.load("preloaded2"); + // already considered loaded -- so this is a nop + loader.load("preloaded2"); - // not already loaded - so passes through to underlying resolver - assertThrows(LibraryLoadException.class, () -> loader.load("dne")); - } + // not already loaded - so passes through to underlying resolver + assertThrows(LibraryLoadException.class, () -> loader.load("dne")); + } - @Test - public void preloaded_listenerSupport() throws LibraryLoadException { - TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); + @Test + public void preloaded_listenerSupport() throws LibraryLoadException { + TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); - NativeLoader loader = - NativeLoader.builder() - .preloaded("preloaded1", "preloaded2") - .addListener(sharedListener) - .build(); + NativeLoader loader = NativeLoader.builder() + .preloaded("preloaded1", "preloaded2") + .addListener(sharedListener) + .build(); - // debatable - but no listener calls just for checking - assertTrue(loader.isPreloaded("preloaded1")); - assertTrue(loader.isPreloaded("preloaded2")); + // debatable - but no listener calls just for checking + assertTrue(loader.isPreloaded("preloaded1")); + assertTrue(loader.isPreloaded("preloaded2")); - sharedListener.expectResolvePreloaded("preloaded1"); - sharedListener.expectLoadPreloaded("preloaded1"); + sharedListener.expectResolvePreloaded("preloaded1"); + sharedListener.expectLoadPreloaded("preloaded1"); - TestLibraryLoadingListener scopedListener1 = - new TestLibraryLoadingListener() - .expectResolvePreloaded("preloaded1") - .expectLoadPreloaded("preloaded1"); + TestLibraryLoadingListener scopedListener1 = new TestLibraryLoadingListener() + .expectResolvePreloaded("preloaded1") + .expectLoadPreloaded("preloaded1"); - try (LibFile lib = loader.resolveDynamic("preloaded1", scopedListener1)) { - lib.load(); - } + try (LibFile lib = loader.resolveDynamic("preloaded1", scopedListener1)) { + lib.load(); + } - sharedListener.assertDone(); - scopedListener1.assertDone(); + sharedListener.assertDone(); + scopedListener1.assertDone(); - sharedListener.expectResolvePreloaded("preloaded2"); - sharedListener.expectLoadPreloaded("preloaded2"); + sharedListener.expectResolvePreloaded("preloaded2"); + sharedListener.expectLoadPreloaded("preloaded2"); - TestLibraryLoadingListener scopedListener2 = - new TestLibraryLoadingListener() - .expectResolvePreloaded("preloaded2") - .expectLoadPreloaded("preloaded2"); + TestLibraryLoadingListener scopedListener2 = new TestLibraryLoadingListener() + .expectResolvePreloaded("preloaded2") + .expectLoadPreloaded("preloaded2"); - // load is just convenience for resolve & load - loader.load("preloaded2", scopedListener2); + // load is just convenience for resolve & load + loader.load("preloaded2", scopedListener2); - sharedListener.assertDone(); - scopedListener2.assertDone(); + sharedListener.assertDone(); + scopedListener2.assertDone(); - sharedListener.expectResolveDynamicFailure("dne"); + sharedListener.expectResolveDynamicFailure("dne"); - TestLibraryLoadingListener scopedListener3 = - new TestLibraryLoadingListener().expectResolveDynamicFailure("dne"); + TestLibraryLoadingListener scopedListener3 = + new TestLibraryLoadingListener().expectResolveDynamicFailure("dne"); - // not already loaded - so passes through to underlying resolver - assertThrows(LibraryLoadException.class, () -> loader.load("dne", scopedListener3)); + // not already loaded - so passes through to underlying resolver + assertThrows(LibraryLoadException.class, () -> loader.load("dne", scopedListener3)); - sharedListener.assertDone(); - scopedListener3.assertDone(); - } + sharedListener.assertDone(); + scopedListener3.assertDone(); + } - @Test - public void unsupportedPlatform() { - TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); + @Test + public void unsupportedPlatform() { + TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); - PlatformSpec unsupportedOsSpec = TestPlatformSpec.of(UNSUPPORTED_OS, AARCH64); - NativeLoader loader = - NativeLoader.builder().platformSpec(unsupportedOsSpec).addListener(sharedListener).build(); + PlatformSpec unsupportedOsSpec = TestPlatformSpec.of(UNSUPPORTED_OS, AARCH64); + NativeLoader loader = NativeLoader.builder() + .platformSpec(unsupportedOsSpec) + .addListener(sharedListener) + .build(); - assertFalse(loader.isPlatformSupported()); + assertFalse(loader.isPlatformSupported()); - sharedListener.expectResolveDynamicFailure("dummy"); + sharedListener.expectResolveDynamicFailure("dummy"); - // short-circuit fails during resolution because os isn't supported - assertThrows(LibraryLoadException.class, () -> loader.resolveDynamic("dummy")); + // short-circuit fails during resolution because os isn't supported + assertThrows(LibraryLoadException.class, () -> loader.resolveDynamic("dummy")); - sharedListener.assertDone(); - } + sharedListener.assertDone(); + } - @Test - public void unsupportArch() { - PlatformSpec unsupportedOsSpec = TestPlatformSpec.of(LINUX, UNSUPPORTED_ARCH); - NativeLoader loader = NativeLoader.builder().platformSpec(unsupportedOsSpec).build(); + @Test + public void unsupportArch() { + PlatformSpec unsupportedOsSpec = TestPlatformSpec.of(LINUX, UNSUPPORTED_ARCH); + NativeLoader loader = + NativeLoader.builder().platformSpec(unsupportedOsSpec).build(); - assertFalse(loader.isPlatformSupported()); + assertFalse(loader.isPlatformSupported()); - TestLibraryLoadingListener scopedListener = - new TestLibraryLoadingListener().expectResolveDynamicFailure("dummy"); + TestLibraryLoadingListener scopedListener = + new TestLibraryLoadingListener().expectResolveDynamicFailure("dummy"); - // short-circuit fails during resolution because arch isn't supported - assertThrows(LibraryLoadException.class, () -> loader.resolveDynamic("dummy", scopedListener)); + // short-circuit fails during resolution because arch isn't supported + assertThrows(LibraryLoadException.class, () -> loader.resolveDynamic("dummy", scopedListener)); - scopedListener.assertDone(); - } + scopedListener.assertDone(); + } - @Test - public void loadFailure() throws LibraryLoadException { - TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); + @Test + public void loadFailure() throws LibraryLoadException { + TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); - NativeLoader loader = NativeLoader.builder().addListener(sharedListener).build(); - assumeTrue(loader.isPlatformSupported()); + NativeLoader loader = NativeLoader.builder().addListener(sharedListener).build(); + assumeTrue(loader.isPlatformSupported()); - sharedListener.expectResolveDynamic("dummy"); - sharedListener.expectLoadFailure("dummy"); + sharedListener.expectResolveDynamic("dummy"); + sharedListener.expectLoadFailure("dummy"); - TestLibraryLoadingListener scopedListener = - new TestLibraryLoadingListener().expectResolveDynamic("dummy").expectLoadFailure("dummy"); + TestLibraryLoadingListener scopedListener = + new TestLibraryLoadingListener().expectResolveDynamic("dummy").expectLoadFailure("dummy"); - // test libraries are just text files, so they shouldn't load & link properly - // NativeLoader is supposed to wrap the loading failures, so that we - // remember to handle them + // test libraries are just text files, so they shouldn't load & link properly + // NativeLoader is supposed to wrap the loading failures, so that we + // remember to handle them - // on supported platforms, there is a dummy library file, so this will resolve but fail to load - // & link - assertThrows(LibraryLoadException.class, () -> loader.load("dummy", scopedListener)); - } + // on supported platforms, there is a dummy library file, so this will resolve but fail to load + // & link + assertThrows(LibraryLoadException.class, () -> loader.load("dummy", scopedListener)); + } - @Test - public void resolutionFailure_in_LibraryResolver() { - Exception exception = new Exception("boom!"); + @Test + public void resolutionFailure_in_LibraryResolver() { + Exception exception = new Exception("boom!"); - NativeLoader loader = - NativeLoader.builder() - .libResolver( - (pathLocator, platformSpec, component, libName) -> { - throw exception; + NativeLoader loader = NativeLoader.builder() + .libResolver((pathLocator, platformSpec, component, libName) -> { + throw exception; }) - .build(); + .build(); - TestLibraryLoadingListener scopedListener = - new TestLibraryLoadingListener().expectResolveDynamicFailure("dummy", exception); + TestLibraryLoadingListener scopedListener = + new TestLibraryLoadingListener().expectResolveDynamicFailure("dummy", exception); - assertThrows(LibraryLoadException.class, () -> loader.load("dummy", scopedListener)); + assertThrows(LibraryLoadException.class, () -> loader.load("dummy", scopedListener)); - scopedListener.assertDone(); - } + scopedListener.assertDone(); + } - @Test - public void resolutionFailure_in_PathLocator() { - TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); + @Test + public void resolutionFailure_in_PathLocator() { + TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); - Exception exception = new Exception("boom!"); + Exception exception = new Exception("boom!"); - NativeLoader loader = - NativeLoader.builder() - .addListener(sharedListener) - .pathLocator( - (comp, path) -> { - throw exception; + NativeLoader loader = NativeLoader.builder() + .addListener(sharedListener) + .pathLocator((comp, path) -> { + throw exception; }) - .build(); + .build(); - sharedListener.expectResolveDynamicFailure("dummy", exception); + sharedListener.expectResolveDynamicFailure("dummy", exception); - assertThrows(LibraryLoadException.class, () -> loader.load("dummy")); + assertThrows(LibraryLoadException.class, () -> loader.load("dummy")); - sharedListener.assertDone(); - } + sharedListener.assertDone(); + } - @Test - public void fromDir() throws LibraryLoadException { - NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); + @Test + public void fromDir() throws LibraryLoadException { + NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); - try (LibFile lib = loader.resolveDynamic("dummy")) { - // loaded directly from directory, so no clean-up required - assertRegularFile(lib); + try (LibFile lib = loader.resolveDynamic("dummy")) { + // loaded directly from directory, so no clean-up required + assertRegularFile(lib); - // file isn't actually a dynamic library - assertThrows(LibraryLoadException.class, () -> lib.load()); + // file isn't actually a dynamic library + assertThrows(LibraryLoadException.class, () -> lib.load()); + } } - } - @Test - public void fromDir_override_windows() throws LibraryLoadException { - TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); + @Test + public void fromDir_override_windows() throws LibraryLoadException { + TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); - NativeLoader loader = - NativeLoader.builder().fromDir("test-data").addListener(sharedListener).build(); + NativeLoader loader = NativeLoader.builder() + .fromDir("test-data") + .addListener(sharedListener) + .build(); - sharedListener.expectResolveDynamic(TestPlatformSpec.windows(), "dummy"); + sharedListener.expectResolveDynamic(TestPlatformSpec.windows(), "dummy"); + + try (LibFile lib = loader.resolveDynamic(TestPlatformSpec.windows(), "dummy")) { + // loaded directly from directory, so no clean-up required + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().endsWith("dummy.dll")); + } - try (LibFile lib = loader.resolveDynamic(TestPlatformSpec.windows(), "dummy")) { - // loaded directly from directory, so no clean-up required - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().endsWith("dummy.dll")); + sharedListener.assertDone(); } - sharedListener.assertDone(); - } + @Test + public void fromDir_override_mac() throws LibraryLoadException { + NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); - @Test - public void fromDir_override_mac() throws LibraryLoadException { - NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); + TestLibraryLoadingListener scopedListener = + new TestLibraryLoadingListener().expectResolveDynamic(TestPlatformSpec.mac(), "dummy"); - TestLibraryLoadingListener scopedListener = - new TestLibraryLoadingListener().expectResolveDynamic(TestPlatformSpec.mac(), "dummy"); + try (LibFile lib = loader.resolveDynamic(TestPlatformSpec.mac(), "dummy", scopedListener)) { + // loaded directly from directory, so no clean-up required + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().endsWith("libdummy.dylib")); + } - try (LibFile lib = loader.resolveDynamic(TestPlatformSpec.mac(), "dummy", scopedListener)) { - // loaded directly from directory, so no clean-up required - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().endsWith("libdummy.dylib")); + scopedListener.assertDone(); } - scopedListener.assertDone(); - } + @Test + public void fromDir_override_linux() throws LibraryLoadException { + NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); - @Test - public void fromDir_override_linux() throws LibraryLoadException { - NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); - - try (LibFile lib = loader.resolveDynamic(TestPlatformSpec.linux(), "dummy")) { - // loaded directly from directory, so no clean-up required - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().endsWith("libdummy.so")); + try (LibFile lib = loader.resolveDynamic(TestPlatformSpec.linux(), "dummy")) { + // loaded directly from directory, so no clean-up required + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().endsWith("libdummy.so")); + } } - } - @Test - public void fromDirList() throws LibraryLoadException { - NativeLoader loader = NativeLoader.builder().fromDirs("dne1", "dne2", "test-data").build(); + @Test + public void fromDirList() throws LibraryLoadException { + NativeLoader loader = + NativeLoader.builder().fromDirs("dne1", "dne2", "test-data").build(); - try (LibFile lib = loader.resolveDynamic("dummy")) { - // loaded directly from directory, so no clean-up required - assertRegularFile(lib); + try (LibFile lib = loader.resolveDynamic("dummy")) { + // loaded directly from directory, so no clean-up required + assertRegularFile(lib); + } } - } - - @Test - public void fromDir_with_component() throws LibraryLoadException { - TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); - NativeLoader loader = - NativeLoader.builder().fromDir("test-data").addListener(sharedListener).build(); + @Test + public void fromDir_with_component() throws LibraryLoadException { + TestLibraryLoadingListener sharedListener = new TestLibraryLoadingListener(); - sharedListener.expectResolveDynamic("comp1", "dummy"); + NativeLoader loader = NativeLoader.builder() + .fromDir("test-data") + .addListener(sharedListener) + .build(); - try (LibFile lib = loader.resolveDynamic("comp1", "dummy")) { - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().contains("comp1")); - } + sharedListener.expectResolveDynamic("comp1", "dummy"); - sharedListener.assertDone(); + try (LibFile lib = loader.resolveDynamic("comp1", "dummy")) { + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().contains("comp1")); + } - sharedListener.expectResolveDynamic("comp2", "dummy"); + sharedListener.assertDone(); - try (LibFile lib = loader.resolveDynamic("comp2", "dummy")) { - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().contains("comp2")); - } + sharedListener.expectResolveDynamic("comp2", "dummy"); - sharedListener.assertDone(); - } + try (LibFile lib = loader.resolveDynamic("comp2", "dummy")) { + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().contains("comp2")); + } - @Test - public void fromDir_load_with_component() { - NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); + sharedListener.assertDone(); + } - // lib file is a dummy, so fails during loading and linking - assertThrows(LibraryLoadException.class, () -> loader.load("comp1", "dummy")); + @Test + public void fromDir_load_with_component() { + NativeLoader loader = NativeLoader.builder().fromDir("test-data").build(); - TestLibraryLoadingListener scopedListener2 = - new TestLibraryLoadingListener() - .expectResolveDynamic("comp2", "dummy") - .expectLoadFailure("comp2", "dummy"); + // lib file is a dummy, so fails during loading and linking + assertThrows(LibraryLoadException.class, () -> loader.load("comp1", "dummy")); - assertThrows(LibraryLoadException.class, () -> loader.load("comp2", "dummy", scopedListener2)); + TestLibraryLoadingListener scopedListener2 = new TestLibraryLoadingListener() + .expectResolveDynamic("comp2", "dummy") + .expectLoadFailure("comp2", "dummy"); - scopedListener2.assertDone(); - } + assertThrows(LibraryLoadException.class, () -> loader.load("comp2", "dummy", scopedListener2)); - @Test - public void fromDirBackedClassLoader() throws IOException, LibraryLoadException { - // ClassLoader pulling from a directory, so there's still a normal file - URL[] urls = {new File("test-data").toURL()}; + scopedListener2.assertDone(); + } - try (URLClassLoader classLoader = new URLClassLoader(urls)) { - NativeLoader loader = NativeLoader.builder().fromClassLoader(classLoader).build(); - try (LibFile lib = loader.resolveDynamic("dummy")) { - // since there's a normal file, no need to copy to a temp file and clean-up - assertRegularFile(lib); - } + @Test + public void fromDirBackedClassLoader() throws IOException, LibraryLoadException { + // ClassLoader pulling from a directory, so there's still a normal file + URL[] urls = {new File("test-data").toURL()}; + + try (URLClassLoader classLoader = new URLClassLoader(urls)) { + NativeLoader loader = + NativeLoader.builder().fromClassLoader(classLoader).build(); + try (LibFile lib = loader.resolveDynamic("dummy")) { + // since there's a normal file, no need to copy to a temp file and clean-up + assertRegularFile(lib); + } + } } - } - @Test - public void fromDirBackedClassLoader_with_component() throws IOException, LibraryLoadException { - // ClassLoader pulling from a directory, so there's still a normal file - URL[] urls = {new File("test-data").toURL()}; + @Test + public void fromDirBackedClassLoader_with_component() throws IOException, LibraryLoadException { + // ClassLoader pulling from a directory, so there's still a normal file + URL[] urls = {new File("test-data").toURL()}; - try (URLClassLoader classLoader = new URLClassLoader(urls)) { - NativeLoader loader = NativeLoader.builder().fromClassLoader(classLoader).build(); + try (URLClassLoader classLoader = new URLClassLoader(urls)) { + NativeLoader loader = + NativeLoader.builder().fromClassLoader(classLoader).build(); - try (LibFile lib = loader.resolveDynamic("comp1", "dummy")) { - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().contains("comp1")); - } + try (LibFile lib = loader.resolveDynamic("comp1", "dummy")) { + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().contains("comp1")); + } - try (LibFile lib = loader.resolveDynamic("comp2", "dummy")) { - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().contains("comp2")); - } + try (LibFile lib = loader.resolveDynamic("comp2", "dummy")) { + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().contains("comp2")); + } + } } - } - @Test - public void fromDirBackedClassLoader_with_subResource() throws IOException, LibraryLoadException { - // ClassLoader pulling from a directory, so there's still a normal file - URL[] urls = {new File("test-data").toURL()}; - - try (URLClassLoader classLoader = new URLClassLoader(urls)) { - NativeLoader loader = NativeLoader.builder().fromClassLoader(classLoader, "resource").build(); - try (LibFile lib = loader.resolveDynamic("dummy")) { - // since there's a normal file, no need to copy to a temp file and clean-up - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().contains("resource")); - } + @Test + public void fromDirBackedClassLoader_with_subResource() throws IOException, LibraryLoadException { + // ClassLoader pulling from a directory, so there's still a normal file + URL[] urls = {new File("test-data").toURL()}; + + try (URLClassLoader classLoader = new URLClassLoader(urls)) { + NativeLoader loader = NativeLoader.builder() + .fromClassLoader(classLoader, "resource") + .build(); + try (LibFile lib = loader.resolveDynamic("dummy")) { + // since there's a normal file, no need to copy to a temp file and clean-up + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().contains("resource")); + } + } } - } - @Test - public void fromDirBackedClassLoader_with_subResource_and_comp() - throws IOException, LibraryLoadException { - // ClassLoader pulling from a directory, so there's still a normal file - try (URLClassLoader classLoader = createClassLoader(Paths.get("test-data"))) { - NativeLoader loader = NativeLoader.builder().fromClassLoader(classLoader, "resource").build(); - try (LibFile lib = loader.resolveDynamic("comp1", "dummy")) { - // since there's a normal file, no need to copy to a temp file and clean-up - assertRegularFile(lib); - assertTrue(lib.getAbsolutePath().contains("comp1")); - assertTrue(lib.getAbsolutePath().contains("resource")); - } + @Test + public void fromDirBackedClassLoader_with_subResource_and_comp() throws IOException, LibraryLoadException { + // ClassLoader pulling from a directory, so there's still a normal file + try (URLClassLoader classLoader = createClassLoader(Paths.get("test-data"))) { + NativeLoader loader = NativeLoader.builder() + .fromClassLoader(classLoader, "resource") + .build(); + try (LibFile lib = loader.resolveDynamic("comp1", "dummy")) { + // since there's a normal file, no need to copy to a temp file and clean-up + assertRegularFile(lib); + assertTrue(lib.getAbsolutePath().contains("comp1")); + assertTrue(lib.getAbsolutePath().contains("resource")); + } + } } - } - @Test - public void fromJarBackedClassLoader() throws IOException, LibraryLoadException { - Path jar = jar("test-data"); - try { - try (URLClassLoader classLoader = createClassLoader(jar)) { - NativeLoader loader = NativeLoader.builder().fromClassLoader(classLoader).build(); + @Test + public void fromJarBackedClassLoader() throws IOException, LibraryLoadException { + Path jar = jar("test-data"); + try { + try (URLClassLoader classLoader = createClassLoader(jar)) { + NativeLoader loader = + NativeLoader.builder().fromClassLoader(classLoader).build(); + + TestLibraryLoadingListener scopedListener = new TestLibraryLoadingListener() + .expectResolveDynamic("dummy") + .expectTempFileCreated("dummy") + .expectTempFileCleanup("dummy"); + + try (LibFile lib = loader.resolveDynamic("dummy", scopedListener)) { + // loaded from a jar, so copied to temp file + assertTempFile(lib); + } - TestLibraryLoadingListener scopedListener = - new TestLibraryLoadingListener() - .expectResolveDynamic("dummy") - .expectTempFileCreated("dummy") - .expectTempFileCleanup("dummy"); - - try (LibFile lib = loader.resolveDynamic("dummy", scopedListener)) { - // loaded from a jar, so copied to temp file - assertTempFile(lib); + scopedListener.assertDone(); + } + } finally { + deleteHelper(jar); } - - scopedListener.assertDone(); - } - } finally { - deleteHelper(jar); } - } - @Test - public void fromJarBackedClassLoader_load_with_component() - throws IOException, LibraryLoadException { - Path jar = jar("test-data"); - try { - try (URLClassLoader classLoader = createClassLoader(jar)) { - NativeLoader loader = NativeLoader.builder().fromClassLoader(classLoader).build(); + @Test + public void fromJarBackedClassLoader_load_with_component() throws IOException, LibraryLoadException { + Path jar = jar("test-data"); + try { + try (URLClassLoader classLoader = createClassLoader(jar)) { + NativeLoader loader = + NativeLoader.builder().fromClassLoader(classLoader).build(); + + // lib file is a dummy, so fails during loading and linking + assertThrows(LibraryLoadException.class, () -> loader.load("comp1", "dummy")); + + TestLibraryLoadingListener scopedListener2 = new TestLibraryLoadingListener() + .expectResolveDynamic("comp2", "dummy") + .expectTempFileCreated("comp2", "dummy") + .expectLoadFailure("comp2", "dummy") + .expectTempFileCleanup("comp2", "dummy"); + + assertThrows(LibraryLoadException.class, () -> loader.load("comp2", "dummy", scopedListener2)); + + scopedListener2.assertDone(); + } + } finally { + deleteHelper(jar); + } + } - // lib file is a dummy, so fails during loading and linking - assertThrows(LibraryLoadException.class, () -> loader.load("comp1", "dummy")); + @Test + public void fromJarBackedClassLoader_with_tempDir() throws IOException, LibraryLoadException { + Path jar = jar("test-data"); + try { + Path tempDir = Paths.get("temp"); + deleteHelper(tempDir); + + try (URLClassLoader classLoader = createClassLoader(jar)) { + NativeLoader loader = NativeLoader.builder() + .fromClassLoader(classLoader) + .tempDir(tempDir) + .build(); + try (LibFile lib = loader.resolveDynamic("dummy")) { + // loaded from a jar, so copied to temp file + assertTempFile(lib); + } + } finally { + deleteHelper(tempDir); + } + } finally { + deleteHelper(jar); + } + } - TestLibraryLoadingListener scopedListener2 = - new TestLibraryLoadingListener() - .expectResolveDynamic("comp2", "dummy") - .expectTempFileCreated("comp2", "dummy") - .expectLoadFailure("comp2", "dummy") - .expectTempFileCleanup("comp2", "dummy"); + @Test + public void fromJarBackedClassLoader_with_unwritable_tempDir() throws IOException, LibraryLoadException { + Path jar = jar("test-data"); + try { + Path noWriteDir = Paths.get("no-write-temp"); + deleteHelper(noWriteDir); + Files.createDirectories(noWriteDir, posixAttr("r-x------")); + + try (URLClassLoader classLoader = createClassLoader(jar)) { + NativeLoader loader = NativeLoader.builder() + .fromClassLoader(classLoader) + .tempDir(noWriteDir) + .build(); + + TestLibraryLoadingListener scopedListener = new TestLibraryLoadingListener() + .expectResolveDynamic("dummy") + .expectTempFileCreationFailure("dummy"); + + // unable to resolve to a File because tempDir isn't writable + assertThrows(LibraryLoadException.class, () -> loader.resolveDynamic("dummy", scopedListener)); + + scopedListener.assertDone(); + } finally { + deleteHelper(noWriteDir); + } + } finally { + deleteHelper(jar); + } + } - assertThrows( - LibraryLoadException.class, () -> loader.load("comp2", "dummy", scopedListener2)); + @Test + public void fromJarBackedClassLoader_with_locked_file() throws IOException, LibraryLoadException { + Path jar = jar("test-data"); + try { + Path tempDir = Paths.get("temp"); + deleteHelper(tempDir); + + try (URLClassLoader classLoader = createClassLoader(jar)) { + NativeLoader loader = NativeLoader.builder() + .fromClassLoader(classLoader) + .tempDir(tempDir) + .build(); + try (LibFile lib = loader.resolveDynamic("dummy")) { + // loaded from a jar, so copied to temp file + assertTempFile(lib); + + // simulating lock - by blocking ability to delete from parent dir + // forces fallback to deleteOnExit + Files.setPosixFilePermissions(lib.toPath().getParent(), posixPerms("r-x------")); + } + } finally { + deleteHelper(tempDir); + } + } finally { + deleteHelper(jar); + } + } - scopedListener2.assertDone(); - } - } finally { - deleteHelper(jar); + void deleteHelper(Path dir) { + try { + Files.setPosixFilePermissions(dir, posixPerms("rwx------")); + Files.delete(dir); + } catch (IOException e) { + } } - } - @Test - public void fromJarBackedClassLoader_with_tempDir() throws IOException, LibraryLoadException { - Path jar = jar("test-data"); - try { - Path tempDir = Paths.get("temp"); - deleteHelper(tempDir); + static URLClassLoader createClassLoader(Path... paths) { + return new URLClassLoader(urls(paths)); + } - try (URLClassLoader classLoader = createClassLoader(jar)) { - NativeLoader loader = - NativeLoader.builder().fromClassLoader(classLoader).tempDir(tempDir).build(); - try (LibFile lib = loader.resolveDynamic("dummy")) { - // loaded from a jar, so copied to temp file - assertTempFile(lib); + static URL[] urls(Path... paths) { + URL[] urls = new URL[paths.length]; + for (int i = 0; i < urls.length; ++i) { + try { + urls[i] = paths[i].toUri().toURL(); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } } - } finally { - deleteHelper(tempDir); - } - } finally { - deleteHelper(jar); - } - } - - @Test - public void fromJarBackedClassLoader_with_unwritable_tempDir() - throws IOException, LibraryLoadException { - Path jar = jar("test-data"); - try { - Path noWriteDir = Paths.get("no-write-temp"); - deleteHelper(noWriteDir); - Files.createDirectories(noWriteDir, posixAttr("r-x------")); - - try (URLClassLoader classLoader = createClassLoader(jar)) { - NativeLoader loader = - NativeLoader.builder().fromClassLoader(classLoader).tempDir(noWriteDir).build(); + return urls; + } - TestLibraryLoadingListener scopedListener = - new TestLibraryLoadingListener() - .expectResolveDynamic("dummy") - .expectTempFileCreationFailure("dummy"); + static Path jar(String dirName) { + return jar(Paths.get(dirName)); + } - // unable to resolve to a File because tempDir isn't writable - assertThrows( - LibraryLoadException.class, () -> loader.resolveDynamic("dummy", scopedListener)); + static Path jar(Path dir) { + try { + return jarHelper(dir); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } - scopedListener.assertDone(); - } finally { - deleteHelper(noWriteDir); - } - } finally { - deleteHelper(jar); - } - } - - @Test - public void fromJarBackedClassLoader_with_locked_file() throws IOException, LibraryLoadException { - Path jar = jar("test-data"); - try { - Path tempDir = Paths.get("temp"); - deleteHelper(tempDir); - - try (URLClassLoader classLoader = createClassLoader(jar)) { - NativeLoader loader = - NativeLoader.builder().fromClassLoader(classLoader).tempDir(tempDir).build(); - try (LibFile lib = loader.resolveDynamic("dummy")) { - // loaded from a jar, so copied to temp file - assertTempFile(lib); + static Path jarHelper(Path dir) throws IOException { + Path jarPath = Files.createTempFile(dir.toFile().getName(), ".jar", posixAttr("rwx------")); - // simulating lock - by blocking ability to delete from parent dir - // forces fallback to deleteOnExit - Files.setPosixFilePermissions(lib.toPath().getParent(), posixPerms("r-x------")); - } - } finally { - deleteHelper(tempDir); - } - } finally { - deleteHelper(jar); - } - } - - void deleteHelper(Path dir) { - try { - Files.setPosixFilePermissions(dir, posixPerms("rwx------")); - Files.delete(dir); - } catch (IOException e) { - } - } - - static URLClassLoader createClassLoader(Path... paths) { - return new URLClassLoader(urls(paths)); - } - - static URL[] urls(Path... paths) { - URL[] urls = new URL[paths.length]; - for (int i = 0; i < urls.length; ++i) { - try { - urls[i] = paths[i].toUri().toURL(); - } catch (MalformedURLException e) { - throw new IllegalStateException(e); - } - } - return urls; - } - - static Path jar(String dirName) { - return jar(Paths.get(dirName)); - } - - static Path jar(Path dir) { - try { - return jarHelper(dir); - } catch (IOException e) { - throw new IllegalStateException(e); - } - } - - static Path jarHelper(Path dir) throws IOException { - Path jarPath = Files.createTempFile(dir.toFile().getName(), ".jar", posixAttr("rwx------")); - - try (JarOutputStream jarStream = new JarOutputStream(Files.newOutputStream(jarPath))) { - Files.walk(dir) - .filter(path -> !Files.isDirectory(path)) - .forEach( - path -> { + try (JarOutputStream jarStream = new JarOutputStream(Files.newOutputStream(jarPath))) { + Files.walk(dir).filter(path -> !Files.isDirectory(path)).forEach(path -> { try { - JarEntry jarEntry = new JarEntry(dir.relativize(path).toString()); - jarStream.putNextEntry(jarEntry); - Files.copy(path, jarStream); - jarStream.closeEntry(); + JarEntry jarEntry = new JarEntry(dir.relativize(path).toString()); + jarStream.putNextEntry(jarEntry); + Files.copy(path, jarStream); + jarStream.closeEntry(); } catch (IOException e) { - throw new UncheckedIOException(e); + throw new UncheckedIOException(e); } - }); - } - return jarPath; - } - - static FileAttribute> posixAttr(String posixStr) { - return PosixFilePermissions.asFileAttribute(posixPerms(posixStr)); - } - - static Set posixPerms(String posixStr) { - return PosixFilePermissions.fromString(posixStr); - } - - static void assertPreloaded(LibFile lib) { - assertTrue(lib.isPreloaded()); - assertNull(lib.toFile()); - assertNull(lib.toPath()); - assertNull(lib.getAbsolutePath()); - assertFalse(lib.needsCleanup); - } - - static void assertRegularFile(LibFile lib) { - assertFalse(lib.isPreloaded()); - assertNotNull(lib.toFile()); - assertNotNull(lib.toPath()); - assertTrue(lib.toFile().exists()); - assertNotNull(lib.getAbsolutePath()); - assertFalse(lib.needsCleanup); - } - - static void assertTempFile(LibFile lib) { - assertFalse(lib.isPreloaded()); - assertNotNull(lib.toFile()); - assertNotNull(lib.toPath()); - assertTrue(lib.toFile().exists()); - assertNotNull(lib.getAbsolutePath()); - assertTrue(lib.needsCleanup); - } + }); + } + return jarPath; + } + + static FileAttribute> posixAttr(String posixStr) { + return PosixFilePermissions.asFileAttribute(posixPerms(posixStr)); + } + + static Set posixPerms(String posixStr) { + return PosixFilePermissions.fromString(posixStr); + } + + static void assertPreloaded(LibFile lib) { + assertTrue(lib.isPreloaded()); + assertNull(lib.toFile()); + assertNull(lib.toPath()); + assertNull(lib.getAbsolutePath()); + assertFalse(lib.needsCleanup); + } + + static void assertRegularFile(LibFile lib) { + assertFalse(lib.isPreloaded()); + assertNotNull(lib.toFile()); + assertNotNull(lib.toPath()); + assertTrue(lib.toFile().exists()); + assertNotNull(lib.getAbsolutePath()); + assertFalse(lib.needsCleanup); + } + + static void assertTempFile(LibFile lib) { + assertFalse(lib.isPreloaded()); + assertNotNull(lib.toFile()); + assertNotNull(lib.toPath()); + assertTrue(lib.toFile().exists()); + assertNotNull(lib.getAbsolutePath()); + assertTrue(lib.needsCleanup); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/NestedDirLibraryResolverTest.java b/components/native-loader/src/test/java/datadog/nativeloader/NestedDirLibraryResolverTest.java index a85e68a269a..cbb4b0ae7ee 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/NestedDirLibraryResolverTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/NestedDirLibraryResolverTest.java @@ -11,54 +11,46 @@ import org.junit.jupiter.api.Test; public class NestedDirLibraryResolverTest { - @Test - public void linux_x86_64_libc() { - test( - TestPlatformSpec.of(LINUX, X86_64, GLIBC), - "linux/x86_64/libc/libtest.so", - "linux/x86_64/libtest.so", - "linux/libtest.so", - "libtest.so"); - } - - @Test - public void linux_x86_64_musl() { - test( - TestPlatformSpec.of(LINUX, X86_64, MUSL), - "linux/x86_64/musl/libtest.so", - "linux/x86_64/libtest.so", - "linux/libtest.so", - "libtest.so"); - } - - @Test - public void osx_x86_64() { - test( - TestPlatformSpec.of(MAC, X86_64), - "macos/x86_64/libtest.dylib", - "macos/libtest.dylib", - "libtest.dylib"); - } - - @Test - public void osx_aarch() { - test( - TestPlatformSpec.of(MAC, AARCH64), - "macos/aarch64/libtest.dylib", - "macos/libtest.dylib", - "libtest.dylib"); - } - - @Test - public void windows_x86_64() { - test(TestPlatformSpec.of(WINDOWS, X86_64), "win/x86_64/test.dll", "win/test.dll", "test.dll"); - } - - static final void test(PlatformSpec platformSpec, String... expectedPaths) { - CapturingPathLocator.testFailOnExceptions( - NestedDirLibraryResolver.INSTANCE, - platformSpec, - CapturingPathLocator.WITH_OMIT_COMP_FALLBACK, - expectedPaths); - } + @Test + public void linux_x86_64_libc() { + test( + TestPlatformSpec.of(LINUX, X86_64, GLIBC), + "linux/x86_64/libc/libtest.so", + "linux/x86_64/libtest.so", + "linux/libtest.so", + "libtest.so"); + } + + @Test + public void linux_x86_64_musl() { + test( + TestPlatformSpec.of(LINUX, X86_64, MUSL), + "linux/x86_64/musl/libtest.so", + "linux/x86_64/libtest.so", + "linux/libtest.so", + "libtest.so"); + } + + @Test + public void osx_x86_64() { + test(TestPlatformSpec.of(MAC, X86_64), "macos/x86_64/libtest.dylib", "macos/libtest.dylib", "libtest.dylib"); + } + + @Test + public void osx_aarch() { + test(TestPlatformSpec.of(MAC, AARCH64), "macos/aarch64/libtest.dylib", "macos/libtest.dylib", "libtest.dylib"); + } + + @Test + public void windows_x86_64() { + test(TestPlatformSpec.of(WINDOWS, X86_64), "win/x86_64/test.dll", "win/test.dll", "test.dll"); + } + + static final void test(PlatformSpec platformSpec, String... expectedPaths) { + CapturingPathLocator.testFailOnExceptions( + NestedDirLibraryResolver.INSTANCE, + platformSpec, + CapturingPathLocator.WITH_OMIT_COMP_FALLBACK, + expectedPaths); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/PathLocationHelperTest.java b/components/native-loader/src/test/java/datadog/nativeloader/PathLocationHelperTest.java index dae51056efd..ce4dbfaaef7 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/PathLocationHelperTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/PathLocationHelperTest.java @@ -9,97 +9,94 @@ import org.junit.jupiter.api.Test; public final class PathLocationHelperTest { - @Test - public void happyPath() throws MalformedURLException { - URL expected = new URL("http://localhost"); - - PathLocator pathLocator = (comp, path) -> expected; - PathLocatorHelper helper = new PathLocatorHelper("test", pathLocator); - - URL result = helper.locate(null, "path"); - assertSame(expected, result); - } - - @Test - public void throwingException() { - Exception expectedCause = new IllegalStateException("wrong!"); - PathLocator throwingPathLocator = - (comp, path) -> { - throw expectedCause; + @Test + public void happyPath() throws MalformedURLException { + URL expected = new URL("http://localhost"); + + PathLocator pathLocator = (comp, path) -> expected; + PathLocatorHelper helper = new PathLocatorHelper("test", pathLocator); + + URL result = helper.locate(null, "path"); + assertSame(expected, result); + } + + @Test + public void throwingException() { + Exception expectedCause = new IllegalStateException("wrong!"); + PathLocator throwingPathLocator = (comp, path) -> { + throw expectedCause; }; - PathLocatorHelper helper = new PathLocatorHelper("test", throwingPathLocator); + PathLocatorHelper helper = new PathLocatorHelper("test", throwingPathLocator); - // on exception, PathLocator returns null, but stores the exception for use when tryThrow is - // called later - URL result = helper.locate(null, "path"); - assertNull(result); + // on exception, PathLocator returns null, but stores the exception for use when tryThrow is + // called later + URL result = helper.locate(null, "path"); + assertNull(result); - try { - // should throw, since an exception occurred earlier - helper.tryThrow(); + try { + // should throw, since an exception occurred earlier + helper.tryThrow(); - fail("should raise a LibraryLoadException"); - } catch (LibraryLoadException e) { - assertSame(expectedCause, e.getCause()); + fail("should raise a LibraryLoadException"); + } catch (LibraryLoadException e) { + assertSame(expectedCause, e.getCause()); + } } - } - - @Test - public void throwingLibraryLoadException() { - Exception expectedCause = new LibraryLoadException("test", "wrong!"); - PathLocator throwingPathLocator = - (comp, path) -> { - throw expectedCause; + + @Test + public void throwingLibraryLoadException() { + Exception expectedCause = new LibraryLoadException("test", "wrong!"); + PathLocator throwingPathLocator = (comp, path) -> { + throw expectedCause; }; - PathLocatorHelper helper = new PathLocatorHelper("test", throwingPathLocator); + PathLocatorHelper helper = new PathLocatorHelper("test", throwingPathLocator); - // on exception, PathLocator returns null, but stores the exception for use when tryThrow is - // called later - URL result = helper.locate(null, "path"); - assertNull(result); + // on exception, PathLocator returns null, but stores the exception for use when tryThrow is + // called later + URL result = helper.locate(null, "path"); + assertNull(result); - try { - // should throw, since an exception occurred earlier - helper.tryThrow(); + try { + // should throw, since an exception occurred earlier + helper.tryThrow(); - fail("should raise a LibraryLoadException"); - } catch (LibraryLoadException e) { - assertSame(expectedCause, e); + fail("should raise a LibraryLoadException"); + } catch (LibraryLoadException e) { + assertSame(expectedCause, e); + } } - } - - @Test - public void throwingMultipleExceptions() { - Exception firstCause = new IllegalStateException("wrong!"); - Exception secondCause = new IllegalStateException("wrong again!"); - - PathLocator throwingPathLocator = - (comp, path) -> { - if (path.equals("firstPath")) { - throw firstCause; - } else { - throw secondCause; - } + + @Test + public void throwingMultipleExceptions() { + Exception firstCause = new IllegalStateException("wrong!"); + Exception secondCause = new IllegalStateException("wrong again!"); + + PathLocator throwingPathLocator = (comp, path) -> { + if (path.equals("firstPath")) { + throw firstCause; + } else { + throw secondCause; + } }; - // on exception, PathLocator returns null, but stores the first exception - PathLocatorHelper helper = new PathLocatorHelper("test", throwingPathLocator); - URL firstResult = helper.locate(null, "firstPath"); - assertNull(firstResult); + // on exception, PathLocator returns null, but stores the first exception + PathLocatorHelper helper = new PathLocatorHelper("test", throwingPathLocator); + URL firstResult = helper.locate(null, "firstPath"); + assertNull(firstResult); - // still retgurns null, but doesn't store exception - URL secondResult = helper.locate(null, "secondPath"); - assertNull(secondResult); + // still retgurns null, but doesn't store exception + URL secondResult = helper.locate(null, "secondPath"); + assertNull(secondResult); - try { - helper.tryThrow(); + try { + helper.tryThrow(); - fail("should raise a LibraryLoadException"); - } catch (LibraryLoadException e) { - // expect just the first cause to be captured - assertSame(firstCause, e.getCause()); + fail("should raise a LibraryLoadException"); + } catch (LibraryLoadException e) { + // expect just the first cause to be captured + assertSame(firstCause, e.getCause()); + } } - } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/PathLocatorsTest.java b/components/native-loader/src/test/java/datadog/nativeloader/PathLocatorsTest.java index 84fb1134a70..6bbc321cb5e 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/PathLocatorsTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/PathLocatorsTest.java @@ -16,97 +16,97 @@ * Although equals is used in other tests & usual contract obliges defining hashCode, too */ public class PathLocatorsTest { - @Test - public void dirBased_equals() { - PathLocator dirLocator1 = PathLocators.fromLibDirs("foo"); - PathLocator dirLocator2 = PathLocators.fromLibDirs(new File("foo")); - PathLocator dirLocator3 = PathLocators.fromLibDirs(Paths.get("foo")); + @Test + public void dirBased_equals() { + PathLocator dirLocator1 = PathLocators.fromLibDirs("foo"); + PathLocator dirLocator2 = PathLocators.fromLibDirs(new File("foo")); + PathLocator dirLocator3 = PathLocators.fromLibDirs(Paths.get("foo")); - assertEqualsAndHashCode(dirLocator1, dirLocator2); - assertEqualsAndHashCode(dirLocator1, dirLocator3); - } + assertEqualsAndHashCode(dirLocator1, dirLocator2); + assertEqualsAndHashCode(dirLocator1, dirLocator3); + } - @Test - public void dirBased_notEquals() { - PathLocator dirLocator1 = PathLocators.fromLibDirs("foo1"); - PathLocator dirLocator2 = PathLocators.fromLibDirs(new File("foo2")); - PathLocator dirLocator3 = PathLocators.fromLibDirs(Paths.get("foo3")); + @Test + public void dirBased_notEquals() { + PathLocator dirLocator1 = PathLocators.fromLibDirs("foo1"); + PathLocator dirLocator2 = PathLocators.fromLibDirs(new File("foo2")); + PathLocator dirLocator3 = PathLocators.fromLibDirs(Paths.get("foo3")); - assertNotEquals(dirLocator1, dirLocator2); - assertNotEquals(dirLocator1, dirLocator3); - } + assertNotEquals(dirLocator1, dirLocator2); + assertNotEquals(dirLocator1, dirLocator3); + } - @Test - public void dirBased_diffType_notEquals() { - PathLocator dirLocator = PathLocators.fromLibDirs("foo1"); - PathLocator otherLocator = (comp, path) -> null; + @Test + public void dirBased_diffType_notEquals() { + PathLocator dirLocator = PathLocators.fromLibDirs("foo1"); + PathLocator otherLocator = (comp, path) -> null; - // be explicit about which equals is being used - assertFalse(dirLocator.equals(otherLocator)); - } + // be explicit about which equals is being used + assertFalse(dirLocator.equals(otherLocator)); + } - @Test - public void libPath() { - PathLocator dirLocator1 = PathLocators.fromLibPathString("foo:bar:baz"); - PathLocator dirLocator2 = PathLocators.fromLibDirs("foo", "bar", "baz"); + @Test + public void libPath() { + PathLocator dirLocator1 = PathLocators.fromLibPathString("foo:bar:baz"); + PathLocator dirLocator2 = PathLocators.fromLibDirs("foo", "bar", "baz"); - assertEqualsAndHashCode(dirLocator1, dirLocator2); - } + assertEqualsAndHashCode(dirLocator1, dirLocator2); + } - @Test - public void classLoaderBased_equals() { - ClassLoader loader = ClassLoader.getSystemClassLoader(); + @Test + public void classLoaderBased_equals() { + ClassLoader loader = ClassLoader.getSystemClassLoader(); - PathLocator locator1 = PathLocators.fromClassLoader(loader); - PathLocator locator2 = PathLocators.fromClassLoader(loader); + PathLocator locator1 = PathLocators.fromClassLoader(loader); + PathLocator locator2 = PathLocators.fromClassLoader(loader); - assertEqualsAndHashCode(locator1, locator2); - } + assertEqualsAndHashCode(locator1, locator2); + } - @Test - public void classLoaderBased_with_resource_equals() { - ClassLoader loader = ClassLoader.getSystemClassLoader(); + @Test + public void classLoaderBased_with_resource_equals() { + ClassLoader loader = ClassLoader.getSystemClassLoader(); - PathLocator locator1 = PathLocators.fromClassLoader(loader, "resource"); - PathLocator locator2 = PathLocators.fromClassLoader(loader, "resource"); + PathLocator locator1 = PathLocators.fromClassLoader(loader, "resource"); + PathLocator locator2 = PathLocators.fromClassLoader(loader, "resource"); - assertEqualsAndHashCode(locator1, locator2); - } + assertEqualsAndHashCode(locator1, locator2); + } - @Test - public void classLoaderBased_notEequals_loader() { - ClassLoader loader1 = ClassLoader.getSystemClassLoader(); - ClassLoader loader2 = new URLClassLoader(new URL[0]); + @Test + public void classLoaderBased_notEequals_loader() { + ClassLoader loader1 = ClassLoader.getSystemClassLoader(); + ClassLoader loader2 = new URLClassLoader(new URL[0]); - PathLocator locator1 = PathLocators.fromClassLoader(loader1); - PathLocator locator2 = PathLocators.fromClassLoader(loader2); + PathLocator locator1 = PathLocators.fromClassLoader(loader1); + PathLocator locator2 = PathLocators.fromClassLoader(loader2); - assertNotEquals(locator1, locator2); - } + assertNotEquals(locator1, locator2); + } - @Test - public void classLoaderBased_notEequals_resource() { - ClassLoader loader = ClassLoader.getSystemClassLoader(); + @Test + public void classLoaderBased_notEequals_resource() { + ClassLoader loader = ClassLoader.getSystemClassLoader(); - PathLocator locator1 = PathLocators.fromClassLoader(loader, "resource1"); - PathLocator locator2 = PathLocators.fromClassLoader(loader, "resource2"); + PathLocator locator1 = PathLocators.fromClassLoader(loader, "resource1"); + PathLocator locator2 = PathLocators.fromClassLoader(loader, "resource2"); - assertNotEquals(locator1, locator2); - } + assertNotEquals(locator1, locator2); + } - @Test - public void classLoaderBased_diffType_notEequals() { - ClassLoader loader = ClassLoader.getSystemClassLoader(); + @Test + public void classLoaderBased_diffType_notEequals() { + ClassLoader loader = ClassLoader.getSystemClassLoader(); - PathLocator locator1 = PathLocators.fromClassLoader(loader, "resource1"); - PathLocator locator2 = (comp, path) -> null; + PathLocator locator1 = PathLocators.fromClassLoader(loader, "resource1"); + PathLocator locator2 = (comp, path) -> null; - // be explicit about which equals is being used - assertFalse(locator1.equals(locator2)); - } + // be explicit about which equals is being used + assertFalse(locator1.equals(locator2)); + } - static final void assertEqualsAndHashCode(Object lhs, Object rhs) { - assertEquals(lhs.hashCode(), rhs.hashCode()); - assertTrue(lhs.equals(rhs)); - } + static final void assertEqualsAndHashCode(Object lhs, Object rhs) { + assertEquals(lhs.hashCode(), rhs.hashCode()); + assertTrue(lhs.equals(rhs)); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/PathUtilsTest.java b/components/native-loader/src/test/java/datadog/nativeloader/PathUtilsTest.java index f2432eaabdb..a110b24b191 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/PathUtilsTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/PathUtilsTest.java @@ -18,149 +18,148 @@ import org.junit.jupiter.api.Test; public class PathUtilsTest { - @Test - public void libFileName_mac() { - assertEquals("libtest.dylib", PathUtils.libFileName(mac(), "test")); - } - - @Test - public void libFileName_linux() { - assertEquals("libtest.so", PathUtils.libFileName(linux(), "test")); - } - - @Test - public void libFileName_windows() { - assertEquals("test.dll", PathUtils.libFileName(windows(), "test")); - } - - @Test - public void libPrefix_mac() { - assertEquals("lib", PathUtils.libPrefix(mac())); - } - - @Test - public void libPrefix_linux() { - assertEquals("lib", PathUtils.libPrefix(linux())); - } - - @Test - public void libPrefix_windows() { - assertEquals("", PathUtils.libPrefix(windows())); - } - - @Test - public void libPrefix_unsupportedOs() { - assertThrows(IllegalArgumentException.class, () -> PathUtils.libPrefix(unsupportedOs())); - } - - @Test - public void dynamicLibExtension_mac() { - assertEquals("dylib", PathUtils.dynamicLibExtension(mac())); - } - - @Test - public void dynamicLibExtension_linux() { - assertEquals("so", PathUtils.dynamicLibExtension(linux())); - } - - @Test - public void dynamicLibExtension_windows() { - assertEquals("dll", PathUtils.dynamicLibExtension(windows())); - } - - @Test - public void dynamicLibExtension_unsupportedOs() { - assertThrows( - IllegalArgumentException.class, () -> PathUtils.dynamicLibExtension(unsupportedOs())); - } - - @Test - public void osPart_linux() { - assertEquals("linux", PathUtils.osPartOf(linux())); - } - - @Test - public void osPart_mac() { - assertEquals("macos", PathUtils.osPartOf(mac())); - } - - @Test - public void osPart_windows() { - assertEquals("win", PathUtils.osPartOf(windows())); - } - - @Test - public void osPart_unsupportedOs() { - assertThrows(IllegalArgumentException.class, () -> PathUtils.osPartOf(unsupportedOs())); - } - - @Test - public void archPart_x86_32() { - assertEquals("x86_32", PathUtils.archPartOf(linux_x86_32())); - } - - @Test - public void archPart_x86_64() { - assertEquals("x86_64", PathUtils.archPartOf(linux_x86_64())); - } - - @Test - public void archPart_arm32() { - assertEquals("arm32", PathUtils.archPartOf(linux_arm32())); - } - - @Test - public void archPart_arm64() { - assertEquals("aarch64", PathUtils.archPartOf(linux_arm64())); - } - - @Test - public void archPart_unsupportedArch() { - assertThrows(IllegalArgumentException.class, () -> PathUtils.archPartOf(unsupportedArch())); - } - - @Test - public void libcPart_linux_glibc() { - assertEquals("libc", PathUtils.libcPartOf(linux_glibc())); - } - - @Test - public void libcPart_linux_musl() { - assertEquals("musl", PathUtils.libcPartOf(linux_musl())); - } - - @Test - public void libcPart_mac() { - assertNull(PathUtils.libcPartOf(mac())); - } - - @Test - public void libcPart_windows() { - assertNull(PathUtils.libcPartOf(windows())); - } - - @Test - public void concat_nonEmpty_nonEmpty() { - assertEquals("foo/bar", PathUtils.concatPath("foo", "bar")); - } - - @Test - public void concat_null_nonEmpty() { - assertEquals("bar", PathUtils.concatPath(null, "bar")); - } - - @Test - public void concat_nonEmpty_null() { - assertEquals("foo", PathUtils.concatPath("foo", null)); - } - - @Test - public void concat_empty_nonEmpty() { - assertEquals("bar", PathUtils.concatPath("", "bar")); - } - - @Test - public void concat_null_empty_nonEmpty() { - assertEquals("bar", PathUtils.concatPath(null, "", "bar")); - } + @Test + public void libFileName_mac() { + assertEquals("libtest.dylib", PathUtils.libFileName(mac(), "test")); + } + + @Test + public void libFileName_linux() { + assertEquals("libtest.so", PathUtils.libFileName(linux(), "test")); + } + + @Test + public void libFileName_windows() { + assertEquals("test.dll", PathUtils.libFileName(windows(), "test")); + } + + @Test + public void libPrefix_mac() { + assertEquals("lib", PathUtils.libPrefix(mac())); + } + + @Test + public void libPrefix_linux() { + assertEquals("lib", PathUtils.libPrefix(linux())); + } + + @Test + public void libPrefix_windows() { + assertEquals("", PathUtils.libPrefix(windows())); + } + + @Test + public void libPrefix_unsupportedOs() { + assertThrows(IllegalArgumentException.class, () -> PathUtils.libPrefix(unsupportedOs())); + } + + @Test + public void dynamicLibExtension_mac() { + assertEquals("dylib", PathUtils.dynamicLibExtension(mac())); + } + + @Test + public void dynamicLibExtension_linux() { + assertEquals("so", PathUtils.dynamicLibExtension(linux())); + } + + @Test + public void dynamicLibExtension_windows() { + assertEquals("dll", PathUtils.dynamicLibExtension(windows())); + } + + @Test + public void dynamicLibExtension_unsupportedOs() { + assertThrows(IllegalArgumentException.class, () -> PathUtils.dynamicLibExtension(unsupportedOs())); + } + + @Test + public void osPart_linux() { + assertEquals("linux", PathUtils.osPartOf(linux())); + } + + @Test + public void osPart_mac() { + assertEquals("macos", PathUtils.osPartOf(mac())); + } + + @Test + public void osPart_windows() { + assertEquals("win", PathUtils.osPartOf(windows())); + } + + @Test + public void osPart_unsupportedOs() { + assertThrows(IllegalArgumentException.class, () -> PathUtils.osPartOf(unsupportedOs())); + } + + @Test + public void archPart_x86_32() { + assertEquals("x86_32", PathUtils.archPartOf(linux_x86_32())); + } + + @Test + public void archPart_x86_64() { + assertEquals("x86_64", PathUtils.archPartOf(linux_x86_64())); + } + + @Test + public void archPart_arm32() { + assertEquals("arm32", PathUtils.archPartOf(linux_arm32())); + } + + @Test + public void archPart_arm64() { + assertEquals("aarch64", PathUtils.archPartOf(linux_arm64())); + } + + @Test + public void archPart_unsupportedArch() { + assertThrows(IllegalArgumentException.class, () -> PathUtils.archPartOf(unsupportedArch())); + } + + @Test + public void libcPart_linux_glibc() { + assertEquals("libc", PathUtils.libcPartOf(linux_glibc())); + } + + @Test + public void libcPart_linux_musl() { + assertEquals("musl", PathUtils.libcPartOf(linux_musl())); + } + + @Test + public void libcPart_mac() { + assertNull(PathUtils.libcPartOf(mac())); + } + + @Test + public void libcPart_windows() { + assertNull(PathUtils.libcPartOf(windows())); + } + + @Test + public void concat_nonEmpty_nonEmpty() { + assertEquals("foo/bar", PathUtils.concatPath("foo", "bar")); + } + + @Test + public void concat_null_nonEmpty() { + assertEquals("bar", PathUtils.concatPath(null, "bar")); + } + + @Test + public void concat_nonEmpty_null() { + assertEquals("foo", PathUtils.concatPath("foo", null)); + } + + @Test + public void concat_empty_nonEmpty() { + assertEquals("bar", PathUtils.concatPath("", "bar")); + } + + @Test + public void concat_null_empty_nonEmpty() { + assertEquals("bar", PathUtils.concatPath(null, "", "bar")); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/PlatformSpecTest.java b/components/native-loader/src/test/java/datadog/nativeloader/PlatformSpecTest.java index 3dce67b4d03..be91f6649db 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/PlatformSpecTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/PlatformSpecTest.java @@ -15,62 +15,62 @@ import org.junit.jupiter.api.Test; public class PlatformSpecTest { - @Test - public void macSpec() { - assertTrue(mac().isMac()); - assertFalse(mac().isUnknownOs()); - } + @Test + public void macSpec() { + assertTrue(mac().isMac()); + assertFalse(mac().isUnknownOs()); + } - @Test - public void linuxSpec() { - assertTrue(linux().isLinux()); - assertFalse(linux().isUnknownOs()); - } + @Test + public void linuxSpec() { + assertTrue(linux().isLinux()); + assertFalse(linux().isUnknownOs()); + } - @Test - public void windowsSpec() { - assertTrue(windows().isWindows()); - assertFalse(windows().isUnknownOs()); - } + @Test + public void windowsSpec() { + assertTrue(windows().isWindows()); + assertFalse(windows().isUnknownOs()); + } - @Test - public void unsupportedOsSpec() { - assertFalse(unsupportedOs().isMac()); - assertFalse(unsupportedOs().isLinux()); - assertFalse(unsupportedOs().isWindows()); - assertTrue(unsupportedOs().isUnknownOs()); - } + @Test + public void unsupportedOsSpec() { + assertFalse(unsupportedOs().isMac()); + assertFalse(unsupportedOs().isLinux()); + assertFalse(unsupportedOs().isWindows()); + assertTrue(unsupportedOs().isUnknownOs()); + } - @Test - public void arm32Spec() { - assertTrue(linux_arm32().isArm32()); - assertFalse(linux_arm32().isUnknownArch()); - } + @Test + public void arm32Spec() { + assertTrue(linux_arm32().isArm32()); + assertFalse(linux_arm32().isUnknownArch()); + } - @Test - public void arm64Spec() { - assertTrue(linux_arm64().isAarch64()); - assertFalse(linux_arm64().isUnknownArch()); - } + @Test + public void arm64Spec() { + assertTrue(linux_arm64().isAarch64()); + assertFalse(linux_arm64().isUnknownArch()); + } - @Test - public void x86_32Spec() { - assertTrue(linux_x86_32().isX86_32()); - assertFalse(linux_x86_32().isUnknownArch()); - } + @Test + public void x86_32Spec() { + assertTrue(linux_x86_32().isX86_32()); + assertFalse(linux_x86_32().isUnknownArch()); + } - @Test - public void x86_64Spec() { - assertTrue(linux_x86_64().isX86_64()); - assertFalse(linux_x86_64().isUnknownArch()); - } + @Test + public void x86_64Spec() { + assertTrue(linux_x86_64().isX86_64()); + assertFalse(linux_x86_64().isUnknownArch()); + } - @Test - public void unsupportedArchSpec() { - assertFalse(unsupportedArch().isArm32()); - assertFalse(unsupportedArch().isAarch64()); - assertFalse(unsupportedArch().isX86_32()); - assertFalse(unsupportedArch().isX86_64()); - assertTrue(unsupportedArch().isUnknownArch()); - } + @Test + public void unsupportedArchSpec() { + assertFalse(unsupportedArch().isArm32()); + assertFalse(unsupportedArch().isAarch64()); + assertFalse(unsupportedArch().isX86_32()); + assertFalse(unsupportedArch().isX86_64()); + assertTrue(unsupportedArch().isUnknownArch()); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/TestLibraryLoadingListener.java b/components/native-loader/src/test/java/datadog/nativeloader/TestLibraryLoadingListener.java index 65af2e522a0..fba3d61447f 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/TestLibraryLoadingListener.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/TestLibraryLoadingListener.java @@ -14,522 +14,462 @@ import java.util.LinkedList; public final class TestLibraryLoadingListener implements LibraryLoadingListener { - private final LinkedList checks; - - private Check failedCheck = null; - private Throwable failedCause = null; - - // By design, listeners are supposed to receive the underlying cause not a LibraryLoadException - // directly - static final ThrowableCheck NOT_LIB_LOAD_EXCEPTION = - (t) -> { - assertFalse( - t instanceof LibraryLoadException, - "LibraryLoadException - instead of underlying cause"); - }; - - public TestLibraryLoadingListener() { - this.checks = new LinkedList<>(); - } - - private TestLibraryLoadingListener(TestLibraryLoadingListener that) { - this.checks = new LinkedList<>(that.checks); - } - - public TestLibraryLoadingListener expectResolveDynamic(String expectedLibName) { - return this.expectResolveDynamic(new LibCheck(expectedLibName)); - } - - public TestLibraryLoadingListener expectResolveDynamic( - String expectedComponent, String expectedLibName) { - return this.expectResolveDynamic(new LibCheck(expectedComponent, expectedLibName)); - } - - public TestLibraryLoadingListener expectResolveDynamic( - PlatformSpec expectedPlatformSpec, String expectedLibName) { - return this.expectResolveDynamic(new LibCheck(expectedPlatformSpec, expectedLibName)); - } - - private TestLibraryLoadingListener expectResolveDynamic(LibCheck libCheck) { - return this.addCheck( - new Check("onResolveDynamic %s", libCheck) { - @Override - public void onResolveDynamic( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - URL optionalUrl) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - } - }); - } - - public TestLibraryLoadingListener expectResolvePreloaded(String expectedLibName) { - return this.expectResolvePreloaded(new LibCheck(expectedLibName)); - } - - private TestLibraryLoadingListener expectResolvePreloaded(LibCheck libCheck) { - return this.addCheck( - new Check("onResolveDynamic:preloaded %s", libCheck) { - @Override - public void onResolveDynamic( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - URL optionalUrl) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - assertTrue(isPreloaded); - } - }); - } - - public TestLibraryLoadingListener expectResolveDynamicFailure(String expectedLibName) { - return this.expectResolveDynamicFailure(new LibCheck(expectedLibName)); - } - - public TestLibraryLoadingListener expectResolveDynamicFailure( - String expectedLibName, Throwable expectedThrowable) { - return this.expectResolveDynamicFailure( - new LibCheck(expectedLibName), (t) -> assertSame(expectedThrowable, t)); - } - - private TestLibraryLoadingListener expectResolveDynamicFailure(LibCheck libCheck) { - return this.expectResolveDynamicFailure(libCheck, NOT_LIB_LOAD_EXCEPTION); - } - - private TestLibraryLoadingListener expectResolveDynamicFailure( - LibCheck libCheck, ThrowableCheck throwableCheck) { - return this.addCheck( - new Check("onResolveDynamicFailure %s", libCheck) { - @Override - public void onResolveDynamicFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - throwableCheck.assertMatches(optionalCause); - } - }); - } - - public TestLibraryLoadingListener expectLoad(String expectedLibName) { - return this.expectLoad(new LibCheck(expectedLibName)); - } - - public TestLibraryLoadingListener expectLoad(String expectedComponent, String expectedLibName) { - return this.expectLoad(new LibCheck(expectedComponent, expectedLibName)); - } - - private TestLibraryLoadingListener expectLoad(LibCheck libCheck) { - return this.addCheck( - new Check("onLoad %s", libCheck) { - @Override - public void onLoad( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - Path optionalLibPath) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - } + private final LinkedList checks; + + private Check failedCheck = null; + private Throwable failedCause = null; + + // By design, listeners are supposed to receive the underlying cause not a LibraryLoadException + // directly + static final ThrowableCheck NOT_LIB_LOAD_EXCEPTION = (t) -> { + assertFalse(t instanceof LibraryLoadException, "LibraryLoadException - instead of underlying cause"); + }; + + public TestLibraryLoadingListener() { + this.checks = new LinkedList<>(); + } + + private TestLibraryLoadingListener(TestLibraryLoadingListener that) { + this.checks = new LinkedList<>(that.checks); + } + + public TestLibraryLoadingListener expectResolveDynamic(String expectedLibName) { + return this.expectResolveDynamic(new LibCheck(expectedLibName)); + } + + public TestLibraryLoadingListener expectResolveDynamic(String expectedComponent, String expectedLibName) { + return this.expectResolveDynamic(new LibCheck(expectedComponent, expectedLibName)); + } + + public TestLibraryLoadingListener expectResolveDynamic(PlatformSpec expectedPlatformSpec, String expectedLibName) { + return this.expectResolveDynamic(new LibCheck(expectedPlatformSpec, expectedLibName)); + } + + private TestLibraryLoadingListener expectResolveDynamic(LibCheck libCheck) { + return this.addCheck(new Check("onResolveDynamic %s", libCheck) { + @Override + public void onResolveDynamic( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + URL optionalUrl) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + } }); - } - - public TestLibraryLoadingListener expectLoadPreloaded(String expectedLibName) { - return this.expectLoadPreloaded(new LibCheck(expectedLibName)); - } - - private TestLibraryLoadingListener expectLoadPreloaded(LibCheck libCheck) { - return this.addCheck( - new Check("onLoad:preloaded %s", libCheck) { - @Override - public void onLoad( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - Path optionalLibPath) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - assertTrue(isPreloaded); - } + } + + public TestLibraryLoadingListener expectResolvePreloaded(String expectedLibName) { + return this.expectResolvePreloaded(new LibCheck(expectedLibName)); + } + + private TestLibraryLoadingListener expectResolvePreloaded(LibCheck libCheck) { + return this.addCheck(new Check("onResolveDynamic:preloaded %s", libCheck) { + @Override + public void onResolveDynamic( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + URL optionalUrl) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + assertTrue(isPreloaded); + } }); - } - - public TestLibraryLoadingListener expectLoadFailure(String expectedLibName) { - return this.expectLoadFailure(new LibCheck(expectedLibName)); - } - - public TestLibraryLoadingListener expectLoadFailure( - String expectedComponent, String expectedLibName) { - return this.expectLoadFailure(new LibCheck(expectedComponent, expectedLibName)); - } - - private TestLibraryLoadingListener expectLoadFailure(LibCheck libCheck) { - return this.expectLoadFailure(libCheck, NOT_LIB_LOAD_EXCEPTION); - } - - private TestLibraryLoadingListener expectLoadFailure( - LibCheck libCheck, ThrowableCheck throwableCheck) { - return this.addCheck( - new Check("onLoadFailure %s", libCheck) { - @Override - public void onLoadFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - throwableCheck.assertMatches(optionalCause); - } + } + + public TestLibraryLoadingListener expectResolveDynamicFailure(String expectedLibName) { + return this.expectResolveDynamicFailure(new LibCheck(expectedLibName)); + } + + public TestLibraryLoadingListener expectResolveDynamicFailure(String expectedLibName, Throwable expectedThrowable) { + return this.expectResolveDynamicFailure(new LibCheck(expectedLibName), (t) -> assertSame(expectedThrowable, t)); + } + + private TestLibraryLoadingListener expectResolveDynamicFailure(LibCheck libCheck) { + return this.expectResolveDynamicFailure(libCheck, NOT_LIB_LOAD_EXCEPTION); + } + + private TestLibraryLoadingListener expectResolveDynamicFailure(LibCheck libCheck, ThrowableCheck throwableCheck) { + return this.addCheck(new Check("onResolveDynamicFailure %s", libCheck) { + @Override + public void onResolveDynamicFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + throwableCheck.assertMatches(optionalCause); + } }); - } - - public TestLibraryLoadingListener expectTempFileCreated(String expectedLibName) { - return this.expectTempFileCreated(new LibCheck(expectedLibName)); - } - - public TestLibraryLoadingListener expectTempFileCreated( - String expectedComponent, String expectedLibName) { - return this.expectTempFileCreated(new LibCheck(expectedComponent, expectedLibName)); - } - - private TestLibraryLoadingListener expectTempFileCreated(LibCheck libCheck) { - return this.addCheck( - new Check("onTempFileCreated %s", libCheck) { - @Override - public void onTempFileCreated( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - assertNotNull(tempFile); - } + } + + public TestLibraryLoadingListener expectLoad(String expectedLibName) { + return this.expectLoad(new LibCheck(expectedLibName)); + } + + public TestLibraryLoadingListener expectLoad(String expectedComponent, String expectedLibName) { + return this.expectLoad(new LibCheck(expectedComponent, expectedLibName)); + } + + private TestLibraryLoadingListener expectLoad(LibCheck libCheck) { + return this.addCheck(new Check("onLoad %s", libCheck) { + @Override + public void onLoad( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + Path optionalLibPath) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + } }); - } - - public TestLibraryLoadingListener expectTempFileCreationFailure(String expectedLibName) { - return this.expectTempFileCreationFailure(new LibCheck(expectedLibName)); - } - - private TestLibraryLoadingListener expectTempFileCreationFailure(LibCheck libCheck) { - return this.expectTempFileCreationFailure(libCheck, NOT_LIB_LOAD_EXCEPTION); - } - - private TestLibraryLoadingListener expectTempFileCreationFailure( - LibCheck libCheck, ThrowableCheck throwableCheck) { - return this.addCheck( - new Check("onTempFileCreationFailure %s", libCheck) { - @Override - public void onTempFileCreationFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Path tempDir, - String libExt, - Throwable optionalCause) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - assertNotNull(tempDir); - assertNotNull(libExt); - } + } + + public TestLibraryLoadingListener expectLoadPreloaded(String expectedLibName) { + return this.expectLoadPreloaded(new LibCheck(expectedLibName)); + } + + private TestLibraryLoadingListener expectLoadPreloaded(LibCheck libCheck) { + return this.addCheck(new Check("onLoad:preloaded %s", libCheck) { + @Override + public void onLoad( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + Path optionalLibPath) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + assertTrue(isPreloaded); + } }); - } - - public TestLibraryLoadingListener expectTempFileCleanup(String expectedLibName) { - return this.expectTempFileCleanup(new LibCheck(expectedLibName)); - } - - public TestLibraryLoadingListener expectTempFileCleanup( - String expectedComponent, String expectedLibName) { - return this.expectTempFileCleanup(new LibCheck(expectedComponent, expectedLibName)); - } - - TestLibraryLoadingListener expectTempFileCleanup(LibCheck libCheck) { - return this.addCheck( - new Check("onTempFileCreationCleanup %s", libCheck) { - @Override - public void onTempFileCleanup( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - libCheck.assertMatches(platformSpec, optionalComponent, libName); - assertNotNull(tempFile); - } + } + + public TestLibraryLoadingListener expectLoadFailure(String expectedLibName) { + return this.expectLoadFailure(new LibCheck(expectedLibName)); + } + + public TestLibraryLoadingListener expectLoadFailure(String expectedComponent, String expectedLibName) { + return this.expectLoadFailure(new LibCheck(expectedComponent, expectedLibName)); + } + + private TestLibraryLoadingListener expectLoadFailure(LibCheck libCheck) { + return this.expectLoadFailure(libCheck, NOT_LIB_LOAD_EXCEPTION); + } + + private TestLibraryLoadingListener expectLoadFailure(LibCheck libCheck, ThrowableCheck throwableCheck) { + return this.addCheck(new Check("onLoadFailure %s", libCheck) { + @Override + public void onLoadFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + throwableCheck.assertMatches(optionalCause); + } }); - } - - public TestLibraryLoadingListener copy() { - return new TestLibraryLoadingListener(this); - } - - public void assertDone() { - if (this.failedCheck != null) { - try { - fail("check failed: " + this.failedCheck, this.failedCause); - } catch (AssertionError e) { - e.initCause(this.failedCause); - - throw e; - } - } - - // written this way for better debugging - assertEquals(Collections.emptyList(), this.checks); - } - - @Override - public void onResolveDynamic( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - URL optionalUrl) { - this.nextCheck( - check -> - check.onResolveDynamic( - platformSpec, optionalComponent, libName, isPreloaded, optionalUrl)); - } - - @Override - public void onResolveDynamicFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - this.nextCheck( - check -> - check.onResolveDynamicFailure(platformSpec, optionalComponent, libName, optionalCause)); - } - - @Override - public void onLoad( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - Path optionalLibPath) { - this.nextCheck( - check -> - check.onLoad(platformSpec, optionalComponent, libName, isPreloaded, optionalLibPath)); - } - - @Override - public void onLoadFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - this.nextCheck( - check -> check.onLoadFailure(platformSpec, optionalComponent, libName, optionalCause)); - } - - @Override - public void onTempFileCreated( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - this.nextCheck( - check -> check.onTempFileCreated(platformSpec, optionalComponent, libName, tempFile)); - } - - @Override - public void onTempFileCreationFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Path tempDir, - String libExt, - Throwable optionalCause) { - this.nextCheck( - check -> - check.onTempFileCreationFailure( - platformSpec, optionalComponent, libName, tempDir, libExt, optionalCause)); - } + } - @Override - public void onTempFileCleanup( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - this.nextCheck( - check -> check.onTempFileCleanup(platformSpec, optionalComponent, libName, tempFile)); - } + public TestLibraryLoadingListener expectTempFileCreated(String expectedLibName) { + return this.expectTempFileCreated(new LibCheck(expectedLibName)); + } - public TestLibraryLoadingListener addCheck(Check check) { - this.checks.addLast(check); - return this; - } + public TestLibraryLoadingListener expectTempFileCreated(String expectedComponent, String expectedLibName) { + return this.expectTempFileCreated(new LibCheck(expectedComponent, expectedLibName)); + } - private void nextCheck(CheckInvocation invocation) { - Check nextCheck = this.checks.isEmpty() ? Check.NOTHING : this.checks.removeFirst(); - try { - invocation.invoke(nextCheck); - } catch (Throwable t) { - if (this.failedCheck == null) { - this.failedCheck = nextCheck; - this.failedCause = t; - } + private TestLibraryLoadingListener expectTempFileCreated(LibCheck libCheck) { + return this.addCheck(new Check("onTempFileCreated %s", libCheck) { + @Override + public void onTempFileCreated( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + assertNotNull(tempFile); + } + }); } - } - public abstract static class Check implements LibraryLoadingListener { - static final Check NOTHING = new Check("nothing") {}; + public TestLibraryLoadingListener expectTempFileCreationFailure(String expectedLibName) { + return this.expectTempFileCreationFailure(new LibCheck(expectedLibName)); + } - private final String name; + private TestLibraryLoadingListener expectTempFileCreationFailure(LibCheck libCheck) { + return this.expectTempFileCreationFailure(libCheck, NOT_LIB_LOAD_EXCEPTION); + } - Check(String nameFormat, Object... nameArgs) { - this(String.format(nameFormat, nameArgs)); + private TestLibraryLoadingListener expectTempFileCreationFailure(LibCheck libCheck, ThrowableCheck throwableCheck) { + return this.addCheck(new Check("onTempFileCreationFailure %s", libCheck) { + @Override + public void onTempFileCreationFailure( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + Path tempDir, + String libExt, + Throwable optionalCause) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + assertNotNull(tempDir); + assertNotNull(libExt); + } + }); } - Check(String name) { - this.name = name; + public TestLibraryLoadingListener expectTempFileCleanup(String expectedLibName) { + return this.expectTempFileCleanup(new LibCheck(expectedLibName)); } - @Override - public void onLoad( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - Path optionalLibPath) { - this.fallback( - "onLoad", platformSpec, optionalComponent, libName, isPreloaded, optionalLibPath); + public TestLibraryLoadingListener expectTempFileCleanup(String expectedComponent, String expectedLibName) { + return this.expectTempFileCleanup(new LibCheck(expectedComponent, expectedLibName)); } - @Override - public void onLoadFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - this.fallback("onLoadFailure", platformSpec, optionalComponent, libName, optionalCause); + TestLibraryLoadingListener expectTempFileCleanup(LibCheck libCheck) { + return this.addCheck(new Check("onTempFileCreationCleanup %s", libCheck) { + @Override + public void onTempFileCleanup( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + libCheck.assertMatches(platformSpec, optionalComponent, libName); + assertNotNull(tempFile); + } + }); + } + + public TestLibraryLoadingListener copy() { + return new TestLibraryLoadingListener(this); + } + + public void assertDone() { + if (this.failedCheck != null) { + try { + fail("check failed: " + this.failedCheck, this.failedCause); + } catch (AssertionError e) { + e.initCause(this.failedCause); + + throw e; + } + } + + // written this way for better debugging + assertEquals(Collections.emptyList(), this.checks); } @Override public void onResolveDynamic( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - URL optionalUrl) { - this.fallback( - "onResolveDynamic", platformSpec, optionalComponent, libName, isPreloaded, optionalUrl); + PlatformSpec platformSpec, String optionalComponent, String libName, boolean isPreloaded, URL optionalUrl) { + this.nextCheck( + check -> check.onResolveDynamic(platformSpec, optionalComponent, libName, isPreloaded, optionalUrl)); } @Override public void onResolveDynamicFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - this.fallback( - "onResolveDynamicFailure", platformSpec, optionalComponent, libName, optionalCause); + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + this.nextCheck(check -> check.onResolveDynamicFailure(platformSpec, optionalComponent, libName, optionalCause)); } @Override - public void onTempFileCreated( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - this.fallback("onTmepFileCreated", platformSpec, optionalComponent, libName, tempFile); + public void onLoad( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + Path optionalLibPath) { + this.nextCheck(check -> check.onLoad(platformSpec, optionalComponent, libName, isPreloaded, optionalLibPath)); } @Override - public void onTempFileCreationFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Path tempDir, - String libExt, - Throwable optionalCause) { - this.fallback( - "onTempFileCreationFailure", - platformSpec, - optionalComponent, - libName, - tempDir, - libExt, - optionalCause); + public void onLoadFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + this.nextCheck(check -> check.onLoadFailure(platformSpec, optionalComponent, libName, optionalCause)); } @Override - public void onTempFileCleanup( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - this.fallback("onTempFileCleanup", platformSpec, optionalComponent, libName, tempFile); - } - - void fallback(String methodName, Object... args) { - fail("unxpected call: " + callToString(methodName, args) + " - expected: " + this.name); + public void onTempFileCreated(PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + this.nextCheck(check -> check.onTempFileCreated(platformSpec, optionalComponent, libName, tempFile)); } - static final String callToString(String methodName, Object... args) { - StringBuilder builder = new StringBuilder(); - builder.append(methodName); - builder.append('('); - for (int i = 0; i < args.length; ++i) { - if (i != 0) builder.append(", "); - builder.append(args[i]); - } - builder.append(')'); - return builder.toString(); + @Override + public void onTempFileCreationFailure( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + Path tempDir, + String libExt, + Throwable optionalCause) { + this.nextCheck(check -> check.onTempFileCreationFailure( + platformSpec, optionalComponent, libName, tempDir, libExt, optionalCause)); } @Override - public String toString() { - return this.name; + public void onTempFileCleanup(PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + this.nextCheck(check -> check.onTempFileCleanup(platformSpec, optionalComponent, libName, tempFile)); } - } - - @FunctionalInterface - interface CheckInvocation { - void invoke(Check check); - } - - static final class LibCheck { - private final PlatformSpec expectedPlatformSpec; - private final String expectedComponent; - private final String expectedLibName; - LibCheck(PlatformSpec expectedPlatformSpec, String expectedLibName) { - this(null, null, expectedLibName); + public TestLibraryLoadingListener addCheck(Check check) { + this.checks.addLast(check); + return this; } - LibCheck(String expectedComponent, String expectedLibName) { - this(null, expectedComponent, expectedLibName); + private void nextCheck(CheckInvocation invocation) { + Check nextCheck = this.checks.isEmpty() ? Check.NOTHING : this.checks.removeFirst(); + try { + invocation.invoke(nextCheck); + } catch (Throwable t) { + if (this.failedCheck == null) { + this.failedCheck = nextCheck; + this.failedCause = t; + } + } } - LibCheck(String expectedLibName) { - this(null, null, expectedLibName); + public abstract static class Check implements LibraryLoadingListener { + static final Check NOTHING = new Check("nothing") {}; + + private final String name; + + Check(String nameFormat, Object... nameArgs) { + this(String.format(nameFormat, nameArgs)); + } + + Check(String name) { + this.name = name; + } + + @Override + public void onLoad( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + Path optionalLibPath) { + this.fallback("onLoad", platformSpec, optionalComponent, libName, isPreloaded, optionalLibPath); + } + + @Override + public void onLoadFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + this.fallback("onLoadFailure", platformSpec, optionalComponent, libName, optionalCause); + } + + @Override + public void onResolveDynamic( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + URL optionalUrl) { + this.fallback("onResolveDynamic", platformSpec, optionalComponent, libName, isPreloaded, optionalUrl); + } + + @Override + public void onResolveDynamicFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + this.fallback("onResolveDynamicFailure", platformSpec, optionalComponent, libName, optionalCause); + } + + @Override + public void onTempFileCreated( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + this.fallback("onTmepFileCreated", platformSpec, optionalComponent, libName, tempFile); + } + + @Override + public void onTempFileCreationFailure( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + Path tempDir, + String libExt, + Throwable optionalCause) { + this.fallback( + "onTempFileCreationFailure", + platformSpec, + optionalComponent, + libName, + tempDir, + libExt, + optionalCause); + } + + @Override + public void onTempFileCleanup( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + this.fallback("onTempFileCleanup", platformSpec, optionalComponent, libName, tempFile); + } + + void fallback(String methodName, Object... args) { + fail("unxpected call: " + callToString(methodName, args) + " - expected: " + this.name); + } + + static final String callToString(String methodName, Object... args) { + StringBuilder builder = new StringBuilder(); + builder.append(methodName); + builder.append('('); + for (int i = 0; i < args.length; ++i) { + if (i != 0) builder.append(", "); + builder.append(args[i]); + } + builder.append(')'); + return builder.toString(); + } + + @Override + public String toString() { + return this.name; + } } - LibCheck(PlatformSpec expectedPlatformSpec, String expectedComponent, String expectedLibName) { - this.expectedPlatformSpec = expectedPlatformSpec; - this.expectedComponent = expectedComponent; - this.expectedLibName = expectedLibName; + @FunctionalInterface + interface CheckInvocation { + void invoke(Check check); } - void assertMatches(PlatformSpec platformSpec, String optionalComponent, String libName) { - if (this.expectedPlatformSpec == null) { - // if no expectedPlatformSpec was provided -- just check that platformSpec is not null - assertNotNull(platformSpec); - } else { - assertEquals(this.expectedPlatformSpec, platformSpec); - } - - if (this.expectedComponent == null) { - // a null expectedComponent is treated as not expecting a component - assertNull(optionalComponent); - } else { - assertEquals(this.expectedComponent, optionalComponent); - } - - assertEquals(this.expectedLibName, libName); + static final class LibCheck { + private final PlatformSpec expectedPlatformSpec; + private final String expectedComponent; + private final String expectedLibName; + + LibCheck(PlatformSpec expectedPlatformSpec, String expectedLibName) { + this(null, null, expectedLibName); + } + + LibCheck(String expectedComponent, String expectedLibName) { + this(null, expectedComponent, expectedLibName); + } + + LibCheck(String expectedLibName) { + this(null, null, expectedLibName); + } + + LibCheck(PlatformSpec expectedPlatformSpec, String expectedComponent, String expectedLibName) { + this.expectedPlatformSpec = expectedPlatformSpec; + this.expectedComponent = expectedComponent; + this.expectedLibName = expectedLibName; + } + + void assertMatches(PlatformSpec platformSpec, String optionalComponent, String libName) { + if (this.expectedPlatformSpec == null) { + // if no expectedPlatformSpec was provided -- just check that platformSpec is not null + assertNotNull(platformSpec); + } else { + assertEquals(this.expectedPlatformSpec, platformSpec); + } + + if (this.expectedComponent == null) { + // a null expectedComponent is treated as not expecting a component + assertNull(optionalComponent); + } else { + assertEquals(this.expectedComponent, optionalComponent); + } + + assertEquals(this.expectedLibName, libName); + } + + @Override + public String toString() { + if (this.expectedComponent == null) { + return this.expectedLibName; + } else { + return this.expectedComponent + "/" + this.expectedLibName; + } + } } - @Override - public String toString() { - if (this.expectedComponent == null) { - return this.expectedLibName; - } else { - return this.expectedComponent + "/" + this.expectedLibName; - } - } - } - - @FunctionalInterface - interface ThrowableCheck { - void assertMatches(Throwable t); - } + @FunctionalInterface + interface ThrowableCheck { + void assertMatches(Throwable t); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/TestPlatformSpec.java b/components/native-loader/src/test/java/datadog/nativeloader/TestPlatformSpec.java index dd875453045..6c7b59480b9 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/TestPlatformSpec.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/TestPlatformSpec.java @@ -1,130 +1,130 @@ package datadog.nativeloader; public class TestPlatformSpec extends PlatformSpec { - public static final String MAC = "mac"; - public static final String WINDOWS = "win"; - public static final String LINUX = "linux"; + public static final String MAC = "mac"; + public static final String WINDOWS = "win"; + public static final String LINUX = "linux"; - public static final String UNSUPPORTED_OS = "unsupported"; + public static final String UNSUPPORTED_OS = "unsupported"; - public static final String X86_32 = "x86_32"; - public static final String X86_64 = "x86_64"; + public static final String X86_32 = "x86_32"; + public static final String X86_64 = "x86_64"; - public static final String ARM32 = "arm32"; - public static final String AARCH64 = "aarch64"; - - public static final String UNSUPPORTED_ARCH = "unsupported"; + public static final String ARM32 = "arm32"; + public static final String AARCH64 = "aarch64"; + + public static final String UNSUPPORTED_ARCH = "unsupported"; - public static final boolean GLIBC = false; - public static final boolean MUSL = true; - - public static final PlatformSpec of(String os, String arch) { - return new TestPlatformSpec(os, arch, false); - } - - public static final TestPlatformSpec of(String os, String arch, boolean isMusl) { - return new TestPlatformSpec(os, arch, isMusl); - } - - public static final PlatformSpec mac() { - return TestPlatformSpec.of(MAC, AARCH64); - } - - public static final PlatformSpec linux() { - return TestPlatformSpec.of(LINUX, X86_64); - } - - public static final PlatformSpec windows() { - return TestPlatformSpec.of(WINDOWS, X86_64); - } - - public static final PlatformSpec unsupportedOs() { - return TestPlatformSpec.of(UNSUPPORTED_OS, AARCH64); - } - - public static final PlatformSpec linux_x86_32() { - return TestPlatformSpec.of(WINDOWS, X86_32); - } - - public static final PlatformSpec linux_x86_64() { - return TestPlatformSpec.of(MAC, X86_64); - } - - public static final PlatformSpec linux_arm32() { - return TestPlatformSpec.of(LINUX, ARM32); - } - - public static final PlatformSpec linux_arm64() { - return TestPlatformSpec.of(LINUX, AARCH64); - } - - public static final PlatformSpec linux_glibc() { - return TestPlatformSpec.of(LINUX, AARCH64, GLIBC); - } - - public static final PlatformSpec linux_musl() { - return TestPlatformSpec.of(LINUX, AARCH64, MUSL); - } - - public static final PlatformSpec unsupportedArch() { - return TestPlatformSpec.of(LINUX, UNSUPPORTED_ARCH); - } - - private final String os; - private final String arch; - private final boolean isMusl; - - private TestPlatformSpec(String os, String arch, boolean isMusl) { - this.os = os; - this.arch = arch; - this.isMusl = isMusl; - } - - @Override - public boolean isLinux() { - return this.isOs(LINUX); - } - - @Override - public boolean isMac() { - return this.isOs(MAC); - } - - @Override - public boolean isWindows() { - return this.isOs(WINDOWS); - } - - private boolean isOs(String osConst) { - return osConst.equals(this.os); - } - - @Override - public boolean isX86_32() { - return this.isArch(X86_32); - } - - @Override - public boolean isAarch64() { - return this.isArch(AARCH64); - } - - @Override - public boolean isArm32() { - return this.isArch(ARM32); - } - - @Override - public boolean isX86_64() { - return this.isArch(X86_64); - } - - @Override - public boolean isMusl() { - return this.isMusl; - } - - private boolean isArch(String archConst) { - return archConst.equals(this.arch); - } + public static final boolean GLIBC = false; + public static final boolean MUSL = true; + + public static final PlatformSpec of(String os, String arch) { + return new TestPlatformSpec(os, arch, false); + } + + public static final TestPlatformSpec of(String os, String arch, boolean isMusl) { + return new TestPlatformSpec(os, arch, isMusl); + } + + public static final PlatformSpec mac() { + return TestPlatformSpec.of(MAC, AARCH64); + } + + public static final PlatformSpec linux() { + return TestPlatformSpec.of(LINUX, X86_64); + } + + public static final PlatformSpec windows() { + return TestPlatformSpec.of(WINDOWS, X86_64); + } + + public static final PlatformSpec unsupportedOs() { + return TestPlatformSpec.of(UNSUPPORTED_OS, AARCH64); + } + + public static final PlatformSpec linux_x86_32() { + return TestPlatformSpec.of(WINDOWS, X86_32); + } + + public static final PlatformSpec linux_x86_64() { + return TestPlatformSpec.of(MAC, X86_64); + } + + public static final PlatformSpec linux_arm32() { + return TestPlatformSpec.of(LINUX, ARM32); + } + + public static final PlatformSpec linux_arm64() { + return TestPlatformSpec.of(LINUX, AARCH64); + } + + public static final PlatformSpec linux_glibc() { + return TestPlatformSpec.of(LINUX, AARCH64, GLIBC); + } + + public static final PlatformSpec linux_musl() { + return TestPlatformSpec.of(LINUX, AARCH64, MUSL); + } + + public static final PlatformSpec unsupportedArch() { + return TestPlatformSpec.of(LINUX, UNSUPPORTED_ARCH); + } + + private final String os; + private final String arch; + private final boolean isMusl; + + private TestPlatformSpec(String os, String arch, boolean isMusl) { + this.os = os; + this.arch = arch; + this.isMusl = isMusl; + } + + @Override + public boolean isLinux() { + return this.isOs(LINUX); + } + + @Override + public boolean isMac() { + return this.isOs(MAC); + } + + @Override + public boolean isWindows() { + return this.isOs(WINDOWS); + } + + private boolean isOs(String osConst) { + return osConst.equals(this.os); + } + + @Override + public boolean isX86_32() { + return this.isArch(X86_32); + } + + @Override + public boolean isAarch64() { + return this.isArch(AARCH64); + } + + @Override + public boolean isArm32() { + return this.isArch(ARM32); + } + + @Override + public boolean isX86_64() { + return this.isArch(X86_64); + } + + @Override + public boolean isMusl() { + return this.isMusl; + } + + private boolean isArch(String archConst) { + return archConst.equals(this.arch); + } } diff --git a/components/native-loader/src/test/java/datadog/nativeloader/ThrowingLibraryLoadingListener.java b/components/native-loader/src/test/java/datadog/nativeloader/ThrowingLibraryLoadingListener.java index 75f0a2ed6fc..9a90ca91a96 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/ThrowingLibraryLoadingListener.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/ThrowingLibraryLoadingListener.java @@ -4,68 +4,58 @@ import java.nio.file.Path; public class ThrowingLibraryLoadingListener implements LibraryLoadingListener { - @Override - public final void onLoad( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - Path optionalLibPath) { - this.throwException("load"); - } - - @Override - public final void onLoadFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - this.throwException("loadFailure"); - } - - @Override - public final void onResolveDynamic( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - boolean isPreloaded, - URL optionalUrl) { - this.throwException("resolveDynamic"); - } - - @Override - public final void onResolveDynamicFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Throwable optionalCause) { - this.throwException("resolveDynamicFailure"); - } - - @Override - public final void onTempFileCreated( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - this.throwException("tempFileCreated"); - } - - @Override - public final void onTempFileCreationFailure( - PlatformSpec platformSpec, - String optionalComponent, - String libName, - Path tempDir, - String libExt, - Throwable optionalCause) { - this.throwException("tempFileCreationFailure"); - } - - @Override - public final void onTempFileCleanup( - PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { - this.throwException("tempFileCleanup"); - } - - void throwException(String event) { - throw new RuntimeException(event); - } + @Override + public final void onLoad( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + boolean isPreloaded, + Path optionalLibPath) { + this.throwException("load"); + } + + @Override + public final void onLoadFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + this.throwException("loadFailure"); + } + + @Override + public final void onResolveDynamic( + PlatformSpec platformSpec, String optionalComponent, String libName, boolean isPreloaded, URL optionalUrl) { + this.throwException("resolveDynamic"); + } + + @Override + public final void onResolveDynamicFailure( + PlatformSpec platformSpec, String optionalComponent, String libName, Throwable optionalCause) { + this.throwException("resolveDynamicFailure"); + } + + @Override + public final void onTempFileCreated( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + this.throwException("tempFileCreated"); + } + + @Override + public final void onTempFileCreationFailure( + PlatformSpec platformSpec, + String optionalComponent, + String libName, + Path tempDir, + String libExt, + Throwable optionalCause) { + this.throwException("tempFileCreationFailure"); + } + + @Override + public final void onTempFileCleanup( + PlatformSpec platformSpec, String optionalComponent, String libName, Path tempFile) { + this.throwException("tempFileCleanup"); + } + + void throwException(String event) { + throw new RuntimeException(event); + } } diff --git a/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardInternal.java b/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardInternal.java index 60b51ec0a4b..358202c95d0 100644 --- a/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardInternal.java +++ b/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardInternal.java @@ -64,566 +64,555 @@ */ public class AIGuardInternal implements Evaluator { - private static final Logger log = LoggerFactory.getLogger(AIGuardInternal.class); + private static final Logger log = LoggerFactory.getLogger(AIGuardInternal.class); - public static class BadConfigurationException extends RuntimeException { - public BadConfigurationException(final String message) { - super(message); - } - } - - static final String SPAN_NAME = "ai_guard"; - static final String TARGET_TAG = "ai_guard.target"; - static final String TOOL_TAG = "ai_guard.tool_name"; - static final String ACTION_TAG = "ai_guard.action"; - static final String REASON_TAG = "ai_guard.reason"; - static final String BLOCKED_TAG = "ai_guard.blocked"; - static final String REDACTED_TAG = "ai_guard.redacted"; - - static final String RESPONSE_REDACTION_REPLACEMENTS = "redaction_replacements"; - - static final String META_STRUCT_TAG = "ai_guard"; - static final String META_STRUCT_MESSAGES = "messages"; - static final String META_STRUCT_CATEGORIES = "attack_categories"; - static final String META_STRUCT_SDS = "sds"; - static final String META_STRUCT_TAG_PROBS = "tag_probs"; - - /** - * Anomaly detection tags copied from the local root span onto every {@code ai_guard} span with - * the {@code ai_guard.} prefix, so the AI Guard backend can correlate AI Guard requests with the - * request context (client IP, user, session) without depending on the local root span. - */ - static final String[] ANOMALY_DETECTION_TAGS = { - Tags.HTTP_CLIENT_IP, Tags.NETWORK_CLIENT_IP, Tags.HTTP_USER_AGENT, "usr.id", "usr.session_id" - }; - - public static void install() { - final Config config = Config.get(); - final String apiKey = config.getApiKey(); - final String appKey = config.getApplicationKey(); - if (isBlank(apiKey) || isBlank(appKey)) { - throw new BadConfigurationException( - "AI Guard: Missing api and/or application key, use DD_API_KEY and DD_APP_KEY"); - } - String endpoint = config.getAiGuardEndpoint(); - if (isBlank(endpoint)) { - endpoint = String.format("https://app.%s/api/v2/ai-guard", config.getSite()); - } - final Map headers = - mapOf( - "DD-API-KEY", - apiKey, - "DD-APPLICATION-KEY", - appKey, - "DD-AI-GUARD-VERSION", - TRACER_VERSION, - "DD-AI-GUARD-SOURCE", - "SDK", - "DD-AI-GUARD-LANGUAGE", - "jvm"); - final HttpUrl url = HttpUrl.get(endpoint).newBuilder().addPathSegment("evaluate").build(); - final int timeout = config.getAiGuardTimeout(); - final OkHttpClient client = buildClient(url, timeout); - Installer.install(new AIGuardInternal(url, headers, client)); - } - - /** Used by tests to reset status */ - static void uninstall() { - Installer.install(new NoOpEvaluator()); - } - - private final HttpUrl url; - private final Moshi moshi; - private final OkHttpClient client; - private final Map meta; - private final Map headers; - private final MessageRedactor redactor; - - AIGuardInternal(final HttpUrl url, final Map headers, final OkHttpClient client) { - this.url = url; - this.headers = headers; - this.client = client; - this.moshi = new Moshi.Builder().add(new AIGuardFactory()).build(); - final Config config = Config.get(); - this.meta = mapOf("service", config.getServiceName(), "env", config.getEnv()); - this.redactor = - config.isAiGuardRedactionEnabled() - ? new MessageRedactor.DefaultRedactor() - : new MessageRedactor.NoOp(); - } - - /** - * Creates a deep copy of the messages before storing them in the metastruct to avoid concurrent - * modifications prior to trace serialization. - */ - private static List messagesForMetaStruct(final List messages) { - final Config config = Config.get(); - final int size = Math.min(messages.size(), config.getAiGuardMaxMessagesLength()); - if (size < messages.size()) { - WafMetricCollector.get().aiGuardTruncated(MESSAGES); - } - final List result = new ArrayList<>(size); - final int maxContent = config.getAiGuardMaxContentSize(); - boolean contentTruncated = false; - for (int i = messages.size() - size; i < messages.size(); i++) { - final Message source = messages.get(i); - - List toolCalls = source.getToolCalls(); - if (toolCalls != null) { - toolCalls = new ArrayList<>(toolCalls); - } - - List contentParts = source.getContentParts(); - if (contentParts != null) { - final List truncatedParts = new ArrayList<>(contentParts.size()); - for (final ContentPart part : contentParts) { - if (part.getType() == ContentPart.Type.TEXT - && part.getText() != null - && part.getText().length() > maxContent) { - contentTruncated = true; - final String text = part.getText().substring(0, maxContent); - truncatedParts.add(ContentPart.text(text)); - } else { - truncatedParts.add(part); - } - } - - result.add( - new Message(source.getRole(), truncatedParts, toolCalls, source.getToolCallId())); - } else { - String content = source.getContent(); - if (content != null && content.length() > maxContent) { - contentTruncated = true; - content = content.substring(0, maxContent); - } - result.add(new Message(source.getRole(), content, toolCalls, source.getToolCallId())); - } - } - if (contentTruncated) { - WafMetricCollector.get().aiGuardTruncated(CONTENT); - } - return result; - } - - /** - * Applies the redaction requested by the AI Guard service and reports the outcome on the span. - * - *

This runs before the rest of the response is interpreted on purpose. Every surface that - * reports the conversation must report it redacted, and both the blocking decision and the - * response validation that precedes it can leave the evaluation through the meta struct: a - * blocked evaluation still reports through it, and so does a response the tracer rejects. The - * {@link AIGuardAbortError} raised on the blocked path deliberately carries no messages. - * - *

The {@code ai_guard.redacted} tag is set to {@code false} before the request is issued and - * only raised here, so an evaluation that fails before this point still reports that nothing was - * redacted rather than looking like the kill switch is off. - * - * @return the telemetry state, {@link AIGuardRedaction#DISABLED} when the kill switch is off, in - * which case no {@code ai_guard.redacted} tag is attached at all - */ - private AIGuardRedaction reportRedaction( - final AgentSpan span, final MessageRedactor.Result redaction) { - if (!redactor.enabled()) { - // No tag at all, so an absent tag ("redaction is off") stays distinguishable from a false - // one ("redaction is on and nothing was redacted"). - return AIGuardRedaction.DISABLED; - } - if (redaction.skipped > 0) { - log.debug( - "AI Guard skipped {} redaction replacement(s) that could not be applied", - redaction.skipped); - // Reported and then forgotten: redaction is best effort and must never fail the evaluation - // it rode in on, so this counts an error without counting a failed request. - WafMetricCollector.get().aiGuardRedactionErrors(redaction.skipped); - } - if (!redaction.redacted()) { - // The tag was already set to false before the request; nothing to correct. - return AIGuardRedaction.NOT_APPLIED; - } - span.setTag(REDACTED_TAG, true); - return AIGuardRedaction.APPLIED; - } - - private static boolean isToolCall(final Message message) { - return message.getToolCalls() != null || message.getToolCallId() != null; - } - - private static String getToolName(final Message current, final List messages) { - if (current.getToolCalls() != null) { - // assistant message with tool calls - return current.getToolCalls().stream() - .map(ToolCall::getFunction) - .map(Function::getName) - .collect(Collectors.joining(",")); - } - // assistant message with tool output (search the linked tool call in reverse order) - final String id = current.getToolCallId(); - for (int i = messages.size() - 1; i >= 0; i--) { - final Message message = messages.get(i); - if (message.getToolCalls() != null) { - for (final ToolCall toolCall : message.getToolCalls()) { - if (toolCall.getId().equals(id)) { - return toolCall.getFunction() == null ? null : toolCall.getFunction().getName(); - } - } - } + public static class BadConfigurationException extends RuntimeException { + public BadConfigurationException(final String message) { + super(message); + } } - return null; - } - private boolean isBlockingEnabled(final Options options, final Object isBlockingEnabled) { - if (isBlockingEnabled == null) { - return false; - } - return options.block() && "true".equalsIgnoreCase(isBlockingEnabled.toString()); - } - - /** - * Applies the {@link ClientIpAddressData} captured during HTTP server request decoration to the - * local root span. This is the lazy half of AI Guard client IP collection: {@code - * HttpServerDecorator} resolves the IP eagerly and stashes it on the {@link RequestContext}; we - * consume it here once an {@code ai_guard} span is created, so IP tags are not added to spans of - * non-AI requests in services that have AI Guard enabled. - */ - private static void applyClientIpTags(final AgentSpan localRootSpan) { - final RequestContext requestContext = localRootSpan.getRequestContext(); - if (requestContext == null) { - return; - } - final ClientIpAddressData clientIpAddressData = requestContext.getClientIpAddressData(); - if (clientIpAddressData == null) { - return; - } - final String peerIp = clientIpAddressData.getPeerIp(); - if (peerIp != null && localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) == null) { - localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, peerIp); - } - final String inferredClientIp = clientIpAddressData.getInferredClientIp(); - if (inferredClientIp != null && localRootSpan.getTag(Tags.HTTP_CLIENT_IP) == null) { - localRootSpan.setTag(Tags.HTTP_CLIENT_IP, inferredClientIp); + static final String SPAN_NAME = "ai_guard"; + static final String TARGET_TAG = "ai_guard.target"; + static final String TOOL_TAG = "ai_guard.tool_name"; + static final String ACTION_TAG = "ai_guard.action"; + static final String REASON_TAG = "ai_guard.reason"; + static final String BLOCKED_TAG = "ai_guard.blocked"; + static final String REDACTED_TAG = "ai_guard.redacted"; + + static final String RESPONSE_REDACTION_REPLACEMENTS = "redaction_replacements"; + + static final String META_STRUCT_TAG = "ai_guard"; + static final String META_STRUCT_MESSAGES = "messages"; + static final String META_STRUCT_CATEGORIES = "attack_categories"; + static final String META_STRUCT_SDS = "sds"; + static final String META_STRUCT_TAG_PROBS = "tag_probs"; + + /** + * Anomaly detection tags copied from the local root span onto every {@code ai_guard} span with + * the {@code ai_guard.} prefix, so the AI Guard backend can correlate AI Guard requests with the + * request context (client IP, user, session) without depending on the local root span. + */ + static final String[] ANOMALY_DETECTION_TAGS = { + Tags.HTTP_CLIENT_IP, Tags.NETWORK_CLIENT_IP, Tags.HTTP_USER_AGENT, "usr.id", "usr.session_id" + }; + + public static void install() { + final Config config = Config.get(); + final String apiKey = config.getApiKey(); + final String appKey = config.getApplicationKey(); + if (isBlank(apiKey) || isBlank(appKey)) { + throw new BadConfigurationException( + "AI Guard: Missing api and/or application key, use DD_API_KEY and DD_APP_KEY"); + } + String endpoint = config.getAiGuardEndpoint(); + if (isBlank(endpoint)) { + endpoint = String.format("https://app.%s/api/v2/ai-guard", config.getSite()); + } + final Map headers = mapOf( + "DD-API-KEY", + apiKey, + "DD-APPLICATION-KEY", + appKey, + "DD-AI-GUARD-VERSION", + TRACER_VERSION, + "DD-AI-GUARD-SOURCE", + "SDK", + "DD-AI-GUARD-LANGUAGE", + "jvm"); + final HttpUrl url = + HttpUrl.get(endpoint).newBuilder().addPathSegment("evaluate").build(); + final int timeout = config.getAiGuardTimeout(); + final OkHttpClient client = buildClient(url, timeout); + Installer.install(new AIGuardInternal(url, headers, client)); + } + + /** Used by tests to reset status */ + static void uninstall() { + Installer.install(new NoOpEvaluator()); + } + + private final HttpUrl url; + private final Moshi moshi; + private final OkHttpClient client; + private final Map meta; + private final Map headers; + private final MessageRedactor redactor; + + AIGuardInternal(final HttpUrl url, final Map headers, final OkHttpClient client) { + this.url = url; + this.headers = headers; + this.client = client; + this.moshi = new Moshi.Builder().add(new AIGuardFactory()).build(); + final Config config = Config.get(); + this.meta = mapOf("service", config.getServiceName(), "env", config.getEnv()); + this.redactor = + config.isAiGuardRedactionEnabled() ? new MessageRedactor.DefaultRedactor() : new MessageRedactor.NoOp(); + } + + /** + * Creates a deep copy of the messages before storing them in the metastruct to avoid concurrent + * modifications prior to trace serialization. + */ + private static List messagesForMetaStruct(final List messages) { + final Config config = Config.get(); + final int size = Math.min(messages.size(), config.getAiGuardMaxMessagesLength()); + if (size < messages.size()) { + WafMetricCollector.get().aiGuardTruncated(MESSAGES); + } + final List result = new ArrayList<>(size); + final int maxContent = config.getAiGuardMaxContentSize(); + boolean contentTruncated = false; + for (int i = messages.size() - size; i < messages.size(); i++) { + final Message source = messages.get(i); + + List toolCalls = source.getToolCalls(); + if (toolCalls != null) { + toolCalls = new ArrayList<>(toolCalls); + } + + List contentParts = source.getContentParts(); + if (contentParts != null) { + final List truncatedParts = new ArrayList<>(contentParts.size()); + for (final ContentPart part : contentParts) { + if (part.getType() == ContentPart.Type.TEXT + && part.getText() != null + && part.getText().length() > maxContent) { + contentTruncated = true; + final String text = part.getText().substring(0, maxContent); + truncatedParts.add(ContentPart.text(text)); + } else { + truncatedParts.add(part); + } + } + + result.add(new Message(source.getRole(), truncatedParts, toolCalls, source.getToolCallId())); + } else { + String content = source.getContent(); + if (content != null && content.length() > maxContent) { + contentTruncated = true; + content = content.substring(0, maxContent); + } + result.add(new Message(source.getRole(), content, toolCalls, source.getToolCallId())); + } + } + if (contentTruncated) { + WafMetricCollector.get().aiGuardTruncated(CONTENT); + } + return result; + } + + /** + * Applies the redaction requested by the AI Guard service and reports the outcome on the span. + * + *

This runs before the rest of the response is interpreted on purpose. Every surface that + * reports the conversation must report it redacted, and both the blocking decision and the + * response validation that precedes it can leave the evaluation through the meta struct: a + * blocked evaluation still reports through it, and so does a response the tracer rejects. The + * {@link AIGuardAbortError} raised on the blocked path deliberately carries no messages. + * + *

The {@code ai_guard.redacted} tag is set to {@code false} before the request is issued and + * only raised here, so an evaluation that fails before this point still reports that nothing was + * redacted rather than looking like the kill switch is off. + * + * @return the telemetry state, {@link AIGuardRedaction#DISABLED} when the kill switch is off, in + * which case no {@code ai_guard.redacted} tag is attached at all + */ + private AIGuardRedaction reportRedaction(final AgentSpan span, final MessageRedactor.Result redaction) { + if (!redactor.enabled()) { + // No tag at all, so an absent tag ("redaction is off") stays distinguishable from a false + // one ("redaction is on and nothing was redacted"). + return AIGuardRedaction.DISABLED; + } + if (redaction.skipped > 0) { + log.debug("AI Guard skipped {} redaction replacement(s) that could not be applied", redaction.skipped); + // Reported and then forgotten: redaction is best effort and must never fail the evaluation + // it rode in on, so this counts an error without counting a failed request. + WafMetricCollector.get().aiGuardRedactionErrors(redaction.skipped); + } + if (!redaction.redacted()) { + // The tag was already set to false before the request; nothing to correct. + return AIGuardRedaction.NOT_APPLIED; + } + span.setTag(REDACTED_TAG, true); + return AIGuardRedaction.APPLIED; } - } - - private static void copyAnomalyDetectionTags( - final AgentSpan span, final AgentSpan localRootSpan) { - for (final String tag : ANOMALY_DETECTION_TAGS) { - final Object value = localRootSpan.getTag(tag); - if (value != null) { - span.setTag("ai_guard." + tag, value.toString()); - } + + private static boolean isToolCall(final Message message) { + return message.getToolCalls() != null || message.getToolCallId() != null; } - } - @Override - public Evaluation evaluate(final List messages, final Options options) { - if (messages == null || messages.isEmpty()) { - throw new IllegalArgumentException("Messages must not be empty"); + private static String getToolName(final Message current, final List messages) { + if (current.getToolCalls() != null) { + // assistant message with tool calls + return current.getToolCalls().stream() + .map(ToolCall::getFunction) + .map(Function::getName) + .collect(Collectors.joining(",")); + } + // assistant message with tool output (search the linked tool call in reverse order) + final String id = current.getToolCallId(); + for (int i = messages.size() - 1; i >= 0; i--) { + final Message message = messages.get(i); + if (message.getToolCalls() != null) { + for (final ToolCall toolCall : message.getToolCalls()) { + if (toolCall.getId().equals(id)) { + return toolCall.getFunction() == null + ? null + : toolCall.getFunction().getName(); + } + } + } + } + return null; } - final AgentTracer.TracerAPI tracer = AgentTracer.get(); - final AgentTracer.SpanBuilder builder = tracer.buildSpan(SPAN_NAME, SPAN_NAME); - final AgentSpan parent = AgentTracer.activeSpan(); - if (parent != null) { - builder.asChildOf(parent.spanContext()); + + private boolean isBlockingEnabled(final Options options, final Object isBlockingEnabled) { + if (isBlockingEnabled == null) { + return false; + } + return options.block() && "true".equalsIgnoreCase(isBlockingEnabled.toString()); + } + + /** + * Applies the {@link ClientIpAddressData} captured during HTTP server request decoration to the + * local root span. This is the lazy half of AI Guard client IP collection: {@code + * HttpServerDecorator} resolves the IP eagerly and stashes it on the {@link RequestContext}; we + * consume it here once an {@code ai_guard} span is created, so IP tags are not added to spans of + * non-AI requests in services that have AI Guard enabled. + */ + private static void applyClientIpTags(final AgentSpan localRootSpan) { + final RequestContext requestContext = localRootSpan.getRequestContext(); + if (requestContext == null) { + return; + } + final ClientIpAddressData clientIpAddressData = requestContext.getClientIpAddressData(); + if (clientIpAddressData == null) { + return; + } + final String peerIp = clientIpAddressData.getPeerIp(); + if (peerIp != null && localRootSpan.getTag(Tags.NETWORK_CLIENT_IP) == null) { + localRootSpan.setTag(Tags.NETWORK_CLIENT_IP, peerIp); + } + final String inferredClientIp = clientIpAddressData.getInferredClientIp(); + if (inferredClientIp != null && localRootSpan.getTag(Tags.HTTP_CLIENT_IP) == null) { + localRootSpan.setTag(Tags.HTTP_CLIENT_IP, inferredClientIp); + } } - final AgentSpan span = builder.start(); - final AgentSpan localRootSpan = span.getLocalRootSpan(); - if (localRootSpan != null) { - localRootSpan.setTag(Tags.AI_GUARD_KEEP, true); - localRootSpan.setTag(Tags.AI_GUARD_EVENT, true); - localRootSpan.setTag(Tags.PROPAGATED_TRACE_SOURCE, ProductTraceSource.AI_GUARD); - applyClientIpTags(localRootSpan); - // copyAnomalyDetectionTags MUST run after applyClientIpTags, to make - // sure client IP tags were populated. - copyAnomalyDetectionTags(span, localRootSpan); + + private static void copyAnomalyDetectionTags(final AgentSpan span, final AgentSpan localRootSpan) { + for (final String tag : ANOMALY_DETECTION_TAGS) { + final Object value = localRootSpan.getTag(tag); + if (value != null) { + span.setTag("ai_guard." + tag, value.toString()); + } + } } - List finalMessages = messages; - // Classifies the ai_guard.error metric when a raise below escapes. A transport failure, and - // anything the tracer cannot attribute to the response, keeps the default; the raises driven - // by the response itself narrow it first. - AIGuardError errorType = AIGuardError.CLIENT_ERROR; - try (final ContextScope scope = tracer.activateSpan(span)) { - final Message last = messages.get(messages.size() - 1); - if (isToolCall(last)) { - span.setTag(TARGET_TAG, "tool"); - final String toolName = getToolName(last, messages); - if (toolName != null) { - span.setTag(TOOL_TAG, toolName); - } - } else { - span.setTag(TARGET_TAG, "prompt"); - } - if (redactor.enabled()) { - // Reported before the request goes out so an evaluation that fails, and therefore redacts - // nothing, still says so. An absent tag stays reserved for the kill switch being off. - span.setTag(REDACTED_TAG, false); - } - final Map metaStruct = new HashMap<>(2); - span.setMetaStruct(META_STRUCT_TAG, metaStruct); - final Request.Builder request = - new Request.Builder() - .url(url) - .method("POST", new MoshiJsonRequestBody(moshi, messages, meta)); - headers.forEach(request::header); - try (final Response response = client.newCall(request.build()).execute()) { - final Map result; - try { - result = parseResponseBody(response); - } catch (final Exception e) { - // A body we cannot read is a response problem, not a transport one, unless the status - // code already explains the failure on its own. - errorType = response.isSuccessful() ? AIGuardError.BAD_RESPONSE : AIGuardError.BAD_STATUS; - throw e; - } - // Resolved before anything else that can throw: once the service has told us what to - // redact, every reporting surface must carry the redacted conversation, including on the - // paths that reject the rest of the response. - final Object rawReplacements = result.get(RESPONSE_REDACTION_REPLACEMENTS); - // Reported back to the caller verbatim, including entries redaction could not apply. - final List redactionReplacements = - rawReplacements instanceof List ? (List) rawReplacements : null; - // The raw value goes to the redactor: a present payload that is not an array is a - // backend contract break the redactor counts, not something to drop silently. - final MessageRedactor.Result redaction = redactor.redact(messages, rawReplacements); - finalMessages = redaction.messages; - final AIGuardRedaction redactionState = reportRedaction(span, redaction); - final Action action; - final String reason; - final List tags; - final List sdsFindings; - final Map tagProbs; - try { - // Everything here reads the parsed body, so a raise means the service answered with - // something that does not honour the response contract. - final String actionStr = (String) result.get("action"); - if (actionStr == null) { - throw new IllegalArgumentException("Action field is missing in the response"); - } - action = Action.valueOf(actionStr); - reason = (String) result.get("reason"); - @SuppressWarnings("unchecked") - final List parsedTags = (List) result.get("tags"); - tags = parsedTags; - sdsFindings = (List) result.get("sds_findings"); - @SuppressWarnings("unchecked") - final Map parsedTagProbs = (Map) result.get("tag_probs"); - tagProbs = parsedTagProbs; + + @Override + public Evaluation evaluate(final List messages, final Options options) { + if (messages == null || messages.isEmpty()) { + throw new IllegalArgumentException("Messages must not be empty"); + } + final AgentTracer.TracerAPI tracer = AgentTracer.get(); + final AgentTracer.SpanBuilder builder = tracer.buildSpan(SPAN_NAME, SPAN_NAME); + final AgentSpan parent = AgentTracer.activeSpan(); + if (parent != null) { + builder.asChildOf(parent.spanContext()); + } + final AgentSpan span = builder.start(); + final AgentSpan localRootSpan = span.getLocalRootSpan(); + if (localRootSpan != null) { + localRootSpan.setTag(Tags.AI_GUARD_KEEP, true); + localRootSpan.setTag(Tags.AI_GUARD_EVENT, true); + localRootSpan.setTag(Tags.PROPAGATED_TRACE_SOURCE, ProductTraceSource.AI_GUARD); + applyClientIpTags(localRootSpan); + // copyAnomalyDetectionTags MUST run after applyClientIpTags, to make + // sure client IP tags were populated. + copyAnomalyDetectionTags(span, localRootSpan); + } + List finalMessages = messages; + // Classifies the ai_guard.error metric when a raise below escapes. A transport failure, and + // anything the tracer cannot attribute to the response, keeps the default; the raises driven + // by the response itself narrow it first. + AIGuardError errorType = AIGuardError.CLIENT_ERROR; + try (final ContextScope scope = tracer.activateSpan(span)) { + final Message last = messages.get(messages.size() - 1); + if (isToolCall(last)) { + span.setTag(TARGET_TAG, "tool"); + final String toolName = getToolName(last, messages); + if (toolName != null) { + span.setTag(TOOL_TAG, toolName); + } + } else { + span.setTag(TARGET_TAG, "prompt"); + } + if (redactor.enabled()) { + // Reported before the request goes out so an evaluation that fails, and therefore redacts + // nothing, still says so. An absent tag stays reserved for the kill switch being off. + span.setTag(REDACTED_TAG, false); + } + final Map metaStruct = new HashMap<>(2); + span.setMetaStruct(META_STRUCT_TAG, metaStruct); + final Request.Builder request = + new Request.Builder().url(url).method("POST", new MoshiJsonRequestBody(moshi, messages, meta)); + headers.forEach(request::header); + try (final Response response = client.newCall(request.build()).execute()) { + final Map result; + try { + result = parseResponseBody(response); + } catch (final Exception e) { + // A body we cannot read is a response problem, not a transport one, unless the status + // code already explains the failure on its own. + errorType = response.isSuccessful() ? AIGuardError.BAD_RESPONSE : AIGuardError.BAD_STATUS; + throw e; + } + // Resolved before anything else that can throw: once the service has told us what to + // redact, every reporting surface must carry the redacted conversation, including on the + // paths that reject the rest of the response. + final Object rawReplacements = result.get(RESPONSE_REDACTION_REPLACEMENTS); + // Reported back to the caller verbatim, including entries redaction could not apply. + final List redactionReplacements = + rawReplacements instanceof List ? (List) rawReplacements : null; + // The raw value goes to the redactor: a present payload that is not an array is a + // backend contract break the redactor counts, not something to drop silently. + final MessageRedactor.Result redaction = redactor.redact(messages, rawReplacements); + finalMessages = redaction.messages; + final AIGuardRedaction redactionState = reportRedaction(span, redaction); + final Action action; + final String reason; + final List tags; + final List sdsFindings; + final Map tagProbs; + try { + // Everything here reads the parsed body, so a raise means the service answered with + // something that does not honour the response contract. + final String actionStr = (String) result.get("action"); + if (actionStr == null) { + throw new IllegalArgumentException("Action field is missing in the response"); + } + action = Action.valueOf(actionStr); + reason = (String) result.get("reason"); + @SuppressWarnings("unchecked") + final List parsedTags = (List) result.get("tags"); + tags = parsedTags; + sdsFindings = (List) result.get("sds_findings"); + @SuppressWarnings("unchecked") + final Map parsedTagProbs = (Map) result.get("tag_probs"); + tagProbs = parsedTagProbs; + } catch (final Exception e) { + errorType = AIGuardError.BAD_RESPONSE; + throw e; + } + span.setTag(ACTION_TAG, action); + if (reason != null) { + span.setTag(REASON_TAG, reason); + } + if (tags != null && !tags.isEmpty()) { + metaStruct.put(META_STRUCT_CATEGORIES, tags); + } + if (tagProbs != null && !tagProbs.isEmpty()) { + metaStruct.put(META_STRUCT_TAG_PROBS, tagProbs); + } + if (sdsFindings != null && !sdsFindings.isEmpty()) { + metaStruct.put(META_STRUCT_SDS, sdsFindings); + } + final boolean shouldBlock = + isBlockingEnabled(options, result.get("is_blocking_enabled")) && action != Action.ALLOW; + WafMetricCollector.get().aiGuardRequest(action, shouldBlock, redactionState); + if (shouldBlock) { + span.setTag(BLOCKED_TAG, true); + throw new AIGuardAbortError(action, reason, tags, tagProbs, sdsFindings); + } + return new Evaluation( + action, reason, tags, tagProbs, sdsFindings, redaction.messages, redactionReplacements); + } finally { + metaStruct.put(META_STRUCT_MESSAGES, messagesForMetaStruct(finalMessages)); + } + } catch (AIGuardAbortError e) { + span.addThrowable(e); + throw e; + } catch (AIGuardClientError e) { + WafMetricCollector.get().aiGuardError(errorType); + span.addThrowable(e); + throw e; } catch (final Exception e) { - errorType = AIGuardError.BAD_RESPONSE; - throw e; - } - span.setTag(ACTION_TAG, action); - if (reason != null) { - span.setTag(REASON_TAG, reason); - } - if (tags != null && !tags.isEmpty()) { - metaStruct.put(META_STRUCT_CATEGORIES, tags); - } - if (tagProbs != null && !tagProbs.isEmpty()) { - metaStruct.put(META_STRUCT_TAG_PROBS, tagProbs); - } - if (sdsFindings != null && !sdsFindings.isEmpty()) { - metaStruct.put(META_STRUCT_SDS, sdsFindings); - } - final boolean shouldBlock = - isBlockingEnabled(options, result.get("is_blocking_enabled")) && action != Action.ALLOW; - WafMetricCollector.get().aiGuardRequest(action, shouldBlock, redactionState); - if (shouldBlock) { - span.setTag(BLOCKED_TAG, true); - throw new AIGuardAbortError(action, reason, tags, tagProbs, sdsFindings); - } - return new Evaluation( - action, reason, tags, tagProbs, sdsFindings, redaction.messages, redactionReplacements); - } finally { - metaStruct.put(META_STRUCT_MESSAGES, messagesForMetaStruct(finalMessages)); - } - } catch (AIGuardAbortError e) { - span.addThrowable(e); - throw e; - } catch (AIGuardClientError e) { - WafMetricCollector.get().aiGuardError(errorType); - span.addThrowable(e); - throw e; - } catch (final Exception e) { - WafMetricCollector.get().aiGuardError(errorType); - final AIGuardClientError error = - new AIGuardClientError( - "AI Guard service returned unexpected response: " + e.getMessage(), e); - span.addThrowable(error); - throw error; - } finally { - span.finish(); + WafMetricCollector.get().aiGuardError(errorType); + final AIGuardClientError error = + new AIGuardClientError("AI Guard service returned unexpected response: " + e.getMessage(), e); + span.addThrowable(error); + throw error; + } finally { + span.finish(); + } } - } - @SuppressWarnings("unchecked") - private Map parseResponseBody(final Response response) throws IOException { - final ResponseBody body = response.body(); - if (body == null) { - throw fail(response.code(), null); - } - final JsonReader reader = JsonReader.of(body.source()); - final Map parsedBody = moshi.adapter(Map.class).fromJson(reader); - final Object errors = parsedBody.get("errors"); - if (errors != null) { - throw fail(response.code(), errors); + @SuppressWarnings("unchecked") + private Map parseResponseBody(final Response response) throws IOException { + final ResponseBody body = response.body(); + if (body == null) { + throw fail(response.code(), null); + } + final JsonReader reader = JsonReader.of(body.source()); + final Map parsedBody = moshi.adapter(Map.class).fromJson(reader); + final Object errors = parsedBody.get("errors"); + if (errors != null) { + throw fail(response.code(), errors); + } + final Map data = (Map) parsedBody.get("data"); + return (Map) data.get("attributes"); } - final Map data = (Map) parsedBody.get("data"); - return (Map) data.get("attributes"); - } - - private AIGuardClientError fail(final int statusCode, final Object errors) { - return new AIGuardClientError("AI Guard service call failed, status: " + statusCode, errors); - } - private static OkHttpClient buildClient(final HttpUrl url, final long timeout) { - return OkHttpUtils.buildHttpClient(url, timeout).newBuilder().build(); - } + private AIGuardClientError fail(final int statusCode, final Object errors) { + return new AIGuardClientError("AI Guard service call failed, status: " + statusCode, errors); + } - private static Map mapOf(final String... props) { - if (props.length % 2 != 0) { - throw new IllegalArgumentException("Props must be even"); + private static OkHttpClient buildClient(final HttpUrl url, final long timeout) { + return OkHttpUtils.buildHttpClient(url, timeout).newBuilder().build(); } - final Map map = new HashMap<>(props.length << 1); - for (int i = 0; i < props.length; ) { - map.put(props[i++], props[i++]); + + private static Map mapOf(final String... props) { + if (props.length % 2 != 0) { + throw new IllegalArgumentException("Props must be even"); + } + final Map map = new HashMap<>(props.length << 1); + for (int i = 0; i < props.length; ) { + map.put(props[i++], props[i++]); + } + return map; } - return map; - } - private static class Installer extends AIGuard { - public static void install(final Evaluator evaluator) { - AIGuard.EVALUATOR = evaluator; + private static class Installer extends AIGuard { + public static void install(final Evaluator evaluator) { + AIGuard.EVALUATOR = evaluator; + } } - } - static class AIGuardFactory implements JsonAdapter.Factory { + static class AIGuardFactory implements JsonAdapter.Factory { - @Nullable - @Override - public JsonAdapter create( - final Type type, final Set annotations, final Moshi moshi) { - final Class rawType = Types.getRawType(type); - if (rawType != AIGuard.Message.class) { - return null; - } - return new MessageAdapter(moshi.adapter(AIGuard.ToolCall.class)).nullSafe(); + @Nullable + @Override + public JsonAdapter create(final Type type, final Set annotations, final Moshi moshi) { + final Class rawType = Types.getRawType(type); + if (rawType != AIGuard.Message.class) { + return null; + } + return new MessageAdapter(moshi.adapter(AIGuard.ToolCall.class)).nullSafe(); + } } - } - static class MessageAdapter extends JsonAdapter { + static class MessageAdapter extends JsonAdapter { - private final JsonAdapter toolCallAdapter; + private final JsonAdapter toolCallAdapter; - MessageAdapter(final JsonAdapter toolCallAdapter) { - this.toolCallAdapter = toolCallAdapter; - } + MessageAdapter(final JsonAdapter toolCallAdapter) { + this.toolCallAdapter = toolCallAdapter; + } - @Nullable - @Override - public Message fromJson(JsonReader reader) throws IOException { - throw new UnsupportedOperationException("Serializing only adapter"); - } + @Nullable + @Override + public Message fromJson(JsonReader reader) throws IOException { + throw new UnsupportedOperationException("Serializing only adapter"); + } - @Override - public void toJson(final JsonWriter writer, final Message value) throws IOException { - writer.beginObject(); - writeValue(writer, "role", value.getRole()); - - if (value.getContentParts() != null) { - writeContentParts(writer, "content", value.getContentParts()); - } else { - writeValue(writer, "content", value.getContent()); - } - - writeArray(writer, "tool_calls", value.getToolCalls()); - writeValue(writer, "tool_call_id", value.getToolCallId()); - writer.endObject(); - } + @Override + public void toJson(final JsonWriter writer, final Message value) throws IOException { + writer.beginObject(); + writeValue(writer, "role", value.getRole()); - private void writeContentParts( - final JsonWriter writer, final String name, final List contentParts) - throws IOException { - writer.name(name); - writer.beginArray(); - for (final ContentPart part : contentParts) { - writer.beginObject(); - - writer.name("type"); - writer.value(part.getType().toString()); - - if (part.getType() == ContentPart.Type.TEXT) { - writer.name("text"); - writer.value(part.getText()); - } else if (part.getType() == ContentPart.Type.IMAGE_URL) { - writer.name("image_url"); - writer.beginObject(); - writer.name("url"); - writer.value(part.getImageUrl().getUrl()); - writer.endObject(); - } - - writer.endObject(); - } - writer.endArray(); - } + if (value.getContentParts() != null) { + writeContentParts(writer, "content", value.getContentParts()); + } else { + writeValue(writer, "content", value.getContent()); + } - private void writeValue(final JsonWriter writer, final String name, final Object value) - throws IOException { - if (value != null) { - writer.name(name); - writer.jsonValue(value); - } - } + writeArray(writer, "tool_calls", value.getToolCalls()); + writeValue(writer, "tool_call_id", value.getToolCallId()); + writer.endObject(); + } + + private void writeContentParts(final JsonWriter writer, final String name, final List contentParts) + throws IOException { + writer.name(name); + writer.beginArray(); + for (final ContentPart part : contentParts) { + writer.beginObject(); + + writer.name("type"); + writer.value(part.getType().toString()); + + if (part.getType() == ContentPart.Type.TEXT) { + writer.name("text"); + writer.value(part.getText()); + } else if (part.getType() == ContentPart.Type.IMAGE_URL) { + writer.name("image_url"); + writer.beginObject(); + writer.name("url"); + writer.value(part.getImageUrl().getUrl()); + writer.endObject(); + } + + writer.endObject(); + } + writer.endArray(); + } + + private void writeValue(final JsonWriter writer, final String name, final Object value) throws IOException { + if (value != null) { + writer.name(name); + writer.jsonValue(value); + } + } - private void writeArray(final JsonWriter writer, final String name, final List value) - throws IOException { - if (value != null) { - writer.name(name); - writer.beginArray(); - for (final ToolCall toolCall : value) { - toolCallAdapter.toJson(writer, toolCall); + private void writeArray(final JsonWriter writer, final String name, final List value) + throws IOException { + if (value != null) { + writer.name(name); + writer.beginArray(); + for (final ToolCall toolCall : value) { + toolCallAdapter.toJson(writer, toolCall); + } + writer.endArray(); + } } - writer.endArray(); - } } - } - static class MoshiJsonRequestBody extends RequestBody { + static class MoshiJsonRequestBody extends RequestBody { - private static final MediaType JSON = MediaType.parse("application/json"); + private static final MediaType JSON = MediaType.parse("application/json"); - private final Moshi moshi; - private final Map meta; - private final Collection messages; + private final Moshi moshi; + private final Map meta; + private final Collection messages; - public MoshiJsonRequestBody( - final Moshi moshi, final Collection messages, final Map meta) { - this.moshi = moshi; - this.messages = messages; - this.meta = meta; - } + public MoshiJsonRequestBody( + final Moshi moshi, final Collection messages, final Map meta) { + this.moshi = moshi; + this.messages = messages; + this.meta = meta; + } - @Nullable - @Override - public MediaType contentType() { - return JSON; - } + @Nullable + @Override + public MediaType contentType() { + return JSON; + } - @Override - public void writeTo(final BufferedSink sink) throws IOException { - final JsonWriter writer = JsonWriter.of(sink); - writer.beginObject(); // request - writer.name("data"); - writer.beginObject(); // data - writer.name("attributes"); - writer.beginObject(); // attributes - writer.name("messages"); - moshi.adapter(Object.class).toJson(writer, messages); - writer.name("meta"); - writer.jsonValue(meta); - writer.endObject(); // attributes - writer.endObject(); // data - writer.endObject(); // request + @Override + public void writeTo(final BufferedSink sink) throws IOException { + final JsonWriter writer = JsonWriter.of(sink); + writer.beginObject(); // request + writer.name("data"); + writer.beginObject(); // data + writer.name("attributes"); + writer.beginObject(); // attributes + writer.name("messages"); + moshi.adapter(Object.class).toJson(writer, messages); + writer.name("meta"); + writer.jsonValue(meta); + writer.endObject(); // attributes + writer.endObject(); // data + writer.endObject(); // request + } } - } } diff --git a/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardSystem.java b/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardSystem.java index 43dc227bb07..4db0787abd1 100644 --- a/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardSystem.java +++ b/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/AIGuardSystem.java @@ -2,13 +2,13 @@ public abstract class AIGuardSystem { - private AIGuardSystem() {} + private AIGuardSystem() {} - public static void start() { - initializeSDK(); - } + public static void start() { + initializeSDK(); + } - private static void initializeSDK() { - AIGuardInternal.install(); - } + private static void initializeSDK() { + AIGuardInternal.install(); + } } diff --git a/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/MessageRedactor.java b/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/MessageRedactor.java index 1c98c47b1bb..dbc9dc098cd 100644 --- a/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/MessageRedactor.java +++ b/dd-java-agent/agent-aiguard/src/main/java/com/datadog/aiguard/MessageRedactor.java @@ -16,334 +16,325 @@ public interface MessageRedactor { - /** Outcome of a redaction pass. */ - final class Result { + /** Outcome of a redaction pass. */ + final class Result { - /** The redacted messages, or the very same list that was passed in when nothing was applied. */ - final List messages; + /** The redacted messages, or the very same list that was passed in when nothing was applied. */ + final List messages; - /** Number of paths successfully overwritten. */ - final int applied; + /** Number of paths successfully overwritten. */ + final int applied; - /** Number of entries skipped fail-safe (unresolvable, non-string, missing or conflicting). */ - final int skipped; + /** Number of entries skipped fail-safe (unresolvable, non-string, missing or conflicting). */ + final int skipped; - private Result(final List messages, final int applied, final int skipped) { - this.messages = messages; - this.applied = applied; - this.skipped = skipped; - } - - /** Whether at least one replacement was applied. */ - boolean redacted() { - return applied > 0; - } + private Result(final List messages, final int applied, final int skipped) { + this.messages = messages; + this.applied = applied; + this.skipped = skipped; + } - private static Result nothingApplied(final List messages, final int skipped) { - return new Result(messages, 0, skipped); - } - } - - /** - * @param replacements the raw {@code redaction_replacements} value from the response, exactly as - * it was parsed. It is deliberately untyped: a present value that is not an array is a - * backend contract break, and only an enabled redactor is in a position to count it. - */ - Result redact(final List messages, @Nullable final Object replacements); - - /** - * Whether redaction is active. When {@code false} the {@code ai_guard.redacted} tag is not - * reported at all, so an absent tag means "redaction is off", which stays distinguishable from a - * {@code false} one meaning "redaction is on and nothing was redacted". - */ - boolean enabled(); - - class NoOp implements MessageRedactor { - - @Override - public Result redact(final List messages, final @Nullable Object replacements) { - // The kill switch is off, so no redaction work is attempted and no redaction error is - // reported, not even for a malformed payload. - return Result.nothingApplied(messages, 0); - } + /** Whether at least one replacement was applied. */ + boolean redacted() { + return applied > 0; + } - @Override - public boolean enabled() { - return false; - } - } - - /** - * Applies the {@code redaction_replacements} returned by the AI Guard service to a message list. - * - *

The service returns the fully redacted string for each affected path, so this class - * never slices, concatenates, or reasons about offsets and string encodings: it resolves a path - * to a single string and overwrites it verbatim. Placeholder selection and redaction strategy are - * resolved server side and already baked into each replacement. - * - *

Two properties matter to callers: - * - *

    - *
  • Copy on write. The caller's list and messages are never mutated. Only - * the messages that are actually redacted are rebuilt; the rest are shared with the input - * list. When nothing is applied, {@link Result#messages} is the very same reference that - * was passed in, so {@code result.messages != messages} tells the caller whether anything - * changed. - *
  • Never throws. A malformed response must not break the caller's control - * flow, so unresolvable paths, non-string targets, and missing or conflicting replacements - * are skipped and counted in {@link Result#skipped}. - *
- */ - class DefaultRedactor implements MessageRedactor { - - @Override - public boolean enabled() { - return true; + private static Result nothingApplied(final List messages, final int skipped) { + return new Result(messages, 0, skipped); + } } /** - * Matches a single path segment, e.g. {@code messages[1]} or {@code function}. Kept verbatim - * from the cross-tracer specification, so every tracer tokenizes paths identically. + * @param replacements the raw {@code redaction_replacements} value from the response, exactly as + * it was parsed. It is deliberately untyped: a present value that is not an array is a + * backend contract break, and only an enabled redactor is in a position to count it. */ - private static final Pattern SEGMENT = - Pattern.compile("\\A([A-Za-z0-9_]+)(?:\\[([0-9]+)\\])?\\z"); - - /** No supported target is deeper than {@code messages[i].tool_calls[k].function.arguments}. */ - private static final int MAX_SEGMENTS = 4; - - private static final int NO_INDEX = -1; - private static final int INVALID_INDEX = -2; - - /** Longest index we bother parsing; anything longer cannot address a real list. */ - private static final int MAX_INDEX_DIGITS = 9; + Result redact(final List messages, @Nullable final Object replacements); /** - * Overwrites every path in {@code replacements} with its replacement string. - * - * @param messages the evaluated messages, never mutated - * @param rawReplacements the raw {@code redaction_replacements} value from the response - * @return the redaction outcome, holding {@code messages} itself when nothing was applied + * Whether redaction is active. When {@code false} the {@code ai_guard.redacted} tag is not + * reported at all, so an absent tag means "redaction is off", which stays distinguishable from a + * {@code false} one meaning "redaction is on and nothing was redacted". */ - @Override - public Result redact(final List messages, @Nullable final Object rawReplacements) { - if (messages == null || messages.isEmpty() || rawReplacements == null) { - return Result.nothingApplied(messages, 0); - } - if (!(rawReplacements instanceof List)) { - // The field is present but is not an array, so there is no entry to even look at. Counted - // as one error rather than dropped: the backend broke the response contract. - return Result.nothingApplied(messages, 1); - } - final List replacements = (List) rawReplacements; - if (replacements.isEmpty()) { - return Result.nothingApplied(messages, 0); - } - - int skipped = 0; - - // Collect one authoritative replacement per path, dropping the ones the backend contradicts - // itself on. Insertion ordered so that a malformed response yields reproducible counters. - final Map byPath = new LinkedHashMap<>(); - Set conflicting = null; - for (final Object entry : replacements) { - if (!(entry instanceof Map)) { - skipped++; - continue; - } - final Map map = (Map) entry; - final Object path = map.get("path"); - final Object replacement = map.get("replacement"); - // An empty replacement is legitimate: "" is a supported placeholder meaning "remove". - if (!(path instanceof String) - || ((String) path).isEmpty() - || !(replacement instanceof String)) { - skipped++; - continue; - } - final String previous = byPath.put((String) path, (String) replacement); - if (previous != null && !previous.equals(replacement)) { - // Conflicting replacements for one path: skip it rather than guess which one wins. - if (conflicting == null) { - conflicting = new HashSet<>(2); - } - conflicting.add((String) path); - } - } - if (conflicting != null) { - for (final String path : conflicting) { - byPath.remove(path); - skipped++; - } - } - if (byPath.isEmpty()) { - return Result.nothingApplied(messages, skipped); - } - - final String[] names = new String[MAX_SEGMENTS]; - final int[] indices = new int[MAX_SEGMENTS]; - List working = null; - int applied = 0; - - for (final Map.Entry entry : byPath.entrySet()) { - final int count = parseSegments(entry.getKey(), names, indices); - // Paths are rooted at the evaluated array, so they always start with `messages[i]`. - if (count < 2 - || indices[0] == NO_INDEX - || !"messages".equals(names[0]) - || indices[0] >= messages.size()) { - skipped++; - continue; - } - final int index = indices[0]; - final Message current = working == null ? messages.get(index) : working.get(index); - final Message updated = apply(current, names, indices, count, entry.getValue()); - if (updated == null) { - skipped++; - continue; + boolean enabled(); + + class NoOp implements MessageRedactor { + + @Override + public Result redact(final List messages, final @Nullable Object replacements) { + // The kill switch is off, so no redaction work is attempted and no redaction error is + // reported, not even for a malformed payload. + return Result.nothingApplied(messages, 0); } - if (working == null) { - working = new ArrayList<>(messages); + + @Override + public boolean enabled() { + return false; } - working.set(index, updated); - applied++; - } - - if (working == null) { - return Result.nothingApplied(messages, skipped); - } - return new Result(working, applied, skipped); } /** - * Rebuilds {@code message} with {@code replacement} written at the target the path resolves to. + * Applies the {@code redaction_replacements} returned by the AI Guard service to a message list. + * + *

The service returns the fully redacted string for each affected path, so this class + * never slices, concatenates, or reasons about offsets and string encodings: it resolves a path + * to a single string and overwrites it verbatim. Placeholder selection and redaction strategy are + * resolved server side and already baked into each replacement. + * + *

Two properties matter to callers: * - * @return the rebuilt message, or {@code null} when the path does not resolve to a writable - * string, in which case the caller skips it fail-safe + *

    + *
  • Copy on write. The caller's list and messages are never mutated. Only + * the messages that are actually redacted are rebuilt; the rest are shared with the input + * list. When nothing is applied, {@link Result#messages} is the very same reference that + * was passed in, so {@code result.messages != messages} tells the caller whether anything + * changed. + *
  • Never throws. A malformed response must not break the caller's control + * flow, so unresolvable paths, non-string targets, and missing or conflicting replacements + * are skipped and counted in {@link Result#skipped}. + *
*/ - @Nullable - private static Message apply( - final Message message, - final String[] names, - final int[] indices, - final int count, - final String replacement) { - - if ("content".equals(names[1])) { - if (indices[1] == NO_INDEX) { - // messages[i].content - if (count != 2 || message.getContentParts() != null || message.getContent() == null) { - // A message holding content parts resolves to a list, not a string: skip. - return null; - } - return withContent(message, replacement); - } - // messages[i].content[j].text - if (count != 3 || !"text".equals(names[2]) || indices[2] != NO_INDEX) { - return null; - } - final List parts = message.getContentParts(); - if (parts == null || indices[1] >= parts.size()) { - return null; - } - final ContentPart part = parts.get(indices[1]); - // Only text parts are redactable; image locators are out of scope. - if (part.getType() != ContentPart.Type.TEXT) { - return null; - } - final List updated = new ArrayList<>(parts); - updated.set(indices[1], ContentPart.text(replacement)); - return withContentParts(message, updated); - } - - if ("tool_calls".equals(names[1])) { - // messages[i].tool_calls[k].function.arguments - if (indices[1] == NO_INDEX - || count != 4 - || !"function".equals(names[2]) - || indices[2] != NO_INDEX - || !"arguments".equals(names[3]) - || indices[3] != NO_INDEX) { - return null; - } - final List toolCalls = message.getToolCalls(); - if (toolCalls == null || indices[1] >= toolCalls.size()) { - return null; - } - final ToolCall toolCall = toolCalls.get(indices[1]); - final Function function = toolCall.getFunction(); - if (function == null || function.getArguments() == null) { - return null; + class DefaultRedactor implements MessageRedactor { + + @Override + public boolean enabled() { + return true; } - final List updated = new ArrayList<>(toolCalls); - updated.set( - indices[1], - new ToolCall(toolCall.getId(), new Function(function.getName(), replacement))); - return withToolCalls(message, updated); - } - - return null; - } - private static Message withContent(final Message message, final String content) { - return new Message( - message.getRole(), content, message.getToolCalls(), message.getToolCallId()); - } + /** + * Matches a single path segment, e.g. {@code messages[1]} or {@code function}. Kept verbatim + * from the cross-tracer specification, so every tracer tokenizes paths identically. + */ + private static final Pattern SEGMENT = Pattern.compile("\\A([A-Za-z0-9_]+)(?:\\[([0-9]+)\\])?\\z"); + + /** No supported target is deeper than {@code messages[i].tool_calls[k].function.arguments}. */ + private static final int MAX_SEGMENTS = 4; + + private static final int NO_INDEX = -1; + private static final int INVALID_INDEX = -2; + + /** Longest index we bother parsing; anything longer cannot address a real list. */ + private static final int MAX_INDEX_DIGITS = 9; + + /** + * Overwrites every path in {@code replacements} with its replacement string. + * + * @param messages the evaluated messages, never mutated + * @param rawReplacements the raw {@code redaction_replacements} value from the response + * @return the redaction outcome, holding {@code messages} itself when nothing was applied + */ + @Override + public Result redact(final List messages, @Nullable final Object rawReplacements) { + if (messages == null || messages.isEmpty() || rawReplacements == null) { + return Result.nothingApplied(messages, 0); + } + if (!(rawReplacements instanceof List)) { + // The field is present but is not an array, so there is no entry to even look at. Counted + // as one error rather than dropped: the backend broke the response contract. + return Result.nothingApplied(messages, 1); + } + final List replacements = (List) rawReplacements; + if (replacements.isEmpty()) { + return Result.nothingApplied(messages, 0); + } + + int skipped = 0; + + // Collect one authoritative replacement per path, dropping the ones the backend contradicts + // itself on. Insertion ordered so that a malformed response yields reproducible counters. + final Map byPath = new LinkedHashMap<>(); + Set conflicting = null; + for (final Object entry : replacements) { + if (!(entry instanceof Map)) { + skipped++; + continue; + } + final Map map = (Map) entry; + final Object path = map.get("path"); + final Object replacement = map.get("replacement"); + // An empty replacement is legitimate: "" is a supported placeholder meaning "remove". + if (!(path instanceof String) || ((String) path).isEmpty() || !(replacement instanceof String)) { + skipped++; + continue; + } + final String previous = byPath.put((String) path, (String) replacement); + if (previous != null && !previous.equals(replacement)) { + // Conflicting replacements for one path: skip it rather than guess which one wins. + if (conflicting == null) { + conflicting = new HashSet<>(2); + } + conflicting.add((String) path); + } + } + if (conflicting != null) { + for (final String path : conflicting) { + byPath.remove(path); + skipped++; + } + } + if (byPath.isEmpty()) { + return Result.nothingApplied(messages, skipped); + } + + final String[] names = new String[MAX_SEGMENTS]; + final int[] indices = new int[MAX_SEGMENTS]; + List working = null; + int applied = 0; + + for (final Map.Entry entry : byPath.entrySet()) { + final int count = parseSegments(entry.getKey(), names, indices); + // Paths are rooted at the evaluated array, so they always start with `messages[i]`. + if (count < 2 + || indices[0] == NO_INDEX + || !"messages".equals(names[0]) + || indices[0] >= messages.size()) { + skipped++; + continue; + } + final int index = indices[0]; + final Message current = working == null ? messages.get(index) : working.get(index); + final Message updated = apply(current, names, indices, count, entry.getValue()); + if (updated == null) { + skipped++; + continue; + } + if (working == null) { + working = new ArrayList<>(messages); + } + working.set(index, updated); + applied++; + } + + if (working == null) { + return Result.nothingApplied(messages, skipped); + } + return new Result(working, applied, skipped); + } - private static Message withContentParts( - final Message message, final List contentParts) { - return new Message( - message.getRole(), contentParts, message.getToolCalls(), message.getToolCallId()); - } + /** + * Rebuilds {@code message} with {@code replacement} written at the target the path resolves to. + * + * @return the rebuilt message, or {@code null} when the path does not resolve to a writable + * string, in which case the caller skips it fail-safe + */ + @Nullable + private static Message apply( + final Message message, + final String[] names, + final int[] indices, + final int count, + final String replacement) { + + if ("content".equals(names[1])) { + if (indices[1] == NO_INDEX) { + // messages[i].content + if (count != 2 || message.getContentParts() != null || message.getContent() == null) { + // A message holding content parts resolves to a list, not a string: skip. + return null; + } + return withContent(message, replacement); + } + // messages[i].content[j].text + if (count != 3 || !"text".equals(names[2]) || indices[2] != NO_INDEX) { + return null; + } + final List parts = message.getContentParts(); + if (parts == null || indices[1] >= parts.size()) { + return null; + } + final ContentPart part = parts.get(indices[1]); + // Only text parts are redactable; image locators are out of scope. + if (part.getType() != ContentPart.Type.TEXT) { + return null; + } + final List updated = new ArrayList<>(parts); + updated.set(indices[1], ContentPart.text(replacement)); + return withContentParts(message, updated); + } + + if ("tool_calls".equals(names[1])) { + // messages[i].tool_calls[k].function.arguments + if (indices[1] == NO_INDEX + || count != 4 + || !"function".equals(names[2]) + || indices[2] != NO_INDEX + || !"arguments".equals(names[3]) + || indices[3] != NO_INDEX) { + return null; + } + final List toolCalls = message.getToolCalls(); + if (toolCalls == null || indices[1] >= toolCalls.size()) { + return null; + } + final ToolCall toolCall = toolCalls.get(indices[1]); + final Function function = toolCall.getFunction(); + if (function == null || function.getArguments() == null) { + return null; + } + final List updated = new ArrayList<>(toolCalls); + updated.set(indices[1], new ToolCall(toolCall.getId(), new Function(function.getName(), replacement))); + return withToolCalls(message, updated); + } - private static Message withToolCalls(final Message message, final List toolCalls) { - final List contentParts = message.getContentParts(); - // A message carries either content parts or a content string, never both; preserve whichever. - return contentParts != null - ? new Message(message.getRole(), contentParts, toolCalls, message.getToolCallId()) - : new Message( - message.getRole(), message.getContent(), toolCalls, message.getToolCallId()); - } + return null; + } - /** - * Splits a path on {@code .} and matches every segment against {@link #SEGMENT}, filling {@code - * names} and {@code indices}. - * - * @return the number of segments, or {@code -1} when any segment is malformed or the path is - * deeper than any supported target - */ - private static int parseSegments(final String path, final String[] names, final int[] indices) { - int count = 0; - int start = 0; - while (true) { - if (count == MAX_SEGMENTS) { - return -1; + private static Message withContent(final Message message, final String content) { + return new Message(message.getRole(), content, message.getToolCalls(), message.getToolCallId()); } - final int dot = path.indexOf('.', start); - final String segment = dot < 0 ? path.substring(start) : path.substring(start, dot); - final Matcher matcher = SEGMENT.matcher(segment); - if (!matcher.matches()) { - return -1; + + private static Message withContentParts(final Message message, final List contentParts) { + return new Message(message.getRole(), contentParts, message.getToolCalls(), message.getToolCallId()); } - final int index = parseIndex(matcher.group(2)); - if (index == INVALID_INDEX) { - return -1; + + private static Message withToolCalls(final Message message, final List toolCalls) { + final List contentParts = message.getContentParts(); + // A message carries either content parts or a content string, never both; preserve whichever. + return contentParts != null + ? new Message(message.getRole(), contentParts, toolCalls, message.getToolCallId()) + : new Message(message.getRole(), message.getContent(), toolCalls, message.getToolCallId()); } - names[count] = matcher.group(1); - indices[count] = index; - count++; - if (dot < 0) { - return count; + + /** + * Splits a path on {@code .} and matches every segment against {@link #SEGMENT}, filling {@code + * names} and {@code indices}. + * + * @return the number of segments, or {@code -1} when any segment is malformed or the path is + * deeper than any supported target + */ + private static int parseSegments(final String path, final String[] names, final int[] indices) { + int count = 0; + int start = 0; + while (true) { + if (count == MAX_SEGMENTS) { + return -1; + } + final int dot = path.indexOf('.', start); + final String segment = dot < 0 ? path.substring(start) : path.substring(start, dot); + final Matcher matcher = SEGMENT.matcher(segment); + if (!matcher.matches()) { + return -1; + } + final int index = parseIndex(matcher.group(2)); + if (index == INVALID_INDEX) { + return -1; + } + names[count] = matcher.group(1); + indices[count] = index; + count++; + if (dot < 0) { + return count; + } + start = dot + 1; + } } - start = dot + 1; - } - } - private static int parseIndex(@Nullable final String index) { - if (index == null) { - return NO_INDEX; - } - // The regex guarantees digits only, so the sole remaining hazard is overflow. - return index.length() > MAX_INDEX_DIGITS ? INVALID_INDEX : Integer.parseInt(index); + private static int parseIndex(@Nullable final String index) { + if (index == null) { + return NO_INDEX; + } + // The regex guarantees digits only, so the sole remaining hazard is overflow. + return index.length() > MAX_INDEX_DIGITS ? INVALID_INDEX : Integer.parseInt(index); + } } - } } diff --git a/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/AIGuardInternalRedactionTest.java b/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/AIGuardInternalRedactionTest.java index 4822bdf432d..4035c9eef50 100644 --- a/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/AIGuardInternalRedactionTest.java +++ b/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/AIGuardInternalRedactionTest.java @@ -53,368 +53,356 @@ @ExtendWith(WithConfigExtension.class) class AIGuardInternalRedactionTest { - private static final HttpUrl URL = - HttpUrl.parse("https://app.datadoghq.com/api/v2/ai-guard/evaluate"); - - private static final String SENSITIVE = "My SSN is 123-45-6789"; - private static final String REDACTED = "My SSN is "; - - private static final List MESSAGES = - asList( - Message.message("system", "You are a helpful assistant."), - Message.message("user", SENSITIVE)); - - private AgentSpan span; - private AgentTracer.TracerAPI originalTracer; - private Map metaStruct; - - @BeforeEach - void setUp() { - originalTracer = AgentTracer.get(); - - span = mock(AgentSpan.class); - lenient().when(span.getLocalRootSpan()).thenReturn(mock(AgentSpan.class)); - doAnswer( - invocation -> { - metaStruct = invocation.getArgument(1); - return span; - }) - .when(span) - .setMetaStruct(eq(META_STRUCT_TAG), any()); - - final AgentTracer.SpanBuilder builder = mock(AgentTracer.SpanBuilder.class, RETURNS_DEEP_STUBS); - lenient().when(builder.start()).thenReturn(span); - final AgentTracer.TracerAPI tracer = mock(AgentTracer.TracerAPI.class); - lenient().when(tracer.buildSpan(anyString(), anyString())).thenReturn(builder); - lenient().when(tracer.activateSpan(any())).thenReturn(mock(ContextScope.class)); - AgentTracer.forceRegister(tracer); - } - - @AfterEach - void tearDown() { - AgentTracer.forceRegister(originalTracer); - AIGuardInternal.uninstall(); - } - - @Test - void redactsMessagesAndRewritesMetaStruct() { - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements(REDACTED))); - - assertEquals(REDACTED, evaluation.getMessages().get(1).getContent()); - // the untouched message is carried over as is - assertEquals("You are a helpful assistant.", evaluation.getMessages().get(0).getContent()); - assertEquals(REDACTED, metaStructMessages().get(1).getContent()); - verify(span).setTag(REDACTED_TAG, true); - // the caller's list is left alone - assertEquals(SENSITIVE, MESSAGES.get(1).getContent()); - } - - @Test - void reportsNotRedactedWhenResponseCarriesNoReplacements() { - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, null)); - - assertSame(MESSAGES, evaluation.getMessages()); - assertEquals(SENSITIVE, metaStructMessages().get(1).getContent()); - verify(span).setTag(REDACTED_TAG, false); - } - - @Test - void reportsNotRedactedWhenReplacementsAreEmpty() { - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, "[]")); - - assertSame(MESSAGES, evaluation.getMessages()); - verify(span).setTag(REDACTED_TAG, false); - } - - @Test - void reportsNotRedactedWhenEveryReplacementIsSkipped() { - final String unresolvable = "[{\"path\":\"messages[9].content\",\"replacement\":\"nope\"}]"; - - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, unresolvable)); - - assertSame(MESSAGES, evaluation.getMessages()); - assertEquals(SENSITIVE, metaStructMessages().get(1).getContent()); - verify(span).setTag(REDACTED_TAG, false); - } - - @Test - void killSwitchLeavesMessagesUntouchedAndEmitsNoTag() { - WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); - - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements(REDACTED))); - - assertSame(MESSAGES, evaluation.getMessages()); - assertEquals(SENSITIVE, metaStructMessages().get(1).getContent()); - // an absent tag means "redaction is off", which is distinct from a false one - verify(span, never()).setTag(eq(REDACTED_TAG), anyBoolean()); - } - - @Test - void redactsMetaStructOnBlockedEvaluation() { - final AIGuardAbortError error = - assertThrows( - AIGuardAbortError.class, - () -> evaluate(responseWith("DENY", true, replacements(REDACTED)))); - - // reporting is still redacted on the blocked path - assertEquals(REDACTED, metaStructMessages().get(1).getContent()); - verify(span).setTag(REDACTED_TAG, true); - - // the abort error must not carry the conversation, redacted or otherwise - assertFalse(errorText(error).contains(SENSITIVE)); - assertFalse(errorText(error).contains(REDACTED)); - } - - /** - * The meta struct messages are built exactly once per evaluation, in the {@code finally} that - * closes the request, so one evaluation reports one truncation no matter how much redaction it - * applied. - */ - @Test - void reportsContentTruncationOnlyOnceWhenRedacting() { - WithConfigExtension.injectEnvConfig("DD_AI_GUARD_MAX_CONTENT_SIZE", "8", false); - drainTelemetry(); - - evaluate(responseWith("ALLOW", false, replacements(REDACTED))); - - assertEquals(1, truncationMetrics()); - } - - @Test - void reportsRedactedTrueInTelemetryWhenSomethingWasRedacted() { - drainTelemetry(); - - evaluate(responseWith("ALLOW", false, replacements(REDACTED))); - - assertTrue(requestTags().contains("redacted:true")); - } - - @Test - void reportsRedactedFalseInTelemetryWhenNothingWasRedacted() { - drainTelemetry(); - - evaluate(responseWith("ALLOW", false, null)); - - assertTrue(requestTags().contains("redacted:false")); - } - - @Test - void reportsNoRedactedTagInTelemetryWhenKillSwitchIsOff() { - WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); - drainTelemetry(); - - evaluate(responseWith("ALLOW", false, replacements(REDACTED))); - - final List tags = requestTags(); - // absence of the tag is what tells a consumer that redaction is off - assertFalse(tags.contains("redacted:true")); - assertFalse(tags.contains("redacted:false")); - assertTrue(tags.contains("error:false")); - } - - private static List requestTags() { - WafMetricCollector.get().prepareMetrics(); - for (final MetricCollector.Metric metric : WafMetricCollector.get().drain()) { - if ("requests".equals(metric.metricName)) { - return metric.tags; - } - } - throw new AssertionError("no ai_guard requests metric was reported"); - } - - private static void drainTelemetry() { - WafMetricCollector.get().prepareMetrics(); - WafMetricCollector.get().drain(); - } - - private static long truncationMetrics() { - WafMetricCollector.get().prepareMetrics(); - long count = 0; - for (final MetricCollector.Metric metric : WafMetricCollector.get().drain()) { - if ("truncated".equals(metric.metricName)) { - count += metric.value.longValue(); - } - } - return count; - } - - @Test - void reportsTheServiceReplacementsVerbatimIncludingUnappliedEntries() { - final String replacements = - "[{\"path\":\"messages[1].content\",\"replacement\":\"" - + REDACTED - + "\"},{\"path\":\"messages[9].content\",\"replacement\":\"never applied\"}]"; - - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements)); - - assertEquals(REDACTED, evaluation.getMessages().get(1).getContent()); - // messages[9] cannot resolve and is skipped fail-safe, but the service asked for it, so it is - // still reported back to the caller - assertEquals(2, evaluation.getRedactionReplacements().size()); - } - - @Test - void reportsTheServiceReplacementsEvenWhenTheKillSwitchIsOff() { - WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); - - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements(REDACTED))); - - // nothing was applied, but the service's request is still visible to the caller - assertSame(MESSAGES, evaluation.getMessages()); - assertEquals(1, evaluation.getRedactionReplacements().size()); - } - - @Test - void reportsNotRedactedWhenTheEvaluationFails() { - // no action field, so the response is rejected before any redaction work happens - assertThrows( - AIGuardClientError.class, () -> evaluate("{\"data\":{\"attributes\":{\"tags\":[]}}}")); - - // a failed evaluation redacted nothing, and must not look like the kill switch is off - verify(span).setTag(REDACTED_TAG, false); - verify(span, never()).setTag(REDACTED_TAG, true); - } + private static final HttpUrl URL = HttpUrl.parse("https://app.datadoghq.com/api/v2/ai-guard/evaluate"); + + private static final String SENSITIVE = "My SSN is 123-45-6789"; + private static final String REDACTED = "My SSN is "; + + private static final List MESSAGES = + asList(Message.message("system", "You are a helpful assistant."), Message.message("user", SENSITIVE)); + + private AgentSpan span; + private AgentTracer.TracerAPI originalTracer; + private Map metaStruct; + + @BeforeEach + void setUp() { + originalTracer = AgentTracer.get(); + + span = mock(AgentSpan.class); + lenient().when(span.getLocalRootSpan()).thenReturn(mock(AgentSpan.class)); + doAnswer(invocation -> { + metaStruct = invocation.getArgument(1); + return span; + }) + .when(span) + .setMetaStruct(eq(META_STRUCT_TAG), any()); + + final AgentTracer.SpanBuilder builder = mock(AgentTracer.SpanBuilder.class, RETURNS_DEEP_STUBS); + lenient().when(builder.start()).thenReturn(span); + final AgentTracer.TracerAPI tracer = mock(AgentTracer.TracerAPI.class); + lenient().when(tracer.buildSpan(anyString(), anyString())).thenReturn(builder); + lenient().when(tracer.activateSpan(any())).thenReturn(mock(ContextScope.class)); + AgentTracer.forceRegister(tracer); + } + + @AfterEach + void tearDown() { + AgentTracer.forceRegister(originalTracer); + AIGuardInternal.uninstall(); + } + + @Test + void redactsMessagesAndRewritesMetaStruct() { + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements(REDACTED))); + + assertEquals(REDACTED, evaluation.getMessages().get(1).getContent()); + // the untouched message is carried over as is + assertEquals( + "You are a helpful assistant.", evaluation.getMessages().get(0).getContent()); + assertEquals(REDACTED, metaStructMessages().get(1).getContent()); + verify(span).setTag(REDACTED_TAG, true); + // the caller's list is left alone + assertEquals(SENSITIVE, MESSAGES.get(1).getContent()); + } + + @Test + void reportsNotRedactedWhenResponseCarriesNoReplacements() { + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, null)); + + assertSame(MESSAGES, evaluation.getMessages()); + assertEquals(SENSITIVE, metaStructMessages().get(1).getContent()); + verify(span).setTag(REDACTED_TAG, false); + } + + @Test + void reportsNotRedactedWhenReplacementsAreEmpty() { + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, "[]")); + + assertSame(MESSAGES, evaluation.getMessages()); + verify(span).setTag(REDACTED_TAG, false); + } + + @Test + void reportsNotRedactedWhenEveryReplacementIsSkipped() { + final String unresolvable = "[{\"path\":\"messages[9].content\",\"replacement\":\"nope\"}]"; + + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, unresolvable)); + + assertSame(MESSAGES, evaluation.getMessages()); + assertEquals(SENSITIVE, metaStructMessages().get(1).getContent()); + verify(span).setTag(REDACTED_TAG, false); + } + + @Test + void killSwitchLeavesMessagesUntouchedAndEmitsNoTag() { + WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); + + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements(REDACTED))); + + assertSame(MESSAGES, evaluation.getMessages()); + assertEquals(SENSITIVE, metaStructMessages().get(1).getContent()); + // an absent tag means "redaction is off", which is distinct from a false one + verify(span, never()).setTag(eq(REDACTED_TAG), anyBoolean()); + } + + @Test + void redactsMetaStructOnBlockedEvaluation() { + final AIGuardAbortError error = assertThrows( + AIGuardAbortError.class, () -> evaluate(responseWith("DENY", true, replacements(REDACTED)))); + + // reporting is still redacted on the blocked path + assertEquals(REDACTED, metaStructMessages().get(1).getContent()); + verify(span).setTag(REDACTED_TAG, true); + + // the abort error must not carry the conversation, redacted or otherwise + assertFalse(errorText(error).contains(SENSITIVE)); + assertFalse(errorText(error).contains(REDACTED)); + } + + /** + * The meta struct messages are built exactly once per evaluation, in the {@code finally} that + * closes the request, so one evaluation reports one truncation no matter how much redaction it + * applied. + */ + @Test + void reportsContentTruncationOnlyOnceWhenRedacting() { + WithConfigExtension.injectEnvConfig("DD_AI_GUARD_MAX_CONTENT_SIZE", "8", false); + drainTelemetry(); + + evaluate(responseWith("ALLOW", false, replacements(REDACTED))); + + assertEquals(1, truncationMetrics()); + } + + @Test + void reportsRedactedTrueInTelemetryWhenSomethingWasRedacted() { + drainTelemetry(); + + evaluate(responseWith("ALLOW", false, replacements(REDACTED))); + + assertTrue(requestTags().contains("redacted:true")); + } + + @Test + void reportsRedactedFalseInTelemetryWhenNothingWasRedacted() { + drainTelemetry(); + + evaluate(responseWith("ALLOW", false, null)); + + assertTrue(requestTags().contains("redacted:false")); + } + + @Test + void reportsNoRedactedTagInTelemetryWhenKillSwitchIsOff() { + WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); + drainTelemetry(); + + evaluate(responseWith("ALLOW", false, replacements(REDACTED))); + + final List tags = requestTags(); + // absence of the tag is what tells a consumer that redaction is off + assertFalse(tags.contains("redacted:true")); + assertFalse(tags.contains("redacted:false")); + assertTrue(tags.contains("error:false")); + } + + private static List requestTags() { + WafMetricCollector.get().prepareMetrics(); + for (final MetricCollector.Metric metric : WafMetricCollector.get().drain()) { + if ("requests".equals(metric.metricName)) { + return metric.tags; + } + } + throw new AssertionError("no ai_guard requests metric was reported"); + } + + private static void drainTelemetry() { + WafMetricCollector.get().prepareMetrics(); + WafMetricCollector.get().drain(); + } + + private static long truncationMetrics() { + WafMetricCollector.get().prepareMetrics(); + long count = 0; + for (final MetricCollector.Metric metric : WafMetricCollector.get().drain()) { + if ("truncated".equals(metric.metricName)) { + count += metric.value.longValue(); + } + } + return count; + } + + @Test + void reportsTheServiceReplacementsVerbatimIncludingUnappliedEntries() { + final String replacements = "[{\"path\":\"messages[1].content\",\"replacement\":\"" + + REDACTED + + "\"},{\"path\":\"messages[9].content\",\"replacement\":\"never applied\"}]"; + + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements)); + + assertEquals(REDACTED, evaluation.getMessages().get(1).getContent()); + // messages[9] cannot resolve and is skipped fail-safe, but the service asked for it, so it is + // still reported back to the caller + assertEquals(2, evaluation.getRedactionReplacements().size()); + } - @Test - void redactsMetaStructWhenTheResponseFailsAfterCarryingReplacements() { - // the replacements arrived, but the response is rejected for a missing action field - final String body = - "{\"data\":{\"attributes\":{\"tags\":[],\"redaction_replacements\":" - + replacements(REDACTED) - + "}}}"; - - assertThrows(AIGuardClientError.class, () -> evaluate(body)); - - // the tracer held the replacements, so what it reports must be redacted - assertEquals(REDACTED, metaStructMessages().get(1).getContent()); - } - - @Test - void emitsNoTagWhenTheEvaluationFailsAndTheKillSwitchIsOff() { - WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); - - assertThrows( - AIGuardClientError.class, () -> evaluate("{\"data\":{\"attributes\":{\"tags\":[]}}}")); - - verify(span, never()).setTag(eq(REDACTED_TAG), anyBoolean()); - } - - @Test - void doesNotFailWhenReplacementsAreMalformed() { - drainTelemetry(); - - final Evaluation evaluation = evaluate(responseWith("ALLOW", false, "\"not-an-array\"")); - - assertSame(MESSAGES, evaluation.getMessages()); - verify(span).setTag(REDACTED_TAG, false); - // the field is present but is not an array, so the backend broke the response contract - assertEquals(1, redactionErrors()); - } - - @Test - void reportsOneRedactionErrorPerReplacementItCouldNotApply() { - final String unresolvable = - "[{\"path\":\"messages[9].content\",\"replacement\":\"nope\"}," - + "{\"path\":\"not a path\",\"replacement\":\"nope\"}]"; - drainTelemetry(); - - evaluate(responseWith("ALLOW", false, unresolvable)); - - assertEquals(2, redactionErrors()); - } - - @Test - void reportsNoRedactionErrorWhenEveryReplacementApplies() { - drainTelemetry(); - - evaluate(responseWith("ALLOW", false, replacements(REDACTED))); - - assertEquals(0, redactionErrors()); - } - - @Test - void reportsNoRedactionErrorWhenTheKillSwitchIsOff() { - WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); - drainTelemetry(); - - // no redaction work is attempted, so a malformed payload is not even looked at - evaluate(responseWith("ALLOW", false, "\"not-an-array\"")); - - assertEquals(0, redactionErrors()); - } - - /** A redaction error never counts as a failed request: it does not fail the evaluation. */ - @Test - void doesNotCountARedactionErrorAsAFailedRequest() { - final String unresolvable = "[{\"path\":\"messages[9].content\",\"replacement\":\"nope\"}]"; - drainTelemetry(); - - evaluate(responseWith("ALLOW", false, unresolvable)); - - assertTrue(requestTags().contains("error:false")); - } - - private static long redactionErrors() { - WafMetricCollector.get().prepareMetrics(); - long count = 0; - for (final MetricCollector.Metric metric : WafMetricCollector.get().drain()) { - if ("error".equals(metric.metricName) && metric.tags.contains("type:redaction_error")) { - count += metric.value.longValue(); - } - } - return count; - } - - private static String errorText(final AIGuardAbortError error) { - return String.valueOf(error.getMessage()) + error + String.valueOf(error.getReason()); - } - - @SuppressWarnings("unchecked") - private List metaStructMessages() { - assertNotNull(metaStruct, "meta struct was never attached to the span"); - return (List) metaStruct.get(META_STRUCT_MESSAGES); - } - - private static String replacements(final String replacement) { - return "[{\"path\":\"messages[1].content\",\"replacement\":\"" + replacement + "\"}]"; - } - - private static String responseWith( - final String action, final boolean blocking, final String redactionReplacements) { - final StringBuilder attributes = new StringBuilder(); - attributes - .append("{\"action\":\"") - .append(action) - .append("\",\"reason\":\"a reason\",\"is_blocking_enabled\":") - .append(blocking) - .append(",\"tags\":[]"); - if (redactionReplacements != null) { - attributes.append(",\"redaction_replacements\":").append(redactionReplacements); - } - attributes.append('}'); - return "{\"data\":{\"id\":\"1\",\"type\":\"evaluations\",\"attributes\":" + attributes + "}}"; - } - - private Evaluation evaluate(final String responseBody) { - final OkHttpClient client = mock(OkHttpClient.class); - final Call call = mock(Call.class); - try { - lenient().when(call.execute()).thenReturn(response(responseBody)); - } catch (final IOException e) { - throw new AssertionError(e); - } - lenient().when(client.newCall(any(Request.class))).thenReturn(call); - return new AIGuardInternal(URL, Collections.emptyMap(), client) - .evaluate(MESSAGES, new Options().block(true)); - } - - private static Response response(final String json) { - return new Response.Builder() - .protocol(Protocol.HTTP_1_1) - .message("ok") - .request(new Request.Builder().url(URL).build()) - .code(200) - .body(ResponseBody.create(MediaType.parse("application/json"), json)) - .build(); - } + @Test + void reportsTheServiceReplacementsEvenWhenTheKillSwitchIsOff() { + WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); + + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, replacements(REDACTED))); + + // nothing was applied, but the service's request is still visible to the caller + assertSame(MESSAGES, evaluation.getMessages()); + assertEquals(1, evaluation.getRedactionReplacements().size()); + } + + @Test + void reportsNotRedactedWhenTheEvaluationFails() { + // no action field, so the response is rejected before any redaction work happens + assertThrows(AIGuardClientError.class, () -> evaluate("{\"data\":{\"attributes\":{\"tags\":[]}}}")); + + // a failed evaluation redacted nothing, and must not look like the kill switch is off + verify(span).setTag(REDACTED_TAG, false); + verify(span, never()).setTag(REDACTED_TAG, true); + } + + @Test + void redactsMetaStructWhenTheResponseFailsAfterCarryingReplacements() { + // the replacements arrived, but the response is rejected for a missing action field + final String body = + "{\"data\":{\"attributes\":{\"tags\":[],\"redaction_replacements\":" + replacements(REDACTED) + "}}}"; + + assertThrows(AIGuardClientError.class, () -> evaluate(body)); + + // the tracer held the replacements, so what it reports must be redacted + assertEquals(REDACTED, metaStructMessages().get(1).getContent()); + } + + @Test + void emitsNoTagWhenTheEvaluationFailsAndTheKillSwitchIsOff() { + WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); + + assertThrows(AIGuardClientError.class, () -> evaluate("{\"data\":{\"attributes\":{\"tags\":[]}}}")); + + verify(span, never()).setTag(eq(REDACTED_TAG), anyBoolean()); + } + + @Test + void doesNotFailWhenReplacementsAreMalformed() { + drainTelemetry(); + + final Evaluation evaluation = evaluate(responseWith("ALLOW", false, "\"not-an-array\"")); + + assertSame(MESSAGES, evaluation.getMessages()); + verify(span).setTag(REDACTED_TAG, false); + // the field is present but is not an array, so the backend broke the response contract + assertEquals(1, redactionErrors()); + } + + @Test + void reportsOneRedactionErrorPerReplacementItCouldNotApply() { + final String unresolvable = "[{\"path\":\"messages[9].content\",\"replacement\":\"nope\"}," + + "{\"path\":\"not a path\",\"replacement\":\"nope\"}]"; + drainTelemetry(); + + evaluate(responseWith("ALLOW", false, unresolvable)); + + assertEquals(2, redactionErrors()); + } + + @Test + void reportsNoRedactionErrorWhenEveryReplacementApplies() { + drainTelemetry(); + + evaluate(responseWith("ALLOW", false, replacements(REDACTED))); + + assertEquals(0, redactionErrors()); + } + + @Test + void reportsNoRedactionErrorWhenTheKillSwitchIsOff() { + WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); + drainTelemetry(); + + // no redaction work is attempted, so a malformed payload is not even looked at + evaluate(responseWith("ALLOW", false, "\"not-an-array\"")); + + assertEquals(0, redactionErrors()); + } + + /** A redaction error never counts as a failed request: it does not fail the evaluation. */ + @Test + void doesNotCountARedactionErrorAsAFailedRequest() { + final String unresolvable = "[{\"path\":\"messages[9].content\",\"replacement\":\"nope\"}]"; + drainTelemetry(); + + evaluate(responseWith("ALLOW", false, unresolvable)); + + assertTrue(requestTags().contains("error:false")); + } + + private static long redactionErrors() { + WafMetricCollector.get().prepareMetrics(); + long count = 0; + for (final MetricCollector.Metric metric : WafMetricCollector.get().drain()) { + if ("error".equals(metric.metricName) && metric.tags.contains("type:redaction_error")) { + count += metric.value.longValue(); + } + } + return count; + } + + private static String errorText(final AIGuardAbortError error) { + return String.valueOf(error.getMessage()) + error + String.valueOf(error.getReason()); + } + + @SuppressWarnings("unchecked") + private List metaStructMessages() { + assertNotNull(metaStruct, "meta struct was never attached to the span"); + return (List) metaStruct.get(META_STRUCT_MESSAGES); + } + + private static String replacements(final String replacement) { + return "[{\"path\":\"messages[1].content\",\"replacement\":\"" + replacement + "\"}]"; + } + + private static String responseWith( + final String action, final boolean blocking, final String redactionReplacements) { + final StringBuilder attributes = new StringBuilder(); + attributes + .append("{\"action\":\"") + .append(action) + .append("\",\"reason\":\"a reason\",\"is_blocking_enabled\":") + .append(blocking) + .append(",\"tags\":[]"); + if (redactionReplacements != null) { + attributes.append(",\"redaction_replacements\":").append(redactionReplacements); + } + attributes.append('}'); + return "{\"data\":{\"id\":\"1\",\"type\":\"evaluations\",\"attributes\":" + attributes + "}}"; + } + + private Evaluation evaluate(final String responseBody) { + final OkHttpClient client = mock(OkHttpClient.class); + final Call call = mock(Call.class); + try { + lenient().when(call.execute()).thenReturn(response(responseBody)); + } catch (final IOException e) { + throw new AssertionError(e); + } + lenient().when(client.newCall(any(Request.class))).thenReturn(call); + return new AIGuardInternal(URL, Collections.emptyMap(), client).evaluate(MESSAGES, new Options().block(true)); + } + + private static Response response(final String json) { + return new Response.Builder() + .protocol(Protocol.HTTP_1_1) + .message("ok") + .request(new Request.Builder().url(URL).build()) + .code(200) + .body(ResponseBody.create(MediaType.parse("application/json"), json)) + .build(); + } } diff --git a/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/MessageRedactorTest.java b/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/MessageRedactorTest.java index 1734cfc4cf6..81a96e04943 100644 --- a/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/MessageRedactorTest.java +++ b/dd-java-agent/agent-aiguard/src/test/java/com/datadog/aiguard/MessageRedactorTest.java @@ -25,612 +25,552 @@ class MessageRedactorTest { - private static final MessageRedactor REDACTOR = new MessageRedactor.DefaultRedactor(); - - private static Map replacement(final Object path, final Object replacement) { - final Map entry = new HashMap<>(2); - entry.put("path", path); - entry.put("replacement", replacement); - return entry; - } - - private static List messages(final Message... messages) { - return new ArrayList<>(asList(messages)); - } - - @Nested - class ContentString { - - @Test - void redactsPlainContent() { - final List messages = - messages( - Message.message("system", "You are a helpful assistant."), - Message.message("user", "My SSN is 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[1].content", "My SSN is "))); - - assertTrue(result.redacted()); - assertEquals(1, result.applied); - assertEquals(0, result.skipped); - assertEquals("My SSN is ", result.messages.get(1).getContent()); - // the untouched message is shared, not rebuilt - assertSame(messages.get(0), result.messages.get(0)); - } - - @Test - void neverMutatesTheCallersMessages() { - final Message original = Message.message("user", "My SSN is 123-45-6789"); - final List messages = messages(original); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[0].content", "My SSN is "))); - - assertNotSame(messages, result.messages); - assertEquals("My SSN is 123-45-6789", original.getContent()); - assertEquals("My SSN is 123-45-6789", messages.get(0).getContent()); - } - - @Test - void preservesRoleAndToolCallId() { - final List messages = messages(Message.tool("call_1", "Account 000123456789")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[0].content", "Account "))); - - final Message redacted = result.messages.get(0); - assertEquals("tool", redacted.getRole()); - assertEquals("call_1", redacted.getToolCallId()); - assertEquals("Account ", redacted.getContent()); - } - - @Test - void appliesEmptyReplacementBecauseItMeansRemoval() { - final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact(messages, singletonList(replacement("messages[0].content", ""))); - - assertTrue(result.redacted()); - assertEquals(0, result.skipped); - assertEquals("", result.messages.get(0).getContent()); - } - - @Test - void copiesReplacementVerbatimIncludingAstralPlaneCharacters() { - final String replacement = "🎭 👨‍👩‍👧‍👦 café"; - final List messages = - messages(Message.message("user", "🎭 123-45-6789 👨‍👩‍👧‍👦 café")); - - final MessageRedactor.Result result = - REDACTOR.redact(messages, singletonList(replacement("messages[0].content", replacement))); - - assertEquals(replacement, result.messages.get(0).getContent()); - } - } - - @Nested - class ContentParts { - - @Test - void redactsTextContentPart() { - final List messages = - messages( - Message.message( - "user", - asList( - ContentPart.text("here is my card 4111111111111111"), - ContentPart.imageUrl("https://example.com/image.jpg")))); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - singletonList( - replacement("messages[0].content[0].text", "here is my card "))); - - assertTrue(result.redacted()); - final List parts = result.messages.get(0).getContentParts(); - assertEquals("here is my card ", parts.get(0).getText()); - // the image part is untouched - assertEquals("https://example.com/image.jpg", parts.get(1).getImageUrl().getUrl()); - } - - @Test - void skipsImageUrlPart() { - final List messages = - messages( - Message.message( - "user", singletonList(ContentPart.imageUrl("https://example.com/image.jpg")))); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[0].content[0].text", ""))); - - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void skipsContentPartPathWhenMessageHoldsAPlainString() { - // messages[0].content[0].text has nothing to resolve against - final List messages = messages(Message.message("user", "plain content")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[0].content[0].text", ""))); + private static final MessageRedactor REDACTOR = new MessageRedactor.DefaultRedactor(); - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void skipsContentPathWhenMessageHoldsContentParts() { - // messages[0].content resolves to a list, not a string - final List messages = - messages(Message.message("user", singletonList(ContentPart.text("secret")))); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[0].content", ""))); - - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - assertSame(messages, result.messages); - } - } - - @Nested - class ToolCallArguments { - - @Test - void redactsToolCallArguments() { - final List messages = - messages( - Message.assistant( - ToolCall.toolCall("call_1", "send_email", "{\"ssn\":\"123-45-6789\"}"))); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - singletonList( - replacement( - "messages[0].tool_calls[0].function.arguments", "{\"ssn\":\"\"}"))); - - assertTrue(result.redacted()); - final ToolCall toolCall = result.messages.get(0).getToolCalls().get(0); - assertEquals("call_1", toolCall.getId()); - assertEquals("send_email", toolCall.getFunction().getName()); - assertEquals("{\"ssn\":\"\"}", toolCall.getFunction().getArguments()); - } - - @Test - void skipsToolCallPathWhenMessageHasNoToolCalls() { - final List messages = messages(Message.message("user", "no tools here")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - singletonList( - replacement("messages[0].tool_calls[0].function.arguments", ""))); - - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void skipsToolCallWhoseFunctionCarriesNoArguments() { - final List messages = - messages( - Message.assistant(new ToolCall("call_1", new ToolCall.Function("send_email", null)))); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - singletonList( - replacement("messages[0].tool_calls[0].function.arguments", ""))); - - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void redactsOnlyTheTargetedToolCall() { - final List messages = - messages( - Message.assistant( - ToolCall.toolCall("call_1", "a", "{\"x\":1}"), - ToolCall.toolCall("call_2", "b", "{\"ssn\":\"123-45-6789\"}"))); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - singletonList( - replacement( - "messages[0].tool_calls[1].function.arguments", "{\"ssn\":\"\"}"))); - - final List toolCalls = result.messages.get(0).getToolCalls(); - assertEquals("{\"x\":1}", toolCalls.get(0).getFunction().getArguments()); - assertEquals("{\"ssn\":\"\"}", toolCalls.get(1).getFunction().getArguments()); - } - - @Test - void preservesContentPartsWhenRedactingToolCallOnTheSameMessage() { - final Message message = - new Message( - "assistant", - singletonList(ContentPart.text("calling a tool")), - singletonList(ToolCall.toolCall("call_1", "send", "{\"ssn\":\"123-45-6789\"}")), - null); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages(message), - singletonList( - replacement( - "messages[0].tool_calls[0].function.arguments", "{\"ssn\":\"\"}"))); - - final Message redacted = result.messages.get(0); - assertNull(redacted.getContent()); - assertEquals("calling a tool", redacted.getContentParts().get(0).getText()); - assertEquals( - "{\"ssn\":\"\"}", redacted.getToolCalls().get(0).getFunction().getArguments()); - } - } - - @Nested - class MultipleReplacements { - - @Test - void appliesReplacementsAcrossSeveralMessages() { - final List messages = - messages( - Message.message("system", "contact ops@acme.io"), - Message.message("user", "My SSN is 123-45-6789"), - Message.message("assistant", "nothing sensitive here")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - asList( - replacement("messages[0].content", "contact "), - replacement("messages[1].content", "My SSN is "))); - - assertEquals(2, result.applied); - assertEquals("contact ", result.messages.get(0).getContent()); - assertEquals("My SSN is ", result.messages.get(1).getContent()); - assertSame(messages.get(2), result.messages.get(2)); - } - - @Test - void composesTwoReplacementsTargetingTheSameMessage() { - final Message message = - new Message( - "assistant", - asList( - ContentPart.text("card 4111111111111111"), ContentPart.text("ssn 123-45-6789")), - singletonList(ToolCall.toolCall("call_1", "send", "{\"email\":\"a@b.io\"}")), - null); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages(message), - asList( - replacement("messages[0].content[0].text", "card "), - replacement("messages[0].content[1].text", "ssn "), - replacement( - "messages[0].tool_calls[0].function.arguments", - "{\"email\":\"\"}"))); - - assertEquals(3, result.applied); - assertEquals(0, result.skipped); - final Message redacted = result.messages.get(0); - assertEquals("card ", redacted.getContentParts().get(0).getText()); - assertEquals("ssn ", redacted.getContentParts().get(1).getText()); - assertEquals( - "{\"email\":\"\"}", - redacted.getToolCalls().get(0).getFunction().getArguments()); - } - - @Test - void appliesSurvivorsWhenOneEntryIsUnresolvable() { - final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - asList( - replacement("messages[0].content", "My SSN is "), - replacement("messages[9].content", "never applied"))); - - assertEquals(1, result.applied); - assertEquals(1, result.skipped); - assertEquals("My SSN is ", result.messages.get(0).getContent()); - } - } + private static Map replacement(final Object path, final Object replacement) { + final Map entry = new HashMap<>(2); + entry.put("path", path); + entry.put("replacement", replacement); + return entry; + } + + private static List messages(final Message... messages) { + return new ArrayList<>(asList(messages)); + } - @Nested - class AppliedReplacements { + @Nested + class ContentString { + + @Test + void redactsPlainContent() { + final List messages = messages( + Message.message("system", "You are a helpful assistant."), + Message.message("user", "My SSN is 123-45-6789")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, singletonList(replacement("messages[1].content", "My SSN is "))); + + assertTrue(result.redacted()); + assertEquals(1, result.applied); + assertEquals(0, result.skipped); + assertEquals("My SSN is ", result.messages.get(1).getContent()); + // the untouched message is shared, not rebuilt + assertSame(messages.get(0), result.messages.get(0)); + } + + @Test + void neverMutatesTheCallersMessages() { + final Message original = Message.message("user", "My SSN is 123-45-6789"); + final List messages = messages(original); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, singletonList(replacement("messages[0].content", "My SSN is "))); + + assertNotSame(messages, result.messages); + assertEquals("My SSN is 123-45-6789", original.getContent()); + assertEquals("My SSN is 123-45-6789", messages.get(0).getContent()); + } - @Test - void reportsOnlyTheEntriesThatWereApplied() { - final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - asList( - replacement("messages[0].content", "My SSN is "), - replacement("messages[9].content", "never applied"))); - - assertEquals(1, result.applied); - assertEquals(1, result.skipped); - } - - @Test - void keepsAnEmptyReplacementWhichMeansRemove() { - final List messages = messages(Message.message("user", "SSN 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact(messages, singletonList(replacement("messages[0].content", ""))); - - assertEquals(1, result.applied); - assertEquals("", result.messages.get(0).getContent()); - } - - @Test - void reportsNothingWhenNothingWasApplied() { - final List messages = messages(Message.message("user", "hello")); - - assertEquals(0, REDACTOR.redact(messages, null).applied); - assertEquals(0, new MessageRedactor.NoOp().redact(messages, null).applied); - } - } - - @Nested - class MalformedResponses { - - @Test - void returnsSameListWhenReplacementsAreNull() { - final List messages = messages(Message.message("user", "hello")); - - final MessageRedactor.Result result = REDACTOR.redact(messages, null); - - assertSame(messages, result.messages); - assertFalse(result.redacted()); - assertEquals(0, result.skipped); - } - - @Test - void returnsSameListWhenReplacementsAreEmpty() { - final List messages = messages(Message.message("user", "hello")); - - final MessageRedactor.Result result = REDACTOR.redact(messages, Collections.emptyList()); - - assertSame(messages, result.messages); - assertFalse(result.redacted()); - } - - @Test - void returnsSameListWhenThereAreNoMessages() { - final MessageRedactor.Result nullMessages = - REDACTOR.redact(null, singletonList(replacement("messages[0].content", ""))); - assertNull(nullMessages.messages); - assertFalse(nullMessages.redacted()); - - final List empty = Collections.emptyList(); - final MessageRedactor.Result noMessages = - REDACTOR.redact(empty, singletonList(replacement("messages[0].content", ""))); - assertSame(empty, noMessages.messages); - assertFalse(noMessages.redacted()); - } - - @Test - void skipsEveryPathTheServiceContradictsItselfOn() { - final List messages = - messages( - Message.message("system", "contact ops@acme.io"), - Message.message("user", "My SSN is 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - asList( - replacement("messages[0].content", "first"), - replacement("messages[0].content", "second"), - replacement("messages[1].content", "third"), - replacement("messages[1].content", "fourth"))); - - assertFalse(result.redacted()); - assertEquals(2, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void skipsConflictingReplacementsForTheSamePath() { - final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - asList( - replacement("messages[0].content", "first"), - replacement("messages[0].content", "second"))); - - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - assertSame(messages, result.messages); - assertEquals("My SSN is 123-45-6789", messages.get(0).getContent()); - } - - @Test - void acceptsDuplicateReplacementsCarryingTheSameValue() { - final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - asList( - replacement("messages[0].content", "My SSN is "), - replacement("messages[0].content", "My SSN is "))); - - assertEquals(1, result.applied); - assertEquals(0, result.skipped); - assertEquals("My SSN is ", result.messages.get(0).getContent()); - } - - @Test - void skipsEntriesThatAreNotObjects() { - final List messages = messages(Message.message("user", "hello")); - - final MessageRedactor.Result result = REDACTOR.redact(messages, asList("not an object", 42)); - - assertFalse(result.redacted()); - assertEquals(2, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void skipsEntriesWithMissingOrNonStringFields() { - final List messages = messages(Message.message("user", "hello")); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - asList( - replacement(null, ""), - replacement("messages[0].content", null), - replacement("", ""), - replacement(42, ""), - replacement("messages[0].content", 42), - new HashMap())); - - assertFalse(result.redacted()); - assertEquals(6, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void neverThrowsOnAToolCallWithoutAFunction() { - final List messages = - messages( - new Message( - "assistant", (String) null, singletonList(new ToolCall("call_1", null)), null)); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, - singletonList( - replacement("messages[0].tool_calls[0].function.arguments", ""))); - - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - } - - @Test - void skipsContentPathOnAMessageWithoutContent() { - final List messages = - messages(Message.assistant(ToolCall.toolCall("call_1", "send", "{}"))); - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[0].content", ""))); - - assertFalse(result.redacted()); - assertEquals(1, result.skipped); - } - } - - @Nested - class PathGrammar { - - @ParameterizedTest - @ValueSource( - strings = { - "", // empty path - "content", // not rooted at messages - "messages", // resolves to the list itself - "messages.content", // missing root index - "messages[0]", // resolves to a message, not a string - "messages[0].foo", // unknown field - "messages[0].content.text", // missing part index - "messages[0].content[0]", // resolves to a content part - "messages[0].content[0].foo", // unknown content part field - "messages[0].content[0].text.extra", // too deep - "messages[0].content[1].image_url.url", // image locator, out of scope - "messages[0].tool_calls.function.arguments", // missing tool call index - "messages[0].tool_calls[0].function", // resolves to a function - "messages[0].tool_calls[0].function.name", // not a redactable target - "messages[0].tool_calls[0].arguments", // missing function hop - "messages[-1].content", // negative index rejected by the segment regex - "messages[0].content ", // trailing space, whole segment must match - " messages[0].content", // leading space - "messages[0]..content", // empty segment - "messages[0].content[", // unbalanced bracket - "messages[0].con-tent", // hyphen is not a word character - "messages[99999999999].content", // index overflows - "messages[0].tool_calls[0].function.arguments.extra", // deeper than any target - "conversation[0].content", // rooted at something other than messages - "messages[0].content[0].text[0]", // an index on the text field itself - "messages[0].content[9].text", // content part index out of range - "messages[0].tool_calls[0].foo.arguments", // the hop is not `function` - "messages[0].tool_calls[0].function[0].arguments", // an index on `function` - "messages[0].tool_calls[0].function.arguments[0]", // an index on `arguments` - "messages[0].tool_calls[9].function.arguments" // tool call index out of range - }) - void skipsUnsupportedPaths(final String path) { - final List messages = - messages( - new Message( - "assistant", - asList( - ContentPart.text("text"), ContentPart.imageUrl("https://example.com/i.jpg")), - singletonList(ToolCall.toolCall("call_1", "send", "{}")), - null)); - - final MessageRedactor.Result result = - REDACTOR.redact(messages, singletonList(replacement(path, ""))); - - assertFalse(result.redacted(), "expected path to be skipped: " + path); - assertEquals(1, result.skipped); - assertSame(messages, result.messages); - } - - @Test - void acceptsMultiDigitIndexes() { - final List messages = new ArrayList<>(); - for (int i = 0; i < 12; i++) { - messages.add(Message.message("user", "message " + i)); - } - - final MessageRedactor.Result result = - REDACTOR.redact( - messages, singletonList(replacement("messages[11].content", ""))); - - assertTrue(result.redacted()); - assertEquals("", result.messages.get(11).getContent()); - } + @Test + void preservesRoleAndToolCallId() { + final List messages = messages(Message.tool("call_1", "Account 000123456789")); - @Test - void toleratesUnknownKeysOnAReplacementEntry() { - final Map entry = new LinkedHashMap<>(); - entry.put("path", "messages[0].content"); - entry.put("replacement", ""); - entry.put("category", "ssn"); + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content", "Account "))); - final MessageRedactor.Result result = - REDACTOR.redact( - messages(Message.message("user", "My SSN is 123-45-6789")), singletonList(entry)); + final Message redacted = result.messages.get(0); + assertEquals("tool", redacted.getRole()); + assertEquals("call_1", redacted.getToolCallId()); + assertEquals("Account ", redacted.getContent()); + } - assertTrue(result.redacted()); - assertEquals("", result.messages.get(0).getContent()); + @Test + void appliesEmptyReplacementBecauseItMeansRemoval() { + final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content", ""))); + + assertTrue(result.redacted()); + assertEquals(0, result.skipped); + assertEquals("", result.messages.get(0).getContent()); + } + + @Test + void copiesReplacementVerbatimIncludingAstralPlaneCharacters() { + final String replacement = "🎭 👨‍👩‍👧‍👦 café"; + final List messages = messages(Message.message("user", "🎭 123-45-6789 👨‍👩‍👧‍👦 café")); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content", replacement))); + + assertEquals(replacement, result.messages.get(0).getContent()); + } + } + + @Nested + class ContentParts { + + @Test + void redactsTextContentPart() { + final List messages = messages(Message.message( + "user", + asList( + ContentPart.text("here is my card 4111111111111111"), + ContentPart.imageUrl("https://example.com/image.jpg")))); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, singletonList(replacement("messages[0].content[0].text", "here is my card "))); + + assertTrue(result.redacted()); + final List parts = result.messages.get(0).getContentParts(); + assertEquals("here is my card ", parts.get(0).getText()); + // the image part is untouched + assertEquals( + "https://example.com/image.jpg", parts.get(1).getImageUrl().getUrl()); + } + + @Test + void skipsImageUrlPart() { + final List messages = messages( + Message.message("user", singletonList(ContentPart.imageUrl("https://example.com/image.jpg")))); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content[0].text", ""))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void skipsContentPartPathWhenMessageHoldsAPlainString() { + // messages[0].content[0].text has nothing to resolve against + final List messages = messages(Message.message("user", "plain content")); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content[0].text", ""))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void skipsContentPathWhenMessageHoldsContentParts() { + // messages[0].content resolves to a list, not a string + final List messages = messages(Message.message("user", singletonList(ContentPart.text("secret")))); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content", ""))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + assertSame(messages, result.messages); + } + } + + @Nested + class ToolCallArguments { + + @Test + void redactsToolCallArguments() { + final List messages = + messages(Message.assistant(ToolCall.toolCall("call_1", "send_email", "{\"ssn\":\"123-45-6789\"}"))); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + singletonList( + replacement("messages[0].tool_calls[0].function.arguments", "{\"ssn\":\"\"}"))); + + assertTrue(result.redacted()); + final ToolCall toolCall = result.messages.get(0).getToolCalls().get(0); + assertEquals("call_1", toolCall.getId()); + assertEquals("send_email", toolCall.getFunction().getName()); + assertEquals("{\"ssn\":\"\"}", toolCall.getFunction().getArguments()); + } + + @Test + void skipsToolCallPathWhenMessageHasNoToolCalls() { + final List messages = messages(Message.message("user", "no tools here")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, singletonList(replacement("messages[0].tool_calls[0].function.arguments", ""))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void skipsToolCallWhoseFunctionCarriesNoArguments() { + final List messages = + messages(Message.assistant(new ToolCall("call_1", new ToolCall.Function("send_email", null)))); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, singletonList(replacement("messages[0].tool_calls[0].function.arguments", ""))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void redactsOnlyTheTargetedToolCall() { + final List messages = messages(Message.assistant( + ToolCall.toolCall("call_1", "a", "{\"x\":1}"), + ToolCall.toolCall("call_2", "b", "{\"ssn\":\"123-45-6789\"}"))); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + singletonList( + replacement("messages[0].tool_calls[1].function.arguments", "{\"ssn\":\"\"}"))); + + final List toolCalls = result.messages.get(0).getToolCalls(); + assertEquals("{\"x\":1}", toolCalls.get(0).getFunction().getArguments()); + assertEquals( + "{\"ssn\":\"\"}", toolCalls.get(1).getFunction().getArguments()); + } + + @Test + void preservesContentPartsWhenRedactingToolCallOnTheSameMessage() { + final Message message = new Message( + "assistant", + singletonList(ContentPart.text("calling a tool")), + singletonList(ToolCall.toolCall("call_1", "send", "{\"ssn\":\"123-45-6789\"}")), + null); + + final MessageRedactor.Result result = REDACTOR.redact( + messages(message), + singletonList( + replacement("messages[0].tool_calls[0].function.arguments", "{\"ssn\":\"\"}"))); + + final Message redacted = result.messages.get(0); + assertNull(redacted.getContent()); + assertEquals("calling a tool", redacted.getContentParts().get(0).getText()); + assertEquals( + "{\"ssn\":\"\"}", + redacted.getToolCalls().get(0).getFunction().getArguments()); + } + } + + @Nested + class MultipleReplacements { + + @Test + void appliesReplacementsAcrossSeveralMessages() { + final List messages = messages( + Message.message("system", "contact ops@acme.io"), + Message.message("user", "My SSN is 123-45-6789"), + Message.message("assistant", "nothing sensitive here")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + asList( + replacement("messages[0].content", "contact "), + replacement("messages[1].content", "My SSN is "))); + + assertEquals(2, result.applied); + assertEquals("contact ", result.messages.get(0).getContent()); + assertEquals("My SSN is ", result.messages.get(1).getContent()); + assertSame(messages.get(2), result.messages.get(2)); + } + + @Test + void composesTwoReplacementsTargetingTheSameMessage() { + final Message message = new Message( + "assistant", + asList(ContentPart.text("card 4111111111111111"), ContentPart.text("ssn 123-45-6789")), + singletonList(ToolCall.toolCall("call_1", "send", "{\"email\":\"a@b.io\"}")), + null); + + final MessageRedactor.Result result = REDACTOR.redact( + messages(message), + asList( + replacement("messages[0].content[0].text", "card "), + replacement("messages[0].content[1].text", "ssn "), + replacement("messages[0].tool_calls[0].function.arguments", "{\"email\":\"\"}"))); + + assertEquals(3, result.applied); + assertEquals(0, result.skipped); + final Message redacted = result.messages.get(0); + assertEquals("card ", redacted.getContentParts().get(0).getText()); + assertEquals("ssn ", redacted.getContentParts().get(1).getText()); + assertEquals( + "{\"email\":\"\"}", + redacted.getToolCalls().get(0).getFunction().getArguments()); + } + + @Test + void appliesSurvivorsWhenOneEntryIsUnresolvable() { + final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + asList( + replacement("messages[0].content", "My SSN is "), + replacement("messages[9].content", "never applied"))); + + assertEquals(1, result.applied); + assertEquals(1, result.skipped); + assertEquals("My SSN is ", result.messages.get(0).getContent()); + } + } + + @Nested + class AppliedReplacements { + + @Test + void reportsOnlyTheEntriesThatWereApplied() { + final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + asList( + replacement("messages[0].content", "My SSN is "), + replacement("messages[9].content", "never applied"))); + + assertEquals(1, result.applied); + assertEquals(1, result.skipped); + } + + @Test + void keepsAnEmptyReplacementWhichMeansRemove() { + final List messages = messages(Message.message("user", "SSN 123-45-6789")); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content", ""))); + + assertEquals(1, result.applied); + assertEquals("", result.messages.get(0).getContent()); + } + + @Test + void reportsNothingWhenNothingWasApplied() { + final List messages = messages(Message.message("user", "hello")); + + assertEquals(0, REDACTOR.redact(messages, null).applied); + assertEquals(0, new MessageRedactor.NoOp().redact(messages, null).applied); + } + } + + @Nested + class MalformedResponses { + + @Test + void returnsSameListWhenReplacementsAreNull() { + final List messages = messages(Message.message("user", "hello")); + + final MessageRedactor.Result result = REDACTOR.redact(messages, null); + + assertSame(messages, result.messages); + assertFalse(result.redacted()); + assertEquals(0, result.skipped); + } + + @Test + void returnsSameListWhenReplacementsAreEmpty() { + final List messages = messages(Message.message("user", "hello")); + + final MessageRedactor.Result result = REDACTOR.redact(messages, Collections.emptyList()); + + assertSame(messages, result.messages); + assertFalse(result.redacted()); + } + + @Test + void returnsSameListWhenThereAreNoMessages() { + final MessageRedactor.Result nullMessages = + REDACTOR.redact(null, singletonList(replacement("messages[0].content", ""))); + assertNull(nullMessages.messages); + assertFalse(nullMessages.redacted()); + + final List empty = Collections.emptyList(); + final MessageRedactor.Result noMessages = + REDACTOR.redact(empty, singletonList(replacement("messages[0].content", ""))); + assertSame(empty, noMessages.messages); + assertFalse(noMessages.redacted()); + } + + @Test + void skipsEveryPathTheServiceContradictsItselfOn() { + final List messages = messages( + Message.message("system", "contact ops@acme.io"), Message.message("user", "My SSN is 123-45-6789")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + asList( + replacement("messages[0].content", "first"), + replacement("messages[0].content", "second"), + replacement("messages[1].content", "third"), + replacement("messages[1].content", "fourth"))); + + assertFalse(result.redacted()); + assertEquals(2, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void skipsConflictingReplacementsForTheSamePath() { + final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + asList(replacement("messages[0].content", "first"), replacement("messages[0].content", "second"))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + assertSame(messages, result.messages); + assertEquals("My SSN is 123-45-6789", messages.get(0).getContent()); + } + + @Test + void acceptsDuplicateReplacementsCarryingTheSameValue() { + final List messages = messages(Message.message("user", "My SSN is 123-45-6789")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + asList( + replacement("messages[0].content", "My SSN is "), + replacement("messages[0].content", "My SSN is "))); + + assertEquals(1, result.applied); + assertEquals(0, result.skipped); + assertEquals("My SSN is ", result.messages.get(0).getContent()); + } + + @Test + void skipsEntriesThatAreNotObjects() { + final List messages = messages(Message.message("user", "hello")); + + final MessageRedactor.Result result = REDACTOR.redact(messages, asList("not an object", 42)); + + assertFalse(result.redacted()); + assertEquals(2, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void skipsEntriesWithMissingOrNonStringFields() { + final List messages = messages(Message.message("user", "hello")); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, + asList( + replacement(null, ""), + replacement("messages[0].content", null), + replacement("", ""), + replacement(42, ""), + replacement("messages[0].content", 42), + new HashMap())); + + assertFalse(result.redacted()); + assertEquals(6, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void neverThrowsOnAToolCallWithoutAFunction() { + final List messages = messages( + new Message("assistant", (String) null, singletonList(new ToolCall("call_1", null)), null)); + + final MessageRedactor.Result result = REDACTOR.redact( + messages, singletonList(replacement("messages[0].tool_calls[0].function.arguments", ""))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + } + + @Test + void skipsContentPathOnAMessageWithoutContent() { + final List messages = messages(Message.assistant(ToolCall.toolCall("call_1", "send", "{}"))); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[0].content", ""))); + + assertFalse(result.redacted()); + assertEquals(1, result.skipped); + } + } + + @Nested + class PathGrammar { + + @ParameterizedTest + @ValueSource( + strings = { + "", // empty path + "content", // not rooted at messages + "messages", // resolves to the list itself + "messages.content", // missing root index + "messages[0]", // resolves to a message, not a string + "messages[0].foo", // unknown field + "messages[0].content.text", // missing part index + "messages[0].content[0]", // resolves to a content part + "messages[0].content[0].foo", // unknown content part field + "messages[0].content[0].text.extra", // too deep + "messages[0].content[1].image_url.url", // image locator, out of scope + "messages[0].tool_calls.function.arguments", // missing tool call index + "messages[0].tool_calls[0].function", // resolves to a function + "messages[0].tool_calls[0].function.name", // not a redactable target + "messages[0].tool_calls[0].arguments", // missing function hop + "messages[-1].content", // negative index rejected by the segment regex + "messages[0].content ", // trailing space, whole segment must match + " messages[0].content", // leading space + "messages[0]..content", // empty segment + "messages[0].content[", // unbalanced bracket + "messages[0].con-tent", // hyphen is not a word character + "messages[99999999999].content", // index overflows + "messages[0].tool_calls[0].function.arguments.extra", // deeper than any target + "conversation[0].content", // rooted at something other than messages + "messages[0].content[0].text[0]", // an index on the text field itself + "messages[0].content[9].text", // content part index out of range + "messages[0].tool_calls[0].foo.arguments", // the hop is not `function` + "messages[0].tool_calls[0].function[0].arguments", // an index on `function` + "messages[0].tool_calls[0].function.arguments[0]", // an index on `arguments` + "messages[0].tool_calls[9].function.arguments" // tool call index out of range + }) + void skipsUnsupportedPaths(final String path) { + final List messages = messages(new Message( + "assistant", + asList(ContentPart.text("text"), ContentPart.imageUrl("https://example.com/i.jpg")), + singletonList(ToolCall.toolCall("call_1", "send", "{}")), + null)); + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement(path, ""))); + + assertFalse(result.redacted(), "expected path to be skipped: " + path); + assertEquals(1, result.skipped); + assertSame(messages, result.messages); + } + + @Test + void acceptsMultiDigitIndexes() { + final List messages = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + messages.add(Message.message("user", "message " + i)); + } + + final MessageRedactor.Result result = + REDACTOR.redact(messages, singletonList(replacement("messages[11].content", ""))); + + assertTrue(result.redacted()); + assertEquals("", result.messages.get(11).getContent()); + } + + @Test + void toleratesUnknownKeysOnAReplacementEntry() { + final Map entry = new LinkedHashMap<>(); + entry.put("path", "messages[0].content"); + entry.put("replacement", ""); + entry.put("category", "ssn"); + + final MessageRedactor.Result result = + REDACTOR.redact(messages(Message.message("user", "My SSN is 123-45-6789")), singletonList(entry)); + + assertTrue(result.redacted()); + assertEquals("", result.messages.get(0).getContent()); + } } - } } diff --git a/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/ExceptionLoggerBenchmark.java b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/ExceptionLoggerBenchmark.java index 807ac7c196c..c525a7fc3ca 100644 --- a/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/ExceptionLoggerBenchmark.java +++ b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/ExceptionLoggerBenchmark.java @@ -19,10 +19,10 @@ @Measurement(iterations = 5) @Threads(8) public class ExceptionLoggerBenchmark { - @Benchmark - public Logger getExceptionLogger() { - // This matches what happens in the bytecode weaving that defends against - // exception leaking out of instrumentation. - return LoggerFactory.getLogger(ExceptionLogger.class); - } + @Benchmark + public Logger getExceptionLogger() { + // This matches what happens in the bytecode weaving that defends against + // exception leaking out of instrumentation. + return LoggerFactory.getLogger(ExceptionLogger.class); + } } diff --git a/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java index 8f52f13ee86..e5f194f6f6a 100644 --- a/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java +++ b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java @@ -33,33 +33,32 @@ @OutputTimeUnit(MICROSECONDS) @Fork(value = 1) public class InjectingPipeOutputStreamBenchmark { - private static final List htmlContent; - private static final byte[] marker; - private static final byte[] content; + private static final List htmlContent; + private static final byte[] marker; + private static final byte[] content; - static { - try (InputStream is = new URL("https://www.google.com").openStream()) { - htmlContent = IOUtils.readLines(is, StandardCharsets.UTF_8); - } catch (IOException ioe) { - throw new RuntimeException(ioe); + static { + try (InputStream is = new URL("https://www.google.com").openStream()) { + htmlContent = IOUtils.readLines(is, StandardCharsets.UTF_8); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + marker = "".getBytes(StandardCharsets.UTF_8); + content = "\n"; } - return false; - } - - public String getSnippet() { - return "\n"; - } - - private String getCdnUrl() { - if (!GOV_CLOUD_SITE.equals(this.site)) { - return "https://www.datadoghq-browser-agent.com/datadog-rum-v" + this.majorVersion + ".js"; + + private String getCdnUrl() { + if (!GOV_CLOUD_SITE.equals(this.site)) { + return "https://www.datadoghq-browser-agent.com/datadog-rum-v" + this.majorVersion + ".js"; + } + return "https://www.datadoghq-browser-agent.com/" + + REGIONS.get(this.site) + + "/v" + + this.majorVersion + + "/datadog-rum.js"; } - return "https://www.datadoghq-browser-agent.com/" - + REGIONS.get(this.site) - + "/v" - + this.majorVersion - + "/datadog-rum.js"; - } - - public String jsonPayload() { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - writer.name("applicationId").value(this.applicationId); - writer.name("clientToken").value(this.clientToken); - if (this.site != null) { - writer.name("site").value(this.site); - } - if (this.service != null) { - writer.name("service").value(this.service); - } - if (this.env != null) { - writer.name("env").value(this.env); - } - if (this.version != null) { - writer.name("version").value(this.version); - } - if (this.trackUserInteractions != null) { - writer.name("trackUserInteractions").value(this.trackUserInteractions); - } - if (this.trackResources != null) { - writer.name("trackResources").value(this.trackResources); - } - if (this.trackLongTask != null) { - writer.name("trackLongTask").value(this.trackLongTask); - } - if (this.defaultPrivacyLevel != null) { - writer.name("defaultPrivacyLevel").value(this.defaultPrivacyLevel.toJson()); - } - if (this.sessionSampleRate != null) { - writer.name("sessionSampleRate").value(this.sessionSampleRate); - } - if (this.sessionReplaySampleRate != null) { - writer.name("sessionReplaySampleRate").value(this.sessionReplaySampleRate); - } - if (this.remoteConfigurationId != null) { - writer.name("remoteConfigurationId").value(this.remoteConfigurationId); - } - writer.endObject(); - return writer.toString(); - } catch (Exception e) { - throw new IllegalStateException("Fail to generate config payload", e); + + public String jsonPayload() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.name("applicationId").value(this.applicationId); + writer.name("clientToken").value(this.clientToken); + if (this.site != null) { + writer.name("site").value(this.site); + } + if (this.service != null) { + writer.name("service").value(this.service); + } + if (this.env != null) { + writer.name("env").value(this.env); + } + if (this.version != null) { + writer.name("version").value(this.version); + } + if (this.trackUserInteractions != null) { + writer.name("trackUserInteractions").value(this.trackUserInteractions); + } + if (this.trackResources != null) { + writer.name("trackResources").value(this.trackResources); + } + if (this.trackLongTask != null) { + writer.name("trackLongTask").value(this.trackLongTask); + } + if (this.defaultPrivacyLevel != null) { + writer.name("defaultPrivacyLevel").value(this.defaultPrivacyLevel.toJson()); + } + if (this.sessionSampleRate != null) { + writer.name("sessionSampleRate").value(this.sessionSampleRate); + } + if (this.sessionReplaySampleRate != null) { + writer.name("sessionReplaySampleRate").value(this.sessionReplaySampleRate); + } + if (this.remoteConfigurationId != null) { + writer.name("remoteConfigurationId").value(this.remoteConfigurationId); + } + writer.endObject(); + return writer.toString(); + } catch (Exception e) { + throw new IllegalStateException("Fail to generate config payload", e); + } } - } - public enum PrivacyLevel { - ALLOW("allow"), - MASK("mask"), - MASK_USER_INPUT("mask-user-input"); + public enum PrivacyLevel { + ALLOW("allow"), + MASK("mask"), + MASK_USER_INPUT("mask-user-input"); - private final String json; + private final String json; - PrivacyLevel(String json) { - this.json = json; - } + PrivacyLevel(String json) { + this.json = json; + } - public String toJson() { - return this.json; + public String toJson() { + return this.json; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorMetrics.java b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorMetrics.java index 54ddd499e82..c35380a403b 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorMetrics.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorMetrics.java @@ -22,202 +22,177 @@ */ public class RumInjectorMetrics implements RumTelemetryCollector { - private final Queue metrics = new LinkedBlockingQueue<>(1024); - private final Queue distributions = - new LinkedBlockingQueue<>(1024); - - private final DDCache> succeedTagsCache = DDCaches.newFixedSizeCache(8); - private final DDCache> skippedTagsCache = DDCaches.newFixedSizeCache(8); - private final DDCache> cspTagsCache = DDCaches.newFixedSizeCache(8); - private final DDCache> responseTagsCache = DDCaches.newFixedSizeCache(8); - private final DDCache> timeTagsCache = DDCaches.newFixedSizeCache(8); - private final DDCache> failedTagsCache = DDCaches.newFixedSizeCache(16); - private final DDCache> initTagsCache = DDCaches.newFixedSizeCache(1); - - private final String applicationId; - private final String remoteConfigUsed; - - public RumInjectorMetrics() { - - // Get RUM config values (applicationId and remoteConfigUsed) for tagging - RumInjector rumInjector = RumInjector.get(); - RumInjectorConfig injectorConfig = Config.get().getRumInjectorConfig(); - if (rumInjector.isEnabled() && injectorConfig != null) { - this.applicationId = injectorConfig.applicationId; - this.remoteConfigUsed = injectorConfig.remoteConfigurationId != null ? "true" : "false"; - } else { - this.applicationId = "unknown"; - this.remoteConfigUsed = "false"; + private final Queue metrics = new LinkedBlockingQueue<>(1024); + private final Queue distributions = new LinkedBlockingQueue<>(1024); + + private final DDCache> succeedTagsCache = DDCaches.newFixedSizeCache(8); + private final DDCache> skippedTagsCache = DDCaches.newFixedSizeCache(8); + private final DDCache> cspTagsCache = DDCaches.newFixedSizeCache(8); + private final DDCache> responseTagsCache = DDCaches.newFixedSizeCache(8); + private final DDCache> timeTagsCache = DDCaches.newFixedSizeCache(8); + private final DDCache> failedTagsCache = DDCaches.newFixedSizeCache(16); + private final DDCache> initTagsCache = DDCaches.newFixedSizeCache(1); + + private final String applicationId; + private final String remoteConfigUsed; + + public RumInjectorMetrics() { + + // Get RUM config values (applicationId and remoteConfigUsed) for tagging + RumInjector rumInjector = RumInjector.get(); + RumInjectorConfig injectorConfig = Config.get().getRumInjectorConfig(); + if (rumInjector.isEnabled() && injectorConfig != null) { + this.applicationId = injectorConfig.applicationId; + this.remoteConfigUsed = injectorConfig.remoteConfigurationId != null ? "true" : "false"; + } else { + this.applicationId = "unknown"; + this.remoteConfigUsed = "false"; + } } - } - - @Override - public void onInjectionSucceed(String servletVersion) { - List tags = - succeedTagsCache.computeIfAbsent( - servletVersion, - version -> - Arrays.asList( - "application_id:" + applicationId, - "integration_name:servlet", - "integration_version:" + version, - "remote_config_used:" + remoteConfigUsed)); - - MetricCollector.Metric metric = - new MetricCollector.Metric("rum", true, "injection.succeed", "count", 1, tags); - metrics.offer(metric); - } - - @Override - public void onInjectionFailed(String servletVersion, String contentEncoding) { - String cacheKey = servletVersion + ":" + contentEncoding; - List tags = - failedTagsCache.computeIfAbsent( - cacheKey, - key -> { - if (contentEncoding != null) { + + @Override + public void onInjectionSucceed(String servletVersion) { + List tags = succeedTagsCache.computeIfAbsent( + servletVersion, + version -> Arrays.asList( + "application_id:" + applicationId, + "integration_name:servlet", + "integration_version:" + version, + "remote_config_used:" + remoteConfigUsed)); + + MetricCollector.Metric metric = new MetricCollector.Metric("rum", true, "injection.succeed", "count", 1, tags); + metrics.offer(metric); + } + + @Override + public void onInjectionFailed(String servletVersion, String contentEncoding) { + String cacheKey = servletVersion + ":" + contentEncoding; + List tags = failedTagsCache.computeIfAbsent(cacheKey, key -> { + if (contentEncoding != null) { return Arrays.asList( - "application_id:" + applicationId, - "content_encoding:" + contentEncoding, - "integration_name:servlet", - "integration_version:" + servletVersion, - "reason:failed_to_return_response_wrapper", - "remote_config_used:" + remoteConfigUsed); - } else { + "application_id:" + applicationId, + "content_encoding:" + contentEncoding, + "integration_name:servlet", + "integration_version:" + servletVersion, + "reason:failed_to_return_response_wrapper", + "remote_config_used:" + remoteConfigUsed); + } else { return Arrays.asList( - "application_id:" + applicationId, - "integration_name:servlet", - "integration_version:" + servletVersion, - "reason:failed_to_return_response_wrapper", - "remote_config_used:" + remoteConfigUsed); - } - }); - - MetricCollector.Metric metric = - new MetricCollector.Metric("rum", true, "injection.failed", "count", 1, tags); - metrics.offer(metric); - } - - @Override - public void onInjectionSkipped(String servletVersion) { - List tags = - skippedTagsCache.computeIfAbsent( - servletVersion, - version -> - Arrays.asList( - "application_id:" + applicationId, - "integration_name:servlet", - "integration_version:" + version, - "reason:should_not_inject", - "remote_config_used:" + remoteConfigUsed)); - - MetricCollector.Metric metric = - new MetricCollector.Metric("rum", true, "injection.skipped", "count", 1, tags); - metrics.offer(metric); - } - - @Override - public void onInitializationSucceed() { - List tags = - initTagsCache.computeIfAbsent( - "init", key -> Arrays.asList("integration_name:servlet", "integration_version:N/A")); - - MetricCollector.Metric metric = - new MetricCollector.Metric( - "rum", true, "injection.initialization.succeed", "count", 1, tags); - metrics.offer(metric); - } - - @Override - public void onContentSecurityPolicyDetected(String servletVersion) { - List tags = - cspTagsCache.computeIfAbsent( - servletVersion, - version -> - Arrays.asList( - "integration_name:servlet", - "integration_version:" + version, - "kind:header", - "reason:csp_header_found", - "status:seen")); - - MetricCollector.Metric metric = - new MetricCollector.Metric( - "rum", true, "injection.content_security_policy", "count", 1, tags); - metrics.offer(metric); - } - - @Override - public void onInjectionResponseSize(String servletVersion, long bytes) { - List tags = - responseTagsCache.computeIfAbsent( - servletVersion, - version -> - Arrays.asList( - "integration_name:servlet", - "integration_version:" + version, - "response_kind:header")); - - MetricCollector.DistributionSeriesPoint distribution = - new MetricCollector.DistributionSeriesPoint( - "injection.response.bytes", true, "rum", (int) bytes, tags); - distributions.offer(distribution); - } - - @Override - public void onInjectionTime(String servletVersion, long milliseconds) { - List tags = - timeTagsCache.computeIfAbsent( - servletVersion, - version -> Arrays.asList("integration_name:servlet", "integration_version:" + version)); - - MetricCollector.DistributionSeriesPoint distribution = - new MetricCollector.DistributionSeriesPoint( - "injection.ms", true, "rum", (int) milliseconds, tags); - distributions.offer(distribution); - } - - @Override - public void close() { - metrics.clear(); - distributions.clear(); - succeedTagsCache.clear(); - skippedTagsCache.clear(); - cspTagsCache.clear(); - responseTagsCache.clear(); - timeTagsCache.clear(); - failedTagsCache.clear(); - initTagsCache.clear(); - } - - /** - * Drains all count metrics. - * - * @return Collection of metrics sent via telemetry - */ - public synchronized Collection drain() { - if (metrics.isEmpty()) { - return Collections.emptyList(); + "application_id:" + applicationId, + "integration_name:servlet", + "integration_version:" + servletVersion, + "reason:failed_to_return_response_wrapper", + "remote_config_used:" + remoteConfigUsed); + } + }); + + MetricCollector.Metric metric = new MetricCollector.Metric("rum", true, "injection.failed", "count", 1, tags); + metrics.offer(metric); + } + + @Override + public void onInjectionSkipped(String servletVersion) { + List tags = skippedTagsCache.computeIfAbsent( + servletVersion, + version -> Arrays.asList( + "application_id:" + applicationId, + "integration_name:servlet", + "integration_version:" + version, + "reason:should_not_inject", + "remote_config_used:" + remoteConfigUsed)); + + MetricCollector.Metric metric = new MetricCollector.Metric("rum", true, "injection.skipped", "count", 1, tags); + metrics.offer(metric); } - List drained = new ArrayList<>(metrics); - metrics.clear(); - return drained; - } - - /** - * Drains all distribution metrics. - * - * @return Collection of distribution points sent via telemetry - */ - public synchronized Collection - drainDistributionSeries() { - if (distributions.isEmpty()) { - return Collections.emptyList(); + @Override + public void onInitializationSucceed() { + List tags = initTagsCache.computeIfAbsent( + "init", key -> Arrays.asList("integration_name:servlet", "integration_version:N/A")); + + MetricCollector.Metric metric = + new MetricCollector.Metric("rum", true, "injection.initialization.succeed", "count", 1, tags); + metrics.offer(metric); + } + + @Override + public void onContentSecurityPolicyDetected(String servletVersion) { + List tags = cspTagsCache.computeIfAbsent( + servletVersion, + version -> Arrays.asList( + "integration_name:servlet", + "integration_version:" + version, + "kind:header", + "reason:csp_header_found", + "status:seen")); + + MetricCollector.Metric metric = + new MetricCollector.Metric("rum", true, "injection.content_security_policy", "count", 1, tags); + metrics.offer(metric); } - List drained = new ArrayList<>(distributions); - distributions.clear(); - return drained; - } + @Override + public void onInjectionResponseSize(String servletVersion, long bytes) { + List tags = responseTagsCache.computeIfAbsent( + servletVersion, + version -> Arrays.asList( + "integration_name:servlet", "integration_version:" + version, "response_kind:header")); + + MetricCollector.DistributionSeriesPoint distribution = + new MetricCollector.DistributionSeriesPoint("injection.response.bytes", true, "rum", (int) bytes, tags); + distributions.offer(distribution); + } + + @Override + public void onInjectionTime(String servletVersion, long milliseconds) { + List tags = timeTagsCache.computeIfAbsent( + servletVersion, version -> Arrays.asList("integration_name:servlet", "integration_version:" + version)); + + MetricCollector.DistributionSeriesPoint distribution = + new MetricCollector.DistributionSeriesPoint("injection.ms", true, "rum", (int) milliseconds, tags); + distributions.offer(distribution); + } + + @Override + public void close() { + metrics.clear(); + distributions.clear(); + succeedTagsCache.clear(); + skippedTagsCache.clear(); + cspTagsCache.clear(); + responseTagsCache.clear(); + timeTagsCache.clear(); + failedTagsCache.clear(); + initTagsCache.clear(); + } + + /** + * Drains all count metrics. + * + * @return Collection of metrics sent via telemetry + */ + public synchronized Collection drain() { + if (metrics.isEmpty()) { + return Collections.emptyList(); + } + + List drained = new ArrayList<>(metrics); + metrics.clear(); + return drained; + } + + /** + * Drains all distribution metrics. + * + * @return Collection of distribution points sent via telemetry + */ + public synchronized Collection drainDistributionSeries() { + if (distributions.isEmpty()) { + return Collections.emptyList(); + } + + List drained = new ArrayList<>(distributions); + distributions.clear(); + return drained; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumTelemetryCollector.java b/internal-api/src/main/java/datadog/trace/api/rum/RumTelemetryCollector.java index 21e19a4cd82..a256133cad6 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumTelemetryCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumTelemetryCollector.java @@ -10,8 +10,7 @@ */ public interface RumTelemetryCollector { - RumTelemetryCollector NO_OP = - new RumTelemetryCollector() { + RumTelemetryCollector NO_OP = new RumTelemetryCollector() { @Override public void onInjectionSucceed(String integrationVersion) {} @@ -38,77 +37,77 @@ public void close() {} @Override public Collection drain() { - return Collections.emptyList(); + return Collections.emptyList(); } @Override public Collection drainDistributionSeries() { - return Collections.emptyList(); + return Collections.emptyList(); } - }; - - /** - * Reports successful RUM injection. - * - * @param integrationVersion The version of the integration that was injected. - */ - void onInjectionSucceed(String integrationVersion); - - /** - * Reports failed RUM injection. - * - * @param integrationVersion The version of the integration that was injected. - * @param contentEncoding The content encoding of the response that was injected. - */ - void onInjectionFailed(String integrationVersion, String contentEncoding); - - /** - * Reports skipped RUM injection. - * - * @param integrationVersion The version of the integration that was injected. - */ - void onInjectionSkipped(String integrationVersion); - - /** Reports successful RUM injector initialization. */ - void onInitializationSucceed(); - - /** - * Reports content security policy detected in the response header to be injected. - * - * @param integrationVersion The version of the integration that was injected. - */ - void onContentSecurityPolicyDetected(String integrationVersion); - - /** - * Reports the size of the response before injection. - * - * @param integrationVersion The version of the integration that was injected. - * @param bytes The size of the response before injection. - */ - void onInjectionResponseSize(String integrationVersion, long bytes); - - /** - * Reports the time taken to inject the RUM SDK. - * - * @param integrationVersion The version of the integration that was injected. - * @param milliseconds The time taken to inject the RUM SDK. - */ - void onInjectionTime(String integrationVersion, long milliseconds); - - /** Closes the telemetry collector. */ - default void close() {} - - /** - * Drains all count metrics to be sent via telemetry. - * - * @return Collection of count metrics to be sent via telemetry. - */ - Collection drain(); - - /** - * Drains all distribution metrics to be sent via telemetry. - * - * @return Collection of distribution points to be sent via telemetry. - */ - Collection drainDistributionSeries(); + }; + + /** + * Reports successful RUM injection. + * + * @param integrationVersion The version of the integration that was injected. + */ + void onInjectionSucceed(String integrationVersion); + + /** + * Reports failed RUM injection. + * + * @param integrationVersion The version of the integration that was injected. + * @param contentEncoding The content encoding of the response that was injected. + */ + void onInjectionFailed(String integrationVersion, String contentEncoding); + + /** + * Reports skipped RUM injection. + * + * @param integrationVersion The version of the integration that was injected. + */ + void onInjectionSkipped(String integrationVersion); + + /** Reports successful RUM injector initialization. */ + void onInitializationSucceed(); + + /** + * Reports content security policy detected in the response header to be injected. + * + * @param integrationVersion The version of the integration that was injected. + */ + void onContentSecurityPolicyDetected(String integrationVersion); + + /** + * Reports the size of the response before injection. + * + * @param integrationVersion The version of the integration that was injected. + * @param bytes The size of the response before injection. + */ + void onInjectionResponseSize(String integrationVersion, long bytes); + + /** + * Reports the time taken to inject the RUM SDK. + * + * @param integrationVersion The version of the integration that was injected. + * @param milliseconds The time taken to inject the RUM SDK. + */ + void onInjectionTime(String integrationVersion, long milliseconds); + + /** Closes the telemetry collector. */ + default void close() {} + + /** + * Drains all count metrics to be sent via telemetry. + * + * @return Collection of count metrics to be sent via telemetry. + */ + Collection drain(); + + /** + * Drains all distribution metrics to be sent via telemetry. + * + * @return Collection of distribution points to be sent via telemetry. + */ + Collection drainDistributionSeries(); } diff --git a/internal-api/src/main/java/datadog/trace/api/sampling/AdaptiveSampler.java b/internal-api/src/main/java/datadog/trace/api/sampling/AdaptiveSampler.java index 56ad00be9c6..9d9f5fb7ad3 100644 --- a/internal-api/src/main/java/datadog/trace/api/sampling/AdaptiveSampler.java +++ b/internal-api/src/main/java/datadog/trace/api/sampling/AdaptiveSampler.java @@ -34,282 +34,278 @@ */ public class AdaptiveSampler implements Sampler { - private static final class Counts { - private final LongAdder testCount = new LongAdder(); - private static final AtomicLongFieldUpdater SAMPLE_COUNT = - AtomicLongFieldUpdater.newUpdater(Counts.class, "sampleCount"); - private volatile long sampleCount = 0L; - - void addTest() { - testCount.increment(); + private static final class Counts { + private final LongAdder testCount = new LongAdder(); + private static final AtomicLongFieldUpdater SAMPLE_COUNT = + AtomicLongFieldUpdater.newUpdater(Counts.class, "sampleCount"); + private volatile long sampleCount = 0L; + + void addTest() { + testCount.increment(); + } + + boolean addSample(final long limit) { + return SAMPLE_COUNT.getAndAccumulate(this, limit, (prev, lim) -> Math.min(prev + 1, lim)) < limit; + } + + void addSample() { + SAMPLE_COUNT.incrementAndGet(this); + } + + void reset() { + testCount.reset(); + SAMPLE_COUNT.set(this, 0); + } + + long sampleCount() { + return SAMPLE_COUNT.get(this); + } + + long testCount() { + return testCount.sum(); + } } - boolean addSample(final long limit) { - return SAMPLE_COUNT.getAndAccumulate(this, limit, (prev, lim) -> Math.min(prev + 1, lim)) - < limit; + @FunctionalInterface + public interface ConfigListener { + void onWindowRoll(long totalCount, long sampledCount, long budget, double totalAverage, double probability); } - void addSample() { - SAMPLE_COUNT.incrementAndGet(this); + /* + * Exponential Moving Average (EMA) last element weight. + * Check out papers about using EMA for streaming data - eg. + * https://nestedsoftware.com/2018/04/04/exponential-moving-average-on-streaming-data-4hhl.24876.html + * + * Corresponds to 'lookback' of N values: + * With T being the index of the most recent value the lookback of N values means that for all values with index + * T-K, where K > N, the relative weight of that value computed as (1 - alpha)^K is less or equal than the + * weight assigned by a plain arithmetic average (= 1/N). + */ + private final double emaAlpha; + private final int samplesPerWindow; + + private final AtomicReference countsRef; + + // these attributes need to be volatile since they are accessed from user threds as well as the + // maintenance one + private volatile double probability = 1d; + private volatile long samplesBudget; + + // these attributes are accessed solely from the window maintenance thread + private double totalCountRunningAverage = 0d; + private double avgSamples; + + private final int budgetLookback; + private final double budgetAlpha; + + // accessed exclusively from the window maintenance task - does not require any synchronization + private int countsSlotIdx = 0; + private final Counts[] countsSlots = new Counts[] {new Counts(), new Counts()}; + + private final ConfigListener listener; + + private final Duration windowDuration; + private final AgentTaskScheduler taskScheduler; + + /** + * Create a new sampler instance + * + * @param windowDuration the sampling window duration + * @param samplesPerWindow the maximum number of samples in the sampling window + * @param averageLookback the number of windows to consider in averaging the sampling rate + * @param budgetLookback the number of windows to consider when computing the sampling budget + * @param listener an optional listener receiving the sampler config changes + * @param taskScheduler agent task scheduler to use for periodic rolls + */ + protected AdaptiveSampler( + final Duration windowDuration, + final int samplesPerWindow, + final int averageLookback, + final int budgetLookback, + final @Nullable ConfigListener listener, + final AgentTaskScheduler taskScheduler, + boolean startSampler) { + + if (averageLookback < 1) { + throw new IllegalArgumentException("'averageLookback' argument must be at least 1"); + } + if (budgetLookback < 1) { + throw new IllegalArgumentException("'budgetLookback' argument must be at least 1"); + } + this.samplesPerWindow = samplesPerWindow; + this.budgetLookback = budgetLookback; + samplesBudget = samplesPerWindow + (long) budgetLookback * samplesPerWindow; + emaAlpha = computeIntervalAlpha(averageLookback); + budgetAlpha = computeIntervalAlpha(budgetLookback); + countsRef = new AtomicReference<>(countsSlots[0]); + this.listener = listener; + if (listener != null) { + listener.onWindowRoll(0, 0, samplesBudget, totalCountRunningAverage, probability); + } + + this.windowDuration = windowDuration; + this.taskScheduler = taskScheduler; + + if (startSampler) { + start(); + } } - void reset() { - testCount.reset(); - SAMPLE_COUNT.set(this, 0); + /** + * Create a new sampler instance with automatic window roll. + * + * @param windowDuration the sampling window duration + * @param samplesPerWindow the maximum number of samples in the sampling window + * @param averageLookback the number of windows to consider in averaging the sampling rate + * @param budgetLookback the number of windows to consider when computing the sampling budget + */ + public AdaptiveSampler( + final Duration windowDuration, + final int samplesPerWindow, + final int averageLookback, + final int budgetLookback, + boolean startSampler) { + this( + windowDuration, + samplesPerWindow, + averageLookback, + budgetLookback, + null, + AgentTaskScheduler.get(), + startSampler); } - long sampleCount() { - return SAMPLE_COUNT.get(this); + /** + * Create a new sampler instance with automatic window roll. The instance is automatically + * started. + * + * @param windowDuration the sampling window duration + * @param samplesPerWindow the maximum number of samples in the sampling window + * @param averageLookback the number of windows to consider in averaging the sampling rate + * @param budgetLookback the number of windows to consider when computing the sampling budget + * @param listener an optional listener receiving the sampler config changes + */ + public AdaptiveSampler( + final Duration windowDuration, + final int samplesPerWindow, + final int averageLookback, + final int budgetLookback, + final ConfigListener listener) { + this( + windowDuration, + samplesPerWindow, + averageLookback, + budgetLookback, + listener, + AgentTaskScheduler.get(), + true); } - long testCount() { - return testCount.sum(); - } - } - - @FunctionalInterface - public interface ConfigListener { - void onWindowRoll( - long totalCount, long sampledCount, long budget, double totalAverage, double probability); - } - - /* - * Exponential Moving Average (EMA) last element weight. - * Check out papers about using EMA for streaming data - eg. - * https://nestedsoftware.com/2018/04/04/exponential-moving-average-on-streaming-data-4hhl.24876.html - * - * Corresponds to 'lookback' of N values: - * With T being the index of the most recent value the lookback of N values means that for all values with index - * T-K, where K > N, the relative weight of that value computed as (1 - alpha)^K is less or equal than the - * weight assigned by a plain arithmetic average (= 1/N). - */ - private final double emaAlpha; - private final int samplesPerWindow; - - private final AtomicReference countsRef; - - // these attributes need to be volatile since they are accessed from user threds as well as the - // maintenance one - private volatile double probability = 1d; - private volatile long samplesBudget; - - // these attributes are accessed solely from the window maintenance thread - private double totalCountRunningAverage = 0d; - private double avgSamples; - - private final int budgetLookback; - private final double budgetAlpha; - - // accessed exclusively from the window maintenance task - does not require any synchronization - private int countsSlotIdx = 0; - private final Counts[] countsSlots = new Counts[] {new Counts(), new Counts()}; - - private final ConfigListener listener; - - private final Duration windowDuration; - private final AgentTaskScheduler taskScheduler; - - /** - * Create a new sampler instance - * - * @param windowDuration the sampling window duration - * @param samplesPerWindow the maximum number of samples in the sampling window - * @param averageLookback the number of windows to consider in averaging the sampling rate - * @param budgetLookback the number of windows to consider when computing the sampling budget - * @param listener an optional listener receiving the sampler config changes - * @param taskScheduler agent task scheduler to use for periodic rolls - */ - protected AdaptiveSampler( - final Duration windowDuration, - final int samplesPerWindow, - final int averageLookback, - final int budgetLookback, - final @Nullable ConfigListener listener, - final AgentTaskScheduler taskScheduler, - boolean startSampler) { - - if (averageLookback < 1) { - throw new IllegalArgumentException("'averageLookback' argument must be at least 1"); - } - if (budgetLookback < 1) { - throw new IllegalArgumentException("'budgetLookback' argument must be at least 1"); - } - this.samplesPerWindow = samplesPerWindow; - this.budgetLookback = budgetLookback; - samplesBudget = samplesPerWindow + (long) budgetLookback * samplesPerWindow; - emaAlpha = computeIntervalAlpha(averageLookback); - budgetAlpha = computeIntervalAlpha(budgetLookback); - countsRef = new AtomicReference<>(countsSlots[0]); - this.listener = listener; - if (listener != null) { - listener.onWindowRoll(0, 0, samplesBudget, totalCountRunningAverage, probability); + public void start() { + taskScheduler.weakScheduleAtFixedRate( + RollWindowTask.INSTANCE, + this, + windowDuration.toNanos(), + windowDuration.toNanos(), + TimeUnit.NANOSECONDS); } - this.windowDuration = windowDuration; - this.taskScheduler = taskScheduler; + @Override + public boolean sample() { + final Counts counts = countsRef.get(); + counts.addTest(); + if (ThreadLocalRandom.current().nextDouble() < probability) { + return counts.addSample(samplesBudget); + } + + return false; + } - if (startSampler) { - start(); + @Override + public boolean keep() { + final AdaptiveSampler.Counts counts = countsRef.get(); + counts.addTest(); + counts.addSample(); + return true; } - } - - /** - * Create a new sampler instance with automatic window roll. - * - * @param windowDuration the sampling window duration - * @param samplesPerWindow the maximum number of samples in the sampling window - * @param averageLookback the number of windows to consider in averaging the sampling rate - * @param budgetLookback the number of windows to consider when computing the sampling budget - */ - public AdaptiveSampler( - final Duration windowDuration, - final int samplesPerWindow, - final int averageLookback, - final int budgetLookback, - boolean startSampler) { - this( - windowDuration, - samplesPerWindow, - averageLookback, - budgetLookback, - null, - AgentTaskScheduler.get(), - startSampler); - } - - /** - * Create a new sampler instance with automatic window roll. The instance is automatically - * started. - * - * @param windowDuration the sampling window duration - * @param samplesPerWindow the maximum number of samples in the sampling window - * @param averageLookback the number of windows to consider in averaging the sampling rate - * @param budgetLookback the number of windows to consider when computing the sampling budget - * @param listener an optional listener receiving the sampler config changes - */ - public AdaptiveSampler( - final Duration windowDuration, - final int samplesPerWindow, - final int averageLookback, - final int budgetLookback, - final ConfigListener listener) { - this( - windowDuration, - samplesPerWindow, - averageLookback, - budgetLookback, - listener, - AgentTaskScheduler.get(), - true); - } - - public void start() { - taskScheduler.weakScheduleAtFixedRate( - RollWindowTask.INSTANCE, - this, - windowDuration.toNanos(), - windowDuration.toNanos(), - TimeUnit.NANOSECONDS); - } - - @Override - public boolean sample() { - final Counts counts = countsRef.get(); - counts.addTest(); - if (ThreadLocalRandom.current().nextDouble() < probability) { - return counts.addSample(samplesBudget); + + @Override + public boolean drop() { + final AdaptiveSampler.Counts counts = countsRef.get(); + counts.addTest(); + return false; } - return false; - } - - @Override - public boolean keep() { - final AdaptiveSampler.Counts counts = countsRef.get(); - counts.addTest(); - counts.addSample(); - return true; - } - - @Override - public boolean drop() { - final AdaptiveSampler.Counts counts = countsRef.get(); - counts.addTest(); - return false; - } - - private void rollWindow() { - - final Counts counts = countsSlots[countsSlotIdx]; - try { - /* - * Semi-atomically replace the Counts instance such that sample requests during window maintenance will be - * using the newly created counts instead of the ones currently processed by the maintenance routine. - * We are ok with slightly racy outcome where totaCount and sampledCount may not be totally in sync - * because it allows to avoid contention in the hot-path and the effect on the overall sample rate is minimal - * and will get compensated in the long run. - * Theoretically, a compensating system might be devised but it will always require introducing a single point - * of contention and add a fair amount of complexity. Considering that we are ok with keeping the target sampling - * rate within certain error margins and this data race is not breaking the margin it is better to keep the - * code simple and reasonably fast. - */ - countsSlotIdx = (countsSlotIdx++) % 2; - countsRef.set(countsSlots[countsSlotIdx]); - final long totalCount = counts.testCount(); - final long sampledCount = counts.sampleCount(); - - samplesBudget = calculateBudgetEma(sampledCount); - - if (totalCountRunningAverage == 0 || emaAlpha <= 0.0d) { - totalCountRunningAverage = totalCount; - } else { - totalCountRunningAverage = - totalCountRunningAverage + emaAlpha * (totalCount - totalCountRunningAverage); - } - - if (totalCountRunningAverage <= 0) { - probability = 1; - } else { - probability = Math.min(samplesBudget / totalCountRunningAverage, 1d); - } - if (listener != null) { - listener.onWindowRoll( - totalCount, sampledCount, samplesBudget, totalCountRunningAverage, probability); - } - } finally { - // Reset the previous counts slot - counts.reset(); + private void rollWindow() { + + final Counts counts = countsSlots[countsSlotIdx]; + try { + /* + * Semi-atomically replace the Counts instance such that sample requests during window maintenance will be + * using the newly created counts instead of the ones currently processed by the maintenance routine. + * We are ok with slightly racy outcome where totaCount and sampledCount may not be totally in sync + * because it allows to avoid contention in the hot-path and the effect on the overall sample rate is minimal + * and will get compensated in the long run. + * Theoretically, a compensating system might be devised but it will always require introducing a single point + * of contention and add a fair amount of complexity. Considering that we are ok with keeping the target sampling + * rate within certain error margins and this data race is not breaking the margin it is better to keep the + * code simple and reasonably fast. + */ + countsSlotIdx = (countsSlotIdx++) % 2; + countsRef.set(countsSlots[countsSlotIdx]); + final long totalCount = counts.testCount(); + final long sampledCount = counts.sampleCount(); + + samplesBudget = calculateBudgetEma(sampledCount); + + if (totalCountRunningAverage == 0 || emaAlpha <= 0.0d) { + totalCountRunningAverage = totalCount; + } else { + totalCountRunningAverage = + totalCountRunningAverage + emaAlpha * (totalCount - totalCountRunningAverage); + } + + if (totalCountRunningAverage <= 0) { + probability = 1; + } else { + probability = Math.min(samplesBudget / totalCountRunningAverage, 1d); + } + if (listener != null) { + listener.onWindowRoll(totalCount, sampledCount, samplesBudget, totalCountRunningAverage, probability); + } + } finally { + // Reset the previous counts slot + counts.reset(); + } } - } - private long calculateBudgetEma(final long sampledCount) { - avgSamples = - Double.isNaN(avgSamples) || budgetAlpha <= 0.0d - ? sampledCount - : avgSamples + budgetAlpha * (sampledCount - avgSamples); - return Math.round(Math.max(samplesPerWindow - avgSamples, 0) * budgetLookback); - } + private long calculateBudgetEma(final long sampledCount) { + avgSamples = Double.isNaN(avgSamples) || budgetAlpha <= 0.0d + ? sampledCount + : avgSamples + budgetAlpha * (sampledCount - avgSamples); + return Math.round(Math.max(samplesPerWindow - avgSamples, 0) * budgetLookback); + } - private static double computeIntervalAlpha(final int lookback) { - return 1 - Math.pow(lookback, -1d / lookback); - } + private static double computeIntervalAlpha(final int lookback) { + return 1 - Math.pow(lookback, -1d / lookback); + } - private static class RollWindowTask implements Task { + private static class RollWindowTask implements Task { - static final RollWindowTask INSTANCE = new RollWindowTask(); + static final RollWindowTask INSTANCE = new RollWindowTask(); - @Override - public void run(final AdaptiveSampler target) { - target.rollWindow(); + @Override + public void run(final AdaptiveSampler target) { + target.rollWindow(); + } } - } - // access for tests - long testCount() { - return countsRef.get().testCount(); - } + // access for tests + long testCount() { + return countsRef.get().testCount(); + } - long sampleCount() { - return countsRef.get().sampleCount(); - } + long sampleCount() { + return countsRef.get().sampleCount(); + } } diff --git a/internal-api/src/main/java/datadog/trace/api/sampling/ConstantSampler.java b/internal-api/src/main/java/datadog/trace/api/sampling/ConstantSampler.java index e0fb334f635..9f51c98d70e 100644 --- a/internal-api/src/main/java/datadog/trace/api/sampling/ConstantSampler.java +++ b/internal-api/src/main/java/datadog/trace/api/sampling/ConstantSampler.java @@ -2,24 +2,24 @@ public class ConstantSampler implements Sampler { - private final boolean constant; + private final boolean constant; - public ConstantSampler(boolean constant) { - this.constant = constant; - } + public ConstantSampler(boolean constant) { + this.constant = constant; + } - @Override - public boolean sample() { - return constant; - } + @Override + public boolean sample() { + return constant; + } - @Override - public boolean keep() { - return true; - } + @Override + public boolean keep() { + return true; + } - @Override - public boolean drop() { - return false; - } + @Override + public boolean drop() { + return false; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/sampling/PerRecordingRateLimiter.java b/internal-api/src/main/java/datadog/trace/api/sampling/PerRecordingRateLimiter.java index fdee9232459..6050f50f946 100644 --- a/internal-api/src/main/java/datadog/trace/api/sampling/PerRecordingRateLimiter.java +++ b/internal-api/src/main/java/datadog/trace/api/sampling/PerRecordingRateLimiter.java @@ -5,34 +5,30 @@ public class PerRecordingRateLimiter { - private final AdaptiveSampler sampler; + private final AdaptiveSampler sampler; - public PerRecordingRateLimiter(Duration windowDuration, int limit, Duration recordingLength) { - this(windowDuration, limit, recordingLength, 16); - } + public PerRecordingRateLimiter(Duration windowDuration, int limit, Duration recordingLength) { + this(windowDuration, limit, recordingLength, 16); + } - public PerRecordingRateLimiter( - Duration windowDuration, int limit, Duration recordingLength, int budgetLookback) { - int lookback = samplingWindowsPerRecording(recordingLength.getSeconds(), windowDuration); - int samplesPerWindow = - limit / samplingWindowsPerRecording(recordingLength.getSeconds(), windowDuration); - sampler = new AdaptiveSampler(windowDuration, samplesPerWindow, lookback, budgetLookback, true); - } + public PerRecordingRateLimiter(Duration windowDuration, int limit, Duration recordingLength, int budgetLookback) { + int lookback = samplingWindowsPerRecording(recordingLength.getSeconds(), windowDuration); + int samplesPerWindow = limit / samplingWindowsPerRecording(recordingLength.getSeconds(), windowDuration); + sampler = new AdaptiveSampler(windowDuration, samplesPerWindow, lookback, budgetLookback, true); + } - public boolean permit() { - return sampler.sample(); - } + public boolean permit() { + return sampler.sample(); + } - public static int samplingWindowsPerRecording(long uploadPeriodSeconds, Duration samplingWindow) { - /* - * Java8 doesn't have dividedBy#Duration so we have to implement poor man's version. - * None of these durations should be big enough to warrant dealing with bigints. - * We also do not care about nanoseconds here. - */ - return (int) - Math.min( - Duration.of(uploadPeriodSeconds, ChronoUnit.SECONDS).toMillis() - / samplingWindow.toMillis(), - Integer.MAX_VALUE); - } + public static int samplingWindowsPerRecording(long uploadPeriodSeconds, Duration samplingWindow) { + /* + * Java8 doesn't have dividedBy#Duration so we have to implement poor man's version. + * None of these durations should be big enough to warrant dealing with bigints. + * We also do not care about nanoseconds here. + */ + return (int) Math.min( + Duration.of(uploadPeriodSeconds, ChronoUnit.SECONDS).toMillis() / samplingWindow.toMillis(), + Integer.MAX_VALUE); + } } diff --git a/internal-api/src/main/java/datadog/trace/api/sampling/Sampler.java b/internal-api/src/main/java/datadog/trace/api/sampling/Sampler.java index 032f5454022..c92e1773f0e 100644 --- a/internal-api/src/main/java/datadog/trace/api/sampling/Sampler.java +++ b/internal-api/src/main/java/datadog/trace/api/sampling/Sampler.java @@ -2,24 +2,24 @@ public interface Sampler { - /** - * Provides binary answer whether the current event is to be sampled - * - * @return {@literal true} if the event should be sampled - */ - boolean sample(); + /** + * Provides binary answer whether the current event is to be sampled + * + * @return {@literal true} if the event should be sampled + */ + boolean sample(); - /** - * Force the sampling decision to keep this item - * - * @return always {@literal true} - */ - boolean keep(); + /** + * Force the sampling decision to keep this item + * + * @return always {@literal true} + */ + boolean keep(); - /** - * Force the sampling decision to drop this item - * - * @return always {@literal false} - */ - boolean drop(); + /** + * Force the sampling decision to drop this item + * + * @return always {@literal false} + */ + boolean drop(); } diff --git a/internal-api/src/main/java/datadog/trace/api/sampling/SamplingMechanism.java b/internal-api/src/main/java/datadog/trace/api/sampling/SamplingMechanism.java index 6b0cd397626..d72c579b585 100644 --- a/internal-api/src/main/java/datadog/trace/api/sampling/SamplingMechanism.java +++ b/internal-api/src/main/java/datadog/trace/api/sampling/SamplingMechanism.java @@ -8,83 +8,83 @@ import datadog.trace.api.Config; public class SamplingMechanism { - /** Not encouraged to use */ - public static final byte UNKNOWN = -1; + /** Not encouraged to use */ + public static final byte UNKNOWN = -1; - /** Used before the tracer receives any rates from agent and there are no rules configured */ - public static final byte DEFAULT = 0; + /** Used before the tracer receives any rates from agent and there are no rules configured */ + public static final byte DEFAULT = 0; - /** The sampling rate received in the agent's http response */ - public static final byte AGENT_RATE = 1; + /** The sampling rate received in the agent's http response */ + public static final byte AGENT_RATE = 1; - /** Auto; reserved for future use */ - public static final byte REMOTE_AUTO_RATE = 2; + /** Auto; reserved for future use */ + public static final byte REMOTE_AUTO_RATE = 2; - /** Sampling rule or sampling rate based on tracer config */ - public static final byte LOCAL_USER_RULE = 3; + /** Sampling rule or sampling rate based on tracer config */ + public static final byte LOCAL_USER_RULE = 3; - /** User directly sets sampling priority via code using span.SetTag(ManualKeep) or similar API */ - public static final byte MANUAL = 4; + /** User directly sets sampling priority via code using span.SetTag(ManualKeep) or similar API */ + public static final byte MANUAL = 4; - /** AppSec */ - public static final byte APPSEC = 5; + /** AppSec */ + public static final byte APPSEC = 5; - /** User-defined target; reserved for future use */ - public static final byte REMOTE_USER_RATE = 6; + /** User-defined target; reserved for future use */ + public static final byte REMOTE_USER_RATE = 6; - /** Span Sampling Rate (single span sampled on account of a span sampling rule) */ - public static final byte SPAN_SAMPLING_RATE = 8; + /** Span Sampling Rate (single span sampled on account of a span sampling rule) */ + public static final byte SPAN_SAMPLING_RATE = 8; - /** Data Jobs */ - public static final byte DATA_JOBS = 10; + /** Data Jobs */ + public static final byte DATA_JOBS = 10; - public static final byte REMOTE_USER_RULE = 11; - public static final byte REMOTE_ADAPTIVE_RULE = 12; - public static final byte AI_GUARD = 13; + public static final byte REMOTE_USER_RULE = 11; + public static final byte REMOTE_ADAPTIVE_RULE = 12; + public static final byte AI_GUARD = 13; - /** Force override sampling decision from external source, like W3C traceparent. */ - public static final byte EXTERNAL_OVERRIDE = Byte.MIN_VALUE; + /** Force override sampling decision from external source, like W3C traceparent. */ + public static final byte EXTERNAL_OVERRIDE = Byte.MIN_VALUE; - public static boolean validateWithSamplingPriority(int mechanism, int priority) { - switch (mechanism) { - case UNKNOWN: - return true; + public static boolean validateWithSamplingPriority(int mechanism, int priority) { + switch (mechanism) { + case UNKNOWN: + return true; - case DEFAULT: - case AGENT_RATE: - case REMOTE_AUTO_RATE: - return priority == SAMPLER_DROP || priority == SAMPLER_KEEP; + case DEFAULT: + case AGENT_RATE: + case REMOTE_AUTO_RATE: + return priority == SAMPLER_DROP || priority == SAMPLER_KEEP; - case LOCAL_USER_RULE: - case MANUAL: - case REMOTE_USER_RATE: - return priority == USER_DROP || priority == USER_KEEP; + case LOCAL_USER_RULE: + case MANUAL: + case REMOTE_USER_RATE: + return priority == USER_DROP || priority == USER_KEEP; - case APPSEC: - return priority == PrioritySampling.USER_KEEP - || priority == SAMPLER_DROP - || priority == SAMPLER_KEEP; // Necessary for ASM standalone billing + case APPSEC: + return priority == PrioritySampling.USER_KEEP + || priority == SAMPLER_DROP + || priority == SAMPLER_KEEP; // Necessary for ASM standalone billing - case DATA_JOBS: - return priority == PrioritySampling.USER_KEEP; + case DATA_JOBS: + return priority == PrioritySampling.USER_KEEP; - case EXTERNAL_OVERRIDE: - return false; + case EXTERNAL_OVERRIDE: + return false; + } + return true; } - return true; - } - - /** - * Returns true if sampling priority lock can be avoided for the given mechanism and priority - * - * @param priority the sampling priority - * @param mechanism the sampling mechanism - * @return {@code true} if the sampling priority lock can be avoided, {@code false} otherwise - */ - public static boolean canAvoidSamplingPriorityLock(int priority, int mechanism) { - return (!Config.get().isApmTracingEnabled() && mechanism == SamplingMechanism.APPSEC) - || (Config.get().isDataJobsEnabled() && mechanism == DATA_JOBS); - } - - private SamplingMechanism() {} + + /** + * Returns true if sampling priority lock can be avoided for the given mechanism and priority + * + * @param priority the sampling priority + * @param mechanism the sampling mechanism + * @return {@code true} if the sampling priority lock can be avoided, {@code false} otherwise + */ + public static boolean canAvoidSamplingPriorityLock(int priority, int mechanism) { + return (!Config.get().isApmTracingEnabled() && mechanism == SamplingMechanism.APPSEC) + || (Config.get().isDataJobsEnabled() && mechanism == DATA_JOBS); + } + + private SamplingMechanism() {} } diff --git a/internal-api/src/main/java/datadog/trace/api/sampling/SamplingRule.java b/internal-api/src/main/java/datadog/trace/api/sampling/SamplingRule.java index 45ae8a536d6..68fc71c96da 100644 --- a/internal-api/src/main/java/datadog/trace/api/sampling/SamplingRule.java +++ b/internal-api/src/main/java/datadog/trace/api/sampling/SamplingRule.java @@ -4,80 +4,80 @@ /** This interface describes the criteria for a sampling rule. */ public interface SamplingRule { - static String normalizeGlob(String name) { - return name == null || MATCH_ALL.equals(name) ? MATCH_ALL : name; - } + static String normalizeGlob(String name) { + return name == null || MATCH_ALL.equals(name) ? MATCH_ALL : name; + } - /** The "match all" glob pattern . */ - String MATCH_ALL = "*"; + /** The "match all" glob pattern . */ + String MATCH_ALL = "*"; - enum Provenance { - /** Rules generated from local static config. */ - LOCAL, + enum Provenance { + /** Rules generated from local static config. */ + LOCAL, - /** Rules generated from dynamic sampling via remote-config. */ - DYNAMIC, + /** Rules generated from dynamic sampling via remote-config. */ + DYNAMIC, - /** Rules supplied by the customer via remote-config. */ - CUSTOMER - } + /** Rules supplied by the customer via remote-config. */ + CUSTOMER + } - /** - * Gets the glob pattern the span service must match to validate the rule. - * - * @return The glob pattern the span service must match. - */ - String getService(); - - /** - * Gets the glob pattern the span name must match to validate the rule. - * - * @return The glob pattern the span name must match. - */ - String getName(); - - /** - * Gets the glob pattern the span resource must match to validate the rule. - * - * @return The glob pattern the span resource must match. - */ - String getResource(); + /** + * Gets the glob pattern the span service must match to validate the rule. + * + * @return The glob pattern the span service must match. + */ + String getService(); - /** - * Gets the collection of all (tag name, glob pattern) pairs that span tags must match to validate - * the rules. If the collection is empty, the rule will be validated. If the collection has - * entries, each key must exactly match a span tag name, and its associated glob pattern must - * match the span tag value. - * - * @return The collection of all (tag name, glob pattern) pairs that span tags must match. - */ - Map getTags(); + /** + * Gets the glob pattern the span name must match to validate the rule. + * + * @return The glob pattern the span name must match. + */ + String getName(); - /** - * Gets the probability of keeping (sampling) the element matching the rule. - * - * @return The probability of sampling the element matching the rule, between {@code 0.0} and - * {@code 1.0}. - */ - double getSampleRate(); + /** + * Gets the glob pattern the span resource must match to validate the rule. + * + * @return The glob pattern the span resource must match. + */ + String getResource(); - /** - * How this rule was generated; e.g. from local config, dynamic sampling, or the customer. - * - * @return How this rule was generated. - */ - Provenance getProvenance(); + /** + * Gets the collection of all (tag name, glob pattern) pairs that span tags must match to validate + * the rules. If the collection is empty, the rule will be validated. If the collection has + * entries, each key must exactly match a span tag name, and its associated glob pattern must + * match the span tag value. + * + * @return The collection of all (tag name, glob pattern) pairs that span tags must match. + */ + Map getTags(); - /** This interface describes the criteria of a sampling rule that can match against a trace. */ - interface TraceSamplingRule extends SamplingRule {} + /** + * Gets the probability of keeping (sampling) the element matching the rule. + * + * @return The probability of sampling the element matching the rule, between {@code 0.0} and + * {@code 1.0}. + */ + double getSampleRate(); - /** This interface describes the criteria of a sampling rule that can match against a span. */ - interface SpanSamplingRule extends SamplingRule { /** - * Gets the limit applied to the rule, using a token bucket limiter. + * How this rule was generated; e.g. from local config, dynamic sampling, or the customer. * - * @return The limit applied to the rule, {@link Integer#MAX_VALUE} if no limit. + * @return How this rule was generated. */ - int getMaxPerSecond(); - } + Provenance getProvenance(); + + /** This interface describes the criteria of a sampling rule that can match against a trace. */ + interface TraceSamplingRule extends SamplingRule {} + + /** This interface describes the criteria of a sampling rule that can match against a span. */ + interface SpanSamplingRule extends SamplingRule { + /** + * Gets the limit applied to the rule, using a token bucket limiter. + * + * @return The limit applied to the rule, {@link Integer#MAX_VALUE} if no limit. + */ + int getMaxPerSecond(); + } } diff --git a/internal-api/src/main/java/datadog/trace/api/scopemanager/ExtendedScopeListener.java b/internal-api/src/main/java/datadog/trace/api/scopemanager/ExtendedScopeListener.java index 243f3a8cc25..91ccbd6e766 100644 --- a/internal-api/src/main/java/datadog/trace/api/scopemanager/ExtendedScopeListener.java +++ b/internal-api/src/main/java/datadog/trace/api/scopemanager/ExtendedScopeListener.java @@ -3,9 +3,9 @@ import datadog.trace.api.DDTraceId; public interface ExtendedScopeListener extends ScopeListener { - void afterScopeActivated(DDTraceId traceId, long spanId); + void afterScopeActivated(DDTraceId traceId, long spanId); - /** Called just after a scope is closed. */ - @Override - void afterScopeClosed(); + /** Called just after a scope is closed. */ + @Override + void afterScopeClosed(); } diff --git a/internal-api/src/main/java/datadog/trace/api/scopemanager/ScopeListener.java b/internal-api/src/main/java/datadog/trace/api/scopemanager/ScopeListener.java index 3c55c67ef3f..8aaf2309ab1 100644 --- a/internal-api/src/main/java/datadog/trace/api/scopemanager/ScopeListener.java +++ b/internal-api/src/main/java/datadog/trace/api/scopemanager/ScopeListener.java @@ -2,14 +2,14 @@ /** Hooks for scope activation */ public interface ScopeListener { - /** - * Called just after a scope becomes the active scope - * - *

May be called multiple times. When a scope is initially created, or after a child scope is - * deactivated. - */ - void afterScopeActivated(); + /** + * Called just after a scope becomes the active scope + * + *

May be called multiple times. When a scope is initially created, or after a child scope is + * deactivated. + */ + void afterScopeActivated(); - /** Called just after a scope is closed. */ - void afterScopeClosed(); + /** Called just after a scope is closed. */ + void afterScopeClosed(); } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorImpl.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorImpl.java index 6a956388256..4561f7400d3 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorImpl.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorImpl.java @@ -10,64 +10,55 @@ import org.slf4j.LoggerFactory; public class ConfigInversionMetricCollectorImpl - implements MetricCollector, - ConfigInversionMetricCollector { - private static final Logger log = - LoggerFactory.getLogger(ConfigInversionMetricCollectorImpl.class); - private static final String CONFIG_INVERSION_KEY_TAG = "config_name:"; - private static final String CONFIG_INVERSION_METRIC_NAME = "untracked.config.detected"; - private static final String NAMESPACE = "tracers"; - private static final ConfigInversionMetricCollectorImpl INSTANCE = - new ConfigInversionMetricCollectorImpl(); + implements MetricCollector, + ConfigInversionMetricCollector { + private static final Logger log = LoggerFactory.getLogger(ConfigInversionMetricCollectorImpl.class); + private static final String CONFIG_INVERSION_KEY_TAG = "config_name:"; + private static final String CONFIG_INVERSION_METRIC_NAME = "untracked.config.detected"; + private static final String NAMESPACE = "tracers"; + private static final ConfigInversionMetricCollectorImpl INSTANCE = new ConfigInversionMetricCollectorImpl(); - private final BlockingQueue - metricsQueue; + private final BlockingQueue metricsQueue; - private ConfigInversionMetricCollectorImpl() { - this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); - } + private ConfigInversionMetricCollectorImpl() { + this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); + } - public static ConfigInversionMetricCollectorImpl getInstance() { - return INSTANCE; - } + public static ConfigInversionMetricCollectorImpl getInstance() { + return INSTANCE; + } - public void setUndocumentedEnvVarMetric(String configName) { - setMetricConfigInversionMetric(CONFIG_INVERSION_KEY_TAG + configName); - } + public void setUndocumentedEnvVarMetric(String configName) { + setMetricConfigInversionMetric(CONFIG_INVERSION_KEY_TAG + configName); + } - private void setMetricConfigInversionMetric(final String... tags) { - if (!metricsQueue.offer( - new ConfigInversionMetricCollectorImpl.ConfigInversionMetric( - NAMESPACE, true, CONFIG_INVERSION_METRIC_NAME, "count", 1, tags))) { - log.debug("Unable to add telemetry metric {} for {}", CONFIG_INVERSION_METRIC_NAME, tags[0]); + private void setMetricConfigInversionMetric(final String... tags) { + if (!metricsQueue.offer(new ConfigInversionMetricCollectorImpl.ConfigInversionMetric( + NAMESPACE, true, CONFIG_INVERSION_METRIC_NAME, "count", 1, tags))) { + log.debug("Unable to add telemetry metric {} for {}", CONFIG_INVERSION_METRIC_NAME, tags[0]); + } } - } - @Override - public void prepareMetrics() { - // Nothing to do here - } + @Override + public void prepareMetrics() { + // Nothing to do here + } - @Override - public Collection drain() { - if (this.metricsQueue.isEmpty()) { - return Collections.emptyList(); + @Override + public Collection drain() { + if (this.metricsQueue.isEmpty()) { + return Collections.emptyList(); + } + List drained = + new ArrayList<>(this.metricsQueue.size()); + this.metricsQueue.drainTo(drained); + return drained; } - List drained = - new ArrayList<>(this.metricsQueue.size()); - this.metricsQueue.drainTo(drained); - return drained; - } - public static class ConfigInversionMetric extends MetricCollector.Metric { - public ConfigInversionMetric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - final String... tags) { - super(namespace, common, metricName, type, value, tags); + public static class ConfigInversionMetric extends MetricCollector.Metric { + public ConfigInversionMetric( + String namespace, boolean common, String metricName, String type, Number value, final String... tags) { + super(namespace, common, metricName, type, value, tags); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/CoreMetricCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/CoreMetricCollector.java index 9c8074dee70..946bd67c1ad 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/CoreMetricCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/CoreMetricCollector.java @@ -14,133 +14,123 @@ /** This class is in charge of draining core metrics for telemetry. */ public class CoreMetricCollector implements MetricCollector { - private static final String METRIC_NAMESPACE = "tracers"; - private static final String INTEGRATION_NAME_TAG = "integration_name:"; - private static final CoreMetricCollector INSTANCE = new CoreMetricCollector(); - private final SpanMetricRegistryImpl spanMetricRegistry = SpanMetricRegistryImpl.getInstance(); - private final BaggageMetrics baggageMetrics = BaggageMetrics.getInstance(); - private final StatsMetrics statsMetrics = StatsMetrics.getInstance(); + private static final String METRIC_NAMESPACE = "tracers"; + private static final String INTEGRATION_NAME_TAG = "integration_name:"; + private static final CoreMetricCollector INSTANCE = new CoreMetricCollector(); + private final SpanMetricRegistryImpl spanMetricRegistry = SpanMetricRegistryImpl.getInstance(); + private final BaggageMetrics baggageMetrics = BaggageMetrics.getInstance(); + private final StatsMetrics statsMetrics = StatsMetrics.getInstance(); - private final BlockingQueue metricsQueue; + private final BlockingQueue metricsQueue; - public static CoreMetricCollector getInstance() { - return INSTANCE; - } - - private CoreMetricCollector() { - this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); - } - - public void count(String metricName, long value, String tag) { - if (value <= 0) { - return; + public static CoreMetricCollector getInstance() { + return INSTANCE; } - this.metricsQueue.offer( - new CoreMetric(METRIC_NAMESPACE, true, metricName, "count", value, tag)); - } - @Override - public void prepareMetrics() { - // Collect the bounded, high-value client-side trace-stats span-collapse counters first, tagged - // by collapse reason. There is only a small, fixed set of these; the span-metric registry below - // is unbounded (one entry per instrumentation name), so draining it first could fill the queue - // and starve the collapse counters indefinitely under high instrumentation counts. Collecting - // them up front guarantees they are emitted. - for (StatsMetrics.TaggedCounter counter : this.statsMetrics.getTaggedCounters()) { - if (this.metricsQueue.remainingCapacity() == 0) { - // Queue full: stop before reading any more counters. getValueAndReset() below resets the - // counter's delta baseline, so resetting one we then fail to enqueue would drop that delta - // for good; the untouched counters are picked up on the next collection cycle. - break; - } - long value = counter.getValueAndReset(); - if (value == 0) { - // Skip not updated counters - continue; - } - CoreMetric metric = - new CoreMetric( - METRIC_NAMESPACE, true, counter.getName(), "count", value, counter.getTag()); - if (!this.metricsQueue.offer(metric)) { - // Stop adding metrics if the queue is full - break; - } + private CoreMetricCollector() { + this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); } - // Collect span metrics - spanMetricsLoop: - for (SpanMetricsImpl spanMetrics : this.spanMetricRegistry.getSpanMetrics()) { - if (this.metricsQueue.remainingCapacity() == 0) { - // Queue full: stop before touching any more span-metrics entries, not just the counters of - // the current one, so a full queue doesn't leave us building tags and iterating counters we - // can't enqueue anyway. - break; - } - String tag = INTEGRATION_NAME_TAG + spanMetrics.getInstrumentationName(); - for (CoreCounter counter : spanMetrics.getCounters()) { - if (this.metricsQueue.remainingCapacity() == 0) { - // Queue full: stop before reading any more counters. getValueAndReset() below resets the - // counter's delta baseline, so resetting one we then fail to enqueue would drop that - // delta for good; the untouched counters are picked up on the next collection cycle. - break spanMetricsLoop; + public void count(String metricName, long value, String tag) { + if (value <= 0) { + return; } - long value = counter.getValueAndReset(); - if (value == 0) { - // Skip not updated counters - continue; + this.metricsQueue.offer(new CoreMetric(METRIC_NAMESPACE, true, metricName, "count", value, tag)); + } + + @Override + public void prepareMetrics() { + // Collect the bounded, high-value client-side trace-stats span-collapse counters first, tagged + // by collapse reason. There is only a small, fixed set of these; the span-metric registry below + // is unbounded (one entry per instrumentation name), so draining it first could fill the queue + // and starve the collapse counters indefinitely under high instrumentation counts. Collecting + // them up front guarantees they are emitted. + for (StatsMetrics.TaggedCounter counter : this.statsMetrics.getTaggedCounters()) { + if (this.metricsQueue.remainingCapacity() == 0) { + // Queue full: stop before reading any more counters. getValueAndReset() below resets the + // counter's delta baseline, so resetting one we then fail to enqueue would drop that delta + // for good; the untouched counters are picked up on the next collection cycle. + break; + } + long value = counter.getValueAndReset(); + if (value == 0) { + // Skip not updated counters + continue; + } + CoreMetric metric = + new CoreMetric(METRIC_NAMESPACE, true, counter.getName(), "count", value, counter.getTag()); + if (!this.metricsQueue.offer(metric)) { + // Stop adding metrics if the queue is full + break; + } } - CoreMetric metric = - new CoreMetric(METRIC_NAMESPACE, true, counter.getName(), "count", value, tag); - if (!this.metricsQueue.offer(metric)) { - // Stop adding metrics if the queue is full - break spanMetricsLoop; + + // Collect span metrics + spanMetricsLoop: + for (SpanMetricsImpl spanMetrics : this.spanMetricRegistry.getSpanMetrics()) { + if (this.metricsQueue.remainingCapacity() == 0) { + // Queue full: stop before touching any more span-metrics entries, not just the counters of + // the current one, so a full queue doesn't leave us building tags and iterating counters we + // can't enqueue anyway. + break; + } + String tag = INTEGRATION_NAME_TAG + spanMetrics.getInstrumentationName(); + for (CoreCounter counter : spanMetrics.getCounters()) { + if (this.metricsQueue.remainingCapacity() == 0) { + // Queue full: stop before reading any more counters. getValueAndReset() below resets the + // counter's delta baseline, so resetting one we then fail to enqueue would drop that + // delta for good; the untouched counters are picked up on the next collection cycle. + break spanMetricsLoop; + } + long value = counter.getValueAndReset(); + if (value == 0) { + // Skip not updated counters + continue; + } + CoreMetric metric = new CoreMetric(METRIC_NAMESPACE, true, counter.getName(), "count", value, tag); + if (!this.metricsQueue.offer(metric)) { + // Stop adding metrics if the queue is full + break spanMetricsLoop; + } + } } - } - } - // Collect baggage metrics - for (BaggageMetrics.TaggedCounter counter : this.baggageMetrics.getTaggedCounters()) { - if (this.metricsQueue.remainingCapacity() == 0) { - // Queue full: stop before reading any more counters. getValueAndReset() below resets the - // counter's delta baseline, so resetting one we then fail to enqueue would drop that - // delta for good; the untouched counters are picked up on the next collection cycle. - break; - } - long value = counter.getValueAndReset(); - if (value == 0) { - // Skip not updated counters - continue; - } - // Use the specific tag for each baggage metric - String tag = counter.getTag(); - CoreMetric metric = - new CoreMetric(METRIC_NAMESPACE, true, counter.getName(), "count", value, tag); - if (!this.metricsQueue.offer(metric)) { - // Stop adding metrics if the queue is full - break; - } + // Collect baggage metrics + for (BaggageMetrics.TaggedCounter counter : this.baggageMetrics.getTaggedCounters()) { + if (this.metricsQueue.remainingCapacity() == 0) { + // Queue full: stop before reading any more counters. getValueAndReset() below resets the + // counter's delta baseline, so resetting one we then fail to enqueue would drop that + // delta for good; the untouched counters are picked up on the next collection cycle. + break; + } + long value = counter.getValueAndReset(); + if (value == 0) { + // Skip not updated counters + continue; + } + // Use the specific tag for each baggage metric + String tag = counter.getTag(); + CoreMetric metric = new CoreMetric(METRIC_NAMESPACE, true, counter.getName(), "count", value, tag); + if (!this.metricsQueue.offer(metric)) { + // Stop adding metrics if the queue is full + break; + } + } } - } - @Override - public Collection drain() { - if (this.metricsQueue.isEmpty()) { - return Collections.emptyList(); + @Override + public Collection drain() { + if (this.metricsQueue.isEmpty()) { + return Collections.emptyList(); + } + List drained = new ArrayList<>(this.metricsQueue.size()); + this.metricsQueue.drainTo(drained); + return drained; } - List drained = new ArrayList<>(this.metricsQueue.size()); - this.metricsQueue.drainTo(drained); - return drained; - } - public static class CoreMetric extends MetricCollector.Metric { - public CoreMetric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - String tag) { - super(namespace, common, metricName, type, value, tag); + public static class CoreMetric extends MetricCollector.Metric { + public CoreMetric(String namespace, boolean common, String metricName, String type, Number value, String tag) { + super(namespace, common, metricName, type, value, tag); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/Endpoint.java b/internal-api/src/main/java/datadog/trace/api/telemetry/Endpoint.java index ded841404c4..8702a4d6818 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/Endpoint.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/Endpoint.java @@ -11,152 +11,152 @@ import java.util.Set; public class Endpoint { - private boolean fist; - private String type; - private String method; - private String path; - private String operation; - private String resource; - private List requestBodyType; - private List responseBodyType; - private List responseCode; - private List authentication; - private Map metadata; - - public boolean isFirst() { - return fist; - } - - public Endpoint first(boolean first) { - this.fist = first; - return this; - } - - public String getType() { - return type; - } - - public Endpoint type(final String type) { - this.type = type; - return this; - } - - public String getMethod() { - return method; - } - - public Endpoint method(final String method) { - this.method = method; - return this; - } - - public String getPath() { - return path; - } - - public Endpoint path(final String path) { - this.path = path; - return this; - } - - public String getOperation() { - return operation; - } - - public Endpoint operation(final String operation) { - this.operation = operation; - return this; - } - - public String getResource() { - return resource; - } - - public Endpoint resource(final String resource) { - this.resource = resource; - return this; - } - - public List getRequestBodyType() { - return requestBodyType; - } - - public Endpoint requestBodyType(final List requestBodyType) { - this.requestBodyType = requestBodyType; - return this; - } - - public List getResponseBodyType() { - return responseBodyType; - } - - public Endpoint responseBodyType(final List responseBodyType) { - this.responseBodyType = responseBodyType; - return this; - } - - public List getResponseCode() { - return responseCode; - } - - public Endpoint responseCode(final List responseCode) { - this.responseCode = responseCode; - return this; - } - - public List getAuthentication() { - return authentication; - } - - public Endpoint authentication(final List authentication) { - this.authentication = authentication; - return this; - } - - public Map getMetadata() { - return metadata; - } - - public Endpoint metadata(final Map metadata) { - this.metadata = metadata; - return this; - } - - public interface Operation { - String HTTP_REQUEST = "http.request"; - } - - public interface Type { - String REST = "REST"; - } - - public interface Method { - String CONNECT = "CONNECT"; - String HEAD = "HEAD"; - String GET = "GET"; - String POST = "POST"; - String PUT = "PUT"; - String PATCH = "PATCH"; - String OPTIONS = "OPTIONS"; - String DELETE = "DELETE"; - String TRACE = "TRACE"; - String ALL = "*"; - - Set METHODS = - new HashSet<>(Arrays.asList(CONNECT, HEAD, GET, POST, PUT, PATCH, OPTIONS, DELETE, TRACE)); - - static , C extends Collection> List parseMethods(final C methods) { - if (methods == null || methods.isEmpty()) { - return singletonList(ALL); - } - final List result = new ArrayList<>(methods.size()); - for (E value : methods) { - final String methodName = value.name().toUpperCase(); - if (METHODS.contains(value.name())) { - result.add(methodName); + private boolean fist; + private String type; + private String method; + private String path; + private String operation; + private String resource; + private List requestBodyType; + private List responseBodyType; + private List responseCode; + private List authentication; + private Map metadata; + + public boolean isFirst() { + return fist; + } + + public Endpoint first(boolean first) { + this.fist = first; + return this; + } + + public String getType() { + return type; + } + + public Endpoint type(final String type) { + this.type = type; + return this; + } + + public String getMethod() { + return method; + } + + public Endpoint method(final String method) { + this.method = method; + return this; + } + + public String getPath() { + return path; + } + + public Endpoint path(final String path) { + this.path = path; + return this; + } + + public String getOperation() { + return operation; + } + + public Endpoint operation(final String operation) { + this.operation = operation; + return this; + } + + public String getResource() { + return resource; + } + + public Endpoint resource(final String resource) { + this.resource = resource; + return this; + } + + public List getRequestBodyType() { + return requestBodyType; + } + + public Endpoint requestBodyType(final List requestBodyType) { + this.requestBodyType = requestBodyType; + return this; + } + + public List getResponseBodyType() { + return responseBodyType; + } + + public Endpoint responseBodyType(final List responseBodyType) { + this.responseBodyType = responseBodyType; + return this; + } + + public List getResponseCode() { + return responseCode; + } + + public Endpoint responseCode(final List responseCode) { + this.responseCode = responseCode; + return this; + } + + public List getAuthentication() { + return authentication; + } + + public Endpoint authentication(final List authentication) { + this.authentication = authentication; + return this; + } + + public Map getMetadata() { + return metadata; + } + + public Endpoint metadata(final Map metadata) { + this.metadata = metadata; + return this; + } + + public interface Operation { + String HTTP_REQUEST = "http.request"; + } + + public interface Type { + String REST = "REST"; + } + + public interface Method { + String CONNECT = "CONNECT"; + String HEAD = "HEAD"; + String GET = "GET"; + String POST = "POST"; + String PUT = "PUT"; + String PATCH = "PATCH"; + String OPTIONS = "OPTIONS"; + String DELETE = "DELETE"; + String TRACE = "TRACE"; + String ALL = "*"; + + Set METHODS = + new HashSet<>(Arrays.asList(CONNECT, HEAD, GET, POST, PUT, PATCH, OPTIONS, DELETE, TRACE)); + + static , C extends Collection> List parseMethods(final C methods) { + if (methods == null || methods.isEmpty()) { + return singletonList(ALL); + } + final List result = new ArrayList<>(methods.size()); + for (E value : methods) { + final String methodName = value.name().toUpperCase(); + if (METHODS.contains(value.name())) { + result.add(methodName); + } + } + return result; } - } - return result; } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/EndpointCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/EndpointCollector.java index ed888c1ff43..fef2d7c8457 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/EndpointCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/EndpointCollector.java @@ -6,25 +6,22 @@ import java.util.Iterator; import java.util.concurrent.atomic.AtomicReference; -@SuppressFBWarnings( - value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", - justification = "Usage in tests") +@SuppressFBWarnings(value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", justification = "Usage in tests") public class EndpointCollector { - private static final EndpointCollector INSTANCE = new EndpointCollector(); + private static final EndpointCollector INSTANCE = new EndpointCollector(); - public static EndpointCollector get() { - return INSTANCE; - } + public static EndpointCollector get() { + return INSTANCE; + } - private final AtomicReference> provider = - new AtomicReference<>(emptyIterator()); + private final AtomicReference> provider = new AtomicReference<>(emptyIterator()); - public Iterator drain() { - return provider.getAndSet(emptyIterator()); - } + public Iterator drain() { + return provider.getAndSet(emptyIterator()); + } - public void supplier(final Iterator supplier) { - provider.set(supplier); - } + public void supplier(final Iterator supplier) { + provider.set(supplier); + } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/IntegrationsCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/IntegrationsCollector.java index 0ed6f5643ff..d6ac610f88a 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/IntegrationsCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/IntegrationsCollector.java @@ -9,51 +9,51 @@ public class IntegrationsCollector { - private static final IntegrationsCollector INSTANCE = new IntegrationsCollector(); - private final Queue integrations = new LinkedBlockingQueue<>(); - - private IntegrationsCollector() {} - - public static IntegrationsCollector get() { - return INSTANCE; - } - - @SuppressFBWarnings( - value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", - justification = - "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") - public synchronized void update(Iterable names, boolean enabled) { - Integration i = new Integration(); - i.names = names; - i.enabled = enabled; - - integrations.offer(i); - } - - @SuppressFBWarnings( - value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", - justification = - "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") - public synchronized Map drain() { - if (integrations.isEmpty()) { - return Collections.emptyMap(); + private static final IntegrationsCollector INSTANCE = new IntegrationsCollector(); + private final Queue integrations = new LinkedBlockingQueue<>(); + + private IntegrationsCollector() {} + + public static IntegrationsCollector get() { + return INSTANCE; } - Map map = new LinkedHashMap<>(); + @SuppressFBWarnings( + value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", + justification = + "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") + public synchronized void update(Iterable names, boolean enabled) { + Integration i = new Integration(); + i.names = names; + i.enabled = enabled; - Integration i; - while ((i = integrations.poll()) != null) { - boolean enabled = i.enabled; - for (String name : i.names) { - map.put(name, enabled); - } + integrations.offer(i); } - return map; - } + @SuppressFBWarnings( + value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", + justification = + "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") + public synchronized Map drain() { + if (integrations.isEmpty()) { + return Collections.emptyMap(); + } + + Map map = new LinkedHashMap<>(); + + Integration i; + while ((i = integrations.poll()) != null) { + boolean enabled = i.enabled; + for (String name : i.names) { + map.put(name, enabled); + } + } + + return map; + } - private static class Integration { - public Iterable names; - public boolean enabled; - } + private static class Integration { + public Iterable names; + public boolean enabled; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LLMObsMetricCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LLMObsMetricCollector.java index 9ca9e556b5c..608f99f99f7 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/LLMObsMetricCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LLMObsMetricCollector.java @@ -23,216 +23,200 @@ *

Counts are aggregated per tag combination in-process and emitted as one point per metrics * interval. */ -public final class LLMObsMetricCollector - implements MetricCollector { - private static final String METRIC_NAMESPACE = "mlobs"; - - private static final Logger log = LoggerFactory.getLogger(LLMObsMetricCollector.class); - private static final LLMObsMetricCollector INSTANCE = new LLMObsMetricCollector(); - - public static LLMObsMetricCollector get() { - return INSTANCE; - } - - public static final String SPAN_FINISHED_METRIC = "span.finished"; - public static final String USER_PROCESSOR_CALLED_METRIC = "user_processor_called"; - public static final String FEEDBACK_SUBMITTED_METRIC = "feedback_submitted"; - public static final String COUNT_METRIC_TYPE = "count"; - - /** Tag value used when a submission failed before the real value could be determined. */ - private static final String OTHER = "other"; - - private static final String IS_ROOT_SPAN_TRUE = "is_root_span:1"; - private static final String IS_ROOT_SPAN_FALSE = "is_root_span:0"; - private static final String AUTOINSTRUMENTED_TRUE = "autoinstrumented:1"; - private static final String AUTOINSTRUMENTED_FALSE = "autoinstrumented:0"; - private static final String ERROR_TRUE = "error:1"; - private static final String ERROR_FALSE = "error:0"; - private static final String HAS_SESSION_ID_TRUE = "has_session_id:1"; - private static final String HAS_SESSION_ID_FALSE = "has_session_id:0"; - - /** - * Upper bound on the number of distinct tag combinations tracked. Tag values are drawn from - * bounded sets (integrations, span kinds, and four booleans), so legitimate cardinality is in the - * low hundreds at the 8-integration scale the tag caches are sized for; this only guards against - * an unexpected high-cardinality source. Entries are never removed (see {@link - * #prepareMetrics()}), so this is a lifetime ceiling, not a concurrent one. - */ - static final int MAX_TAG_COMBINATIONS = 512; - - private final BlockingQueue metricsQueue; - private final DDCache integrationTagCache; - private final DDCache spanKindTagCache; - - /** - * Counter per tag combination, aggregated in-process and flushed once per metrics interval by - * {@link #prepareMetrics()}. - */ - private final ConcurrentHashMap, LongAdder> spanFinishedCounters; - - private LLMObsMetricCollector() { - this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); - this.integrationTagCache = DDCaches.newFixedSizeCache(8); - this.spanKindTagCache = DDCaches.newFixedSizeCache(8); - this.spanFinishedCounters = new ConcurrentHashMap<>(); - } - - /** - * Record a span finished metric for LLMObs telemetry. - * - *

This only increments an in-process counter. The counter is converted into a single telemetry - * metric per tag combination by {@link #prepareMetrics()}, once per metrics interval. - * - * @param integration the integration name (e.g., "openai") - * @param spanKind the span kind (e.g., "llm", "embedding") - * @param isRootSpan whether this is a root span - * @param isAutoInstrumented whether this span was auto-instrumented - * @param hasError whether the span had an error - * @param hasSessionId whether a session id was provided for this span - */ - public void recordSpanFinished( - String integration, - String spanKind, - boolean isRootSpan, - boolean isAutoInstrumented, - boolean hasError, - boolean hasSessionId) { - String integrationTag = - integrationTagCache.computeIfAbsent(integration, key -> "integration:" + key); - String spanKindTag = spanKindTagCache.computeIfAbsent(spanKind, key -> "span_kind:" + key); - - List tags = - Arrays.asList( - integrationTag, - spanKindTag, - isRootSpan ? IS_ROOT_SPAN_TRUE : IS_ROOT_SPAN_FALSE, - isAutoInstrumented ? AUTOINSTRUMENTED_TRUE : AUTOINSTRUMENTED_FALSE, - hasError ? ERROR_TRUE : ERROR_FALSE, - hasSessionId ? HAS_SESSION_ID_TRUE : HAS_SESSION_ID_FALSE); - - LongAdder counter = spanFinishedCounters.get(tags); - if (counter == null) { - // Soft bound: concurrent recorders may overshoot slightly, which is fine for a guard. - if (spanFinishedCounters.size() >= MAX_TAG_COMBINATIONS) { - if (log.isDebugEnabled()) { - log.debug( - "Dropping telemetry metric {} for {}: tag combination limit ({}) reached", - SPAN_FINISHED_METRIC, - integration, - MAX_TAG_COMBINATIONS); +public final class LLMObsMetricCollector implements MetricCollector { + private static final String METRIC_NAMESPACE = "mlobs"; + + private static final Logger log = LoggerFactory.getLogger(LLMObsMetricCollector.class); + private static final LLMObsMetricCollector INSTANCE = new LLMObsMetricCollector(); + + public static LLMObsMetricCollector get() { + return INSTANCE; + } + + public static final String SPAN_FINISHED_METRIC = "span.finished"; + public static final String USER_PROCESSOR_CALLED_METRIC = "user_processor_called"; + public static final String FEEDBACK_SUBMITTED_METRIC = "feedback_submitted"; + public static final String COUNT_METRIC_TYPE = "count"; + + /** Tag value used when a submission failed before the real value could be determined. */ + private static final String OTHER = "other"; + + private static final String IS_ROOT_SPAN_TRUE = "is_root_span:1"; + private static final String IS_ROOT_SPAN_FALSE = "is_root_span:0"; + private static final String AUTOINSTRUMENTED_TRUE = "autoinstrumented:1"; + private static final String AUTOINSTRUMENTED_FALSE = "autoinstrumented:0"; + private static final String ERROR_TRUE = "error:1"; + private static final String ERROR_FALSE = "error:0"; + private static final String HAS_SESSION_ID_TRUE = "has_session_id:1"; + private static final String HAS_SESSION_ID_FALSE = "has_session_id:0"; + + /** + * Upper bound on the number of distinct tag combinations tracked. Tag values are drawn from + * bounded sets (integrations, span kinds, and four booleans), so legitimate cardinality is in the + * low hundreds at the 8-integration scale the tag caches are sized for; this only guards against + * an unexpected high-cardinality source. Entries are never removed (see {@link + * #prepareMetrics()}), so this is a lifetime ceiling, not a concurrent one. + */ + static final int MAX_TAG_COMBINATIONS = 512; + + private final BlockingQueue metricsQueue; + private final DDCache integrationTagCache; + private final DDCache spanKindTagCache; + + /** + * Counter per tag combination, aggregated in-process and flushed once per metrics interval by + * {@link #prepareMetrics()}. + */ + private final ConcurrentHashMap, LongAdder> spanFinishedCounters; + + private LLMObsMetricCollector() { + this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); + this.integrationTagCache = DDCaches.newFixedSizeCache(8); + this.spanKindTagCache = DDCaches.newFixedSizeCache(8); + this.spanFinishedCounters = new ConcurrentHashMap<>(); + } + + /** + * Record a span finished metric for LLMObs telemetry. + * + *

This only increments an in-process counter. The counter is converted into a single telemetry + * metric per tag combination by {@link #prepareMetrics()}, once per metrics interval. + * + * @param integration the integration name (e.g., "openai") + * @param spanKind the span kind (e.g., "llm", "embedding") + * @param isRootSpan whether this is a root span + * @param isAutoInstrumented whether this span was auto-instrumented + * @param hasError whether the span had an error + * @param hasSessionId whether a session id was provided for this span + */ + public void recordSpanFinished( + String integration, + String spanKind, + boolean isRootSpan, + boolean isAutoInstrumented, + boolean hasError, + boolean hasSessionId) { + String integrationTag = integrationTagCache.computeIfAbsent(integration, key -> "integration:" + key); + String spanKindTag = spanKindTagCache.computeIfAbsent(spanKind, key -> "span_kind:" + key); + + List tags = Arrays.asList( + integrationTag, + spanKindTag, + isRootSpan ? IS_ROOT_SPAN_TRUE : IS_ROOT_SPAN_FALSE, + isAutoInstrumented ? AUTOINSTRUMENTED_TRUE : AUTOINSTRUMENTED_FALSE, + hasError ? ERROR_TRUE : ERROR_FALSE, + hasSessionId ? HAS_SESSION_ID_TRUE : HAS_SESSION_ID_FALSE); + + LongAdder counter = spanFinishedCounters.get(tags); + if (counter == null) { + // Soft bound: concurrent recorders may overshoot slightly, which is fine for a guard. + if (spanFinishedCounters.size() >= MAX_TAG_COMBINATIONS) { + if (log.isDebugEnabled()) { + log.debug( + "Dropping telemetry metric {} for {}: tag combination limit ({}) reached", + SPAN_FINISHED_METRIC, + integration, + MAX_TAG_COMBINATIONS); + } + return; + } + counter = spanFinishedCounters.computeIfAbsent(tags, key -> new LongAdder()); } - return; - } - counter = spanFinishedCounters.computeIfAbsent(tags, key -> new LongAdder()); + counter.increment(); } - counter.increment(); - } - - /** - * Records that a user-provided LLM Observability span processor was called. - * - * @param error whether the processor failed - */ - public void recordUserProcessorCalled(boolean error) { - LLMObsMetric metric = - new LLMObsMetric( - METRIC_NAMESPACE, - true, - USER_PROCESSOR_CALLED_METRIC, - COUNT_METRIC_TYPE, - 1L, - Collections.singletonList(error ? ERROR_TRUE : ERROR_FALSE)); - if (!metricsQueue.offer(metric)) { - log.debug("Unable to add telemetry metric {}", USER_PROCESSOR_CALLED_METRIC); + + /** + * Records that a user-provided LLM Observability span processor was called. + * + * @param error whether the processor failed + */ + public void recordUserProcessorCalled(boolean error) { + LLMObsMetric metric = new LLMObsMetric( + METRIC_NAMESPACE, + true, + USER_PROCESSOR_CALLED_METRIC, + COUNT_METRIC_TYPE, + 1L, + Collections.singletonList(error ? ERROR_TRUE : ERROR_FALSE)); + if (!metricsQueue.offer(metric)) { + log.debug("Unable to add telemetry metric {}", USER_PROCESSOR_CALLED_METRIC); + } } - } - - /** - * Record an end-user feedback submission attempt, successful or not. - * - *

Mirrors {@code record_llmobs_submit_feedback} in dd-trace-py: rejected submissions are - * counted too, tagged with the validation error, so that adoption and SDK misuse are both - * visible. - * - * @param metricType the feedback metric type (e.g. "boolean"), or null if it could not be - * determined - * @param targetType the wire key of the feedback target (e.g. "span_id"), or null if it could not - * be determined - * @param error the validation error code (e.g. "invalid_submitter"), or null if the submission - * was accepted - */ - public void recordFeedbackSubmitted( - @Nullable String metricType, @Nullable String targetType, @Nullable String error) { - List tags = new ArrayList<>(4); - tags.add(error == null ? ERROR_FALSE : ERROR_TRUE); - if (error != null) { - tags.add("error_type:" + error); + + /** + * Record an end-user feedback submission attempt, successful or not. + * + *

Mirrors {@code record_llmobs_submit_feedback} in dd-trace-py: rejected submissions are + * counted too, tagged with the validation error, so that adoption and SDK misuse are both + * visible. + * + * @param metricType the feedback metric type (e.g. "boolean"), or null if it could not be + * determined + * @param targetType the wire key of the feedback target (e.g. "span_id"), or null if it could not + * be determined + * @param error the validation error code (e.g. "invalid_submitter"), or null if the submission + * was accepted + */ + public void recordFeedbackSubmitted( + @Nullable String metricType, @Nullable String targetType, @Nullable String error) { + List tags = new ArrayList<>(4); + tags.add(error == null ? ERROR_FALSE : ERROR_TRUE); + if (error != null) { + tags.add("error_type:" + error); + } + tags.add("metric_type:" + (metricType == null ? OTHER : metricType)); + tags.add("target_type:" + (targetType == null ? OTHER : targetType)); + + LLMObsMetric metric = + new LLMObsMetric(METRIC_NAMESPACE, true, FEEDBACK_SUBMITTED_METRIC, COUNT_METRIC_TYPE, 1L, tags); + if (!metricsQueue.offer(metric)) { + log.debug("Unable to add telemetry metric {}", FEEDBACK_SUBMITTED_METRIC); + } } - tags.add("metric_type:" + (metricType == null ? OTHER : metricType)); - tags.add("target_type:" + (targetType == null ? OTHER : targetType)); - - LLMObsMetric metric = - new LLMObsMetric( - METRIC_NAMESPACE, true, FEEDBACK_SUBMITTED_METRIC, COUNT_METRIC_TYPE, 1L, tags); - if (!metricsQueue.offer(metric)) { - log.debug("Unable to add telemetry metric {}", FEEDBACK_SUBMITTED_METRIC); + + @Override + public void prepareMetrics() { + // Entries are never removed: a recorder thread may already hold a reference to a LongAdder, so + // removing it here would silently drop a concurrent increment. Tag values come from bounded + // sets, so retaining idle combinations costs at most MAX_TAG_COMBINATIONS entries. + for (Map.Entry, LongAdder> entry : spanFinishedCounters.entrySet()) { + long value = entry.getValue().sumThenReset(); + if (value == 0) { + continue; + } + LLMObsMetric metric = new LLMObsMetric( + METRIC_NAMESPACE, true, SPAN_FINISHED_METRIC, COUNT_METRIC_TYPE, value, entry.getKey()); + if (!metricsQueue.offer(metric)) { + // Queue is full; give the count back to the counter so it is reported in a later interval + // instead of being lost, and stop staging for now. + entry.getValue().add(value); + log.debug("Unable to add telemetry metric {}: queue is full", SPAN_FINISHED_METRIC); + break; + } + } } - } - - @Override - public void prepareMetrics() { - // Entries are never removed: a recorder thread may already hold a reference to a LongAdder, so - // removing it here would silently drop a concurrent increment. Tag values come from bounded - // sets, so retaining idle combinations costs at most MAX_TAG_COMBINATIONS entries. - for (Map.Entry, LongAdder> entry : spanFinishedCounters.entrySet()) { - long value = entry.getValue().sumThenReset(); - if (value == 0) { - continue; - } - LLMObsMetric metric = - new LLMObsMetric( - METRIC_NAMESPACE, - true, - SPAN_FINISHED_METRIC, - COUNT_METRIC_TYPE, - value, - entry.getKey()); - if (!metricsQueue.offer(metric)) { - // Queue is full; give the count back to the counter so it is reported in a later interval - // instead of being lost, and stop staging for now. - entry.getValue().add(value); - log.debug("Unable to add telemetry metric {}: queue is full", SPAN_FINISHED_METRIC); - break; - } + + @Override + public Collection drain() { + if (this.metricsQueue.isEmpty()) { + return Collections.emptyList(); + } + List drained = new ArrayList<>(this.metricsQueue.size()); + this.metricsQueue.drainTo(drained); + return drained; } - } - @Override - public Collection drain() { - if (this.metricsQueue.isEmpty()) { - return Collections.emptyList(); + /** Clears all staged counters and metrics. Visible for testing only. */ + @VisibleForTesting + public void resetForTesting() { + spanFinishedCounters.clear(); + metricsQueue.clear(); } - List drained = new ArrayList<>(this.metricsQueue.size()); - this.metricsQueue.drainTo(drained); - return drained; - } - - /** Clears all staged counters and metrics. Visible for testing only. */ - @VisibleForTesting - public void resetForTesting() { - spanFinishedCounters.clear(); - metricsQueue.clear(); - } - - public static class LLMObsMetric extends MetricCollector.Metric { - public LLMObsMetric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - List tags) { - super(namespace, common, metricName, type, value, tags); + + public static class LLMObsMetric extends MetricCollector.Metric { + public LLMObsMetric( + String namespace, boolean common, String metricName, String type, Number value, List tags) { + super(namespace, common, metricName, type, value, tags); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java index 404e8eebb0f..17d55a30860 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LogCollector.java @@ -18,233 +18,223 @@ import org.slf4j.MarkerFactory; public class LogCollector { - public static final Marker SEND_TELEMETRY = MarkerFactory.getMarker("SEND_TELEMETRY"); - public static final Marker EXCLUDE_TELEMETRY = MarkerFactory.getMarker("EXCLUDE_TELEMETRY"); - private static final int DEFAULT_MAX_CAPACITY = 10; - private static final LogCollector INSTANCE = new LogCollector(); - @VisibleForTesting final ConcurrentHashtable.State rawLogMessages; - - public static LogCollector get() { - return INSTANCE; - } - - private LogCollector() { - this(DEFAULT_MAX_CAPACITY); - } - - @SuppressFBWarnings( - value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", - justification = "Usage in tests") - LogCollector(int maxCapacity) { - this.rawLogMessages = ConcurrentHashtable.createBounded(RawLogMessage.class, maxCapacity); - } - - public void addLogMessage(String logLevel, String message, @Nullable Throwable throwable) { - addLogMessage(logLevel, message, throwable, null); - } - - /** - * Queue a log message to be sent on next telemetry flush. - * - * @param logLevel Log level (ERROR, WARN, DEBUG). Unknown log levels will be ignored. - * @param message Log message. - * @param throwable Optional throwable to attach a stacktrace. - * @param tags Optional tags to attach to the log. These are a comma-separated list, e.g. - * tag1:value1,tag2:value2 - */ - public void addLogMessage( - String logLevel, String message, @Nullable Throwable throwable, @Nullable String tags) { - long keyHash = RawLogMessage.computeHash(logLevel, message, throwable); - // Fast path for duplicates: search lock-free before ever taking the table lock. - RawLogMessage rawLogMessage = find(keyHash, logLevel, message, throwable); - if (rawLogMessage != null) { - rawLogMessage.increment(); - return; + public static final Marker SEND_TELEMETRY = MarkerFactory.getMarker("SEND_TELEMETRY"); + public static final Marker EXCLUDE_TELEMETRY = MarkerFactory.getMarker("EXCLUDE_TELEMETRY"); + private static final int DEFAULT_MAX_CAPACITY = 10; + private static final LogCollector INSTANCE = new LogCollector(); + + @VisibleForTesting + final ConcurrentHashtable.State rawLogMessages; + + public static LogCollector get() { + return INSTANCE; } - // Slow path after a miss: tryGetOrInsertOrNull does its own locked comparison -- including, - // via tryReserve, a recheck against a concurrent duplicate even when the table looks full -- - // so there's no need to repeat find() here first. - try (Reservation reservation = - ConcurrentHashtable.tryReserve(rawLogMessages, keyHash)) { - // Built zeroed, so this occurrence can be counted uniformly below whether or not - // tryGetOrInsertOrNull ends up returning this instance or an existing match. - rawLogMessage = - reservation.tryGetOrInsertOrNull( - new RawLogMessage( - logLevel, message, throwable, tags, System.currentTimeMillis() / 1000)); - if (rawLogMessage == null) { - // TODO: We could emit a metric for dropped logs. - return; - } - rawLogMessage.increment(); + private LogCollector() { + this(DEFAULT_MAX_CAPACITY); } - } - - /** - * Removes all available log group from this collector and returns them. - * - *

The count of each returned group is captured during removal. Increments that complete - * after the count is captured are not included. - * - * @return a collection containing the removed log groups - */ - public Collection drain() { - int size = estimateSize(rawLogMessages); - if (size == 0) { - return Collections.emptyList(); + + @SuppressFBWarnings(value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", justification = "Usage in tests") + LogCollector(int maxCapacity) { + this.rawLogMessages = ConcurrentHashtable.createBounded(RawLogMessage.class, maxCapacity); } - // Note drain takes the table lock - List list = new ArrayList<>(size); - ConcurrentHashtable.drain( - rawLogMessages, - list, - (drained, logMessage) -> { - // Snapshot each log group's count before adding it to the drain result. - logMessage.snapshotCount(); - drained.add(logMessage); - }); - return list; - } - - /** - * Finds a log group with the same level, message, and - * throwable as {@code keyHash}'s candidates. - * - *

Note, throwables are matched by identity or by class and stack trace. - * - *

The bucket chain supports lock-free reads. A caller that inserts after a miss must repeat - * the search under the table write lock. - * - * @param keyHash precomputed hash of the level, message, and throwable class - * @param logLevel log level to match - * @param message message to match - * @param throwable optional throwable to match - * @return the matching log group, or {@code null} if none is present - */ - @Nullable - private RawLogMessage find( - long keyHash, String logLevel, String message, @Nullable Throwable throwable) { - StackTraceElement[] stackTrace = null; - for (RawLogMessage entry : ConcurrentHashtable.hashIterable(rawLogMessages, keyHash)) { - if (!Objects.equals(logLevel, entry.logLevel) || !Objects.equals(message, entry.message)) { - continue; - } - // throwables are more costly to compare, check first the identity - if (throwable == entry.throwable) { - return entry; - } - if (throwable != null - && entry.throwable != null - && throwable.getClass().equals(entry.throwable.getClass())) { - if (stackTrace == null) { - stackTrace = throwable.getStackTrace(); + public void addLogMessage(String logLevel, String message, @Nullable Throwable throwable) { + addLogMessage(logLevel, message, throwable, null); + } + + /** + * Queue a log message to be sent on next telemetry flush. + * + * @param logLevel Log level (ERROR, WARN, DEBUG). Unknown log levels will be ignored. + * @param message Log message. + * @param throwable Optional throwable to attach a stacktrace. + * @param tags Optional tags to attach to the log. These are a comma-separated list, e.g. + * tag1:value1,tag2:value2 + */ + public void addLogMessage(String logLevel, String message, @Nullable Throwable throwable, @Nullable String tags) { + long keyHash = RawLogMessage.computeHash(logLevel, message, throwable); + // Fast path for duplicates: search lock-free before ever taking the table lock. + RawLogMessage rawLogMessage = find(keyHash, logLevel, message, throwable); + if (rawLogMessage != null) { + rawLogMessage.increment(); + return; } - if (Objects.deepEquals(stackTrace, entry.stackTrace())) { - return entry; + + // Slow path after a miss: tryGetOrInsertOrNull does its own locked comparison -- including, + // via tryReserve, a recheck against a concurrent duplicate even when the table looks full -- + // so there's no need to repeat find() here first. + try (Reservation reservation = ConcurrentHashtable.tryReserve(rawLogMessages, keyHash)) { + // Built zeroed, so this occurrence can be counted uniformly below whether or not + // tryGetOrInsertOrNull ends up returning this instance or an existing match. + rawLogMessage = reservation.tryGetOrInsertOrNull( + new RawLogMessage(logLevel, message, throwable, tags, System.currentTimeMillis() / 1000)); + if (rawLogMessage == null) { + // TODO: We could emit a metric for dropped logs. + return; + } + rawLogMessage.increment(); } - } } - return null; - } - - /** - * Groups equivalent log messages for a telemetry flush. - * - *

Messages are equivalent when their log level, message, and throwable type and stack trace - * match. The first message supplies the tags and timestamp; later messages only increment the - * occurrence count. - */ - public static final class RawLogMessage extends ConcurrentHashtable.Entry { - private static final AtomicIntegerFieldUpdater LIVE_OCCURRENCE_COUNT_UPDATER = - AtomicIntegerFieldUpdater.newUpdater(RawLogMessage.class, "liveOccurrenceCount"); - - public final String message; - public final String logLevel; - public final Throwable throwable; - public final String tags; - public final long timestamp; - - /** Number of equivalent log messages captured when this group was drained. */ - public int count; /** - * Live counter equivalent log messages accumulated in this group. Starts zeroed so a caller can - * unconditionally {@link #increment()} once after a find-or-insert, whether it landed this - * instance or an existing match. + * Removes all available log group from this collector and returns them. + * + *

The count of each returned group is captured during removal. Increments that complete + * after the count is captured are not included. + * + * @return a collection containing the removed log groups */ - private volatile int liveOccurrenceCount = 0; - - private volatile StackTraceElement[] cachedStackTrace = null; - - public RawLogMessage( - String logLevel, String message, Throwable throwable, String tags, long timestamp) { - super(computeHash(logLevel, message, throwable)); - this.logLevel = logLevel; - this.message = message; - this.throwable = throwable; - this.tags = tags; - this.timestamp = timestamp; + public Collection drain() { + int size = estimateSize(rawLogMessages); + if (size == 0) { + return Collections.emptyList(); + } + + // Note drain takes the table lock + List list = new ArrayList<>(size); + ConcurrentHashtable.drain(rawLogMessages, list, (drained, logMessage) -> { + // Snapshot each log group's count before adding it to the drain result. + logMessage.snapshotCount(); + drained.add(logMessage); + }); + return list; + } + + /** + * Finds a log group with the same level, message, and + * throwable as {@code keyHash}'s candidates. + * + *

Note, throwables are matched by identity or by class and stack trace. + * + *

The bucket chain supports lock-free reads. A caller that inserts after a miss must repeat + * the search under the table write lock. + * + * @param keyHash precomputed hash of the level, message, and throwable class + * @param logLevel log level to match + * @param message message to match + * @param throwable optional throwable to match + * @return the matching log group, or {@code null} if none is present + */ + @Nullable + private RawLogMessage find(long keyHash, String logLevel, String message, @Nullable Throwable throwable) { + StackTraceElement[] stackTrace = null; + for (RawLogMessage entry : ConcurrentHashtable.hashIterable(rawLogMessages, keyHash)) { + if (!Objects.equals(logLevel, entry.logLevel) || !Objects.equals(message, entry.message)) { + continue; + } + // throwables are more costly to compare, check first the identity + if (throwable == entry.throwable) { + return entry; + } + if (throwable != null + && entry.throwable != null + && throwable.getClass().equals(entry.throwable.getClass())) { + if (stackTrace == null) { + stackTrace = throwable.getStackTrace(); + } + if (Objects.deepEquals(stackTrace, entry.stackTrace())) { + return entry; + } + } + } + return null; } - public StackTraceElement[] stackTrace() { - if (throwable == null) return null; + /** + * Groups equivalent log messages for a telemetry flush. + * + *

Messages are equivalent when their log level, message, and throwable type and stack trace + * match. The first message supplies the tags and timestamp; later messages only increment the + * occurrence count. + */ + public static final class RawLogMessage extends ConcurrentHashtable.Entry { + private static final AtomicIntegerFieldUpdater LIVE_OCCURRENCE_COUNT_UPDATER = + AtomicIntegerFieldUpdater.newUpdater(RawLogMessage.class, "liveOccurrenceCount"); + + public final String message; + public final String logLevel; + public final Throwable throwable; + public final String tags; + public final long timestamp; + + /** Number of equivalent log messages captured when this group was drained. */ + public int count; + + /** + * Live counter equivalent log messages accumulated in this group. Starts zeroed so a caller can + * unconditionally {@link #increment()} once after a find-or-insert, whether it landed this + * instance or an existing match. + */ + private volatile int liveOccurrenceCount = 0; + + private volatile StackTraceElement[] cachedStackTrace = null; + + public RawLogMessage(String logLevel, String message, Throwable throwable, String tags, long timestamp) { + super(computeHash(logLevel, message, throwable)); + this.logLevel = logLevel; + this.message = message; + this.throwable = throwable; + this.tags = tags; + this.timestamp = timestamp; + } - // getStackTrace() makes a defensive copy. Cache one safely published copy for concurrent - // comparisons against equivalent throwables. - StackTraceElement[] stackTrace = cachedStackTrace; - if (stackTrace != null) return stackTrace; + public StackTraceElement[] stackTrace() { + if (throwable == null) return null; - cachedStackTrace = stackTrace = throwable.getStackTrace(); - return stackTrace; - } + // getStackTrace() makes a defensive copy. Cache one safely published copy for concurrent + // comparisons against equivalent throwables. + StackTraceElement[] stackTrace = cachedStackTrace; + if (stackTrace != null) return stackTrace; - private void increment() { - LIVE_OCCURRENCE_COUNT_UPDATER.incrementAndGet(this); - } + cachedStackTrace = stackTrace = throwable.getStackTrace(); + return stackTrace; + } - /** Snapshot this log's live occurrence count */ - private void snapshotCount() { - count = LIVE_OCCURRENCE_COUNT_UPDATER.get(this); - } + private void increment() { + LIVE_OCCURRENCE_COUNT_UPDATER.incrementAndGet(this); + } - @Override - public boolean matches(RawLogMessage that) { - if (!Objects.equals(logLevel, that.logLevel)) return false; - if (!Objects.equals(message, that.message)) return false; - - if (throwable == that.throwable) { - // DQH - While this path may seem unlikely, it does happen if the JVM fast - // throws optimization kicks-in (for NPE, etc), so this case is worth optimizing. - - // This also covers the case where both throwables are null - return true; - } else if (throwable != null && that.throwable != null) { - // Both have a throwable perform a deeper comparison - return throwable.getClass().equals(that.throwable.getClass()) - && Objects.deepEquals(stackTrace(), that.stackTrace()); - } else { - // One has an exception & the other doesn't, not equal - return false; - } - } + /** Snapshot this log's live occurrence count */ + private void snapshotCount() { + count = LIVE_OCCURRENCE_COUNT_UPDATER.get(this); + } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - return matches((RawLogMessage) o); - } + @Override + public boolean matches(RawLogMessage that) { + if (!Objects.equals(logLevel, that.logLevel)) return false; + if (!Objects.equals(message, that.message)) return false; + + if (throwable == that.throwable) { + // DQH - While this path may seem unlikely, it does happen if the JVM fast + // throws optimization kicks-in (for NPE, etc), so this case is worth optimizing. + + // This also covers the case where both throwables are null + return true; + } else if (throwable != null && that.throwable != null) { + // Both have a throwable perform a deeper comparison + return throwable.getClass().equals(that.throwable.getClass()) + && Objects.deepEquals(stackTrace(), that.stackTrace()); + } else { + // One has an exception & the other doesn't, not equal + return false; + } + } - @Override - public int hashCode() { - return (int) keyHash; - } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + return matches((RawLogMessage) o); + } + + @Override + public int hashCode() { + return (int) keyHash; + } - private static long computeHash( - String logLevel, String message, @Nullable Throwable throwable) { - return hash(logLevel, message, throwable == null ? null : throwable.getClass()); + private static long computeHash(String logLevel, String message, @Nullable Throwable throwable) { + return hash(logLevel, message, throwable == null ? null : throwable.getClass()); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LoginEvent.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LoginEvent.java index c7aeeb91db9..7e6c11476d8 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/LoginEvent.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LoginEvent.java @@ -1,24 +1,24 @@ package datadog.trace.api.telemetry; public enum LoginEvent { - LOGIN_SUCCESS("login_success"), - LOGIN_FAILURE("login_failure"), - SIGN_UP("signup"), - CUSTOM("custom"); + LOGIN_SUCCESS("login_success"), + LOGIN_FAILURE("login_failure"), + SIGN_UP("signup"), + CUSTOM("custom"); - private static final int numValues = LoginEvent.values().length; + private static final int numValues = LoginEvent.values().length; - private final String tag; + private final String tag; - LoginEvent(final String tag) { - this.tag = tag; - } + LoginEvent(final String tag) { + this.tag = tag; + } - public String getTag() { - return tag; - } + public String getTag() { + return tag; + } - public static int getNumValues() { - return numValues; - } + public static int getNumValues() { + return numValues; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LoginFramework.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LoginFramework.java index c0b00b47d3f..ea08ef3ea93 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/LoginFramework.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LoginFramework.java @@ -1,21 +1,21 @@ package datadog.trace.api.telemetry; public enum LoginFramework { - SPRING_SECURITY("spring_security"); + SPRING_SECURITY("spring_security"); - private static final int numValues = LoginFramework.values().length; + private static final int numValues = LoginFramework.values().length; - private final String tag; + private final String tag; - LoginFramework(final String tag) { - this.tag = tag; - } + LoginFramework(final String tag) { + this.tag = tag; + } - public String getTag() { - return tag; - } + public String getTag() { + return tag; + } - public static int getNumValues() { - return numValues; - } + public static int getNumValues() { + return numValues; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/LoginVersion.java b/internal-api/src/main/java/datadog/trace/api/telemetry/LoginVersion.java index d91fd519cca..51fcabba82a 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/LoginVersion.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/LoginVersion.java @@ -1,23 +1,23 @@ package datadog.trace.api.telemetry; public enum LoginVersion { - V1("v1"), - V2("v2"), - AUTO(null); + V1("v1"), + V2("v2"), + AUTO(null); - private final String tag; + private final String tag; - LoginVersion(final String tag) { - this.tag = tag; - } + LoginVersion(final String tag) { + this.tag = tag; + } - public String getTag() { - return tag; - } + public String getTag() { + return tag; + } - private static final int numValues = LoginVersion.values().length; + private static final int numValues = LoginVersion.values().length; - public static int getNumValues() { - return numValues; - } + public static int getNumValues() { + return numValues; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/MetricCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/MetricCollector.java index 1e510424d7d..756c6b86abf 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/MetricCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/MetricCollector.java @@ -13,158 +13,142 @@ public interface MetricCollector { - int RAW_QUEUE_SIZE = 1024; - - // TODO All implementations are based on queueing metrics and never care when the queue is full. - // TODO This leads to always resetting counters even if the related metrics could not be enqueued. - // TODO So we may loose metric information during this call. - void prepareMetrics(); - - Collection drain(); - - default Collection drainDistributionSeries() { - return Collections.emptySet(); - } - - class Metric { - public final String metricName; - public final boolean common; - public final String namespace; - public final String type; - public final long timestamp; - public final Number value; - public final List tags; - - public Metric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - final String tag) { - this( - namespace, - common, - metricName, - type, - value, - tag == null ? emptyList() : singletonList(tag)); - } - - public Metric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - final String... tags) { - this(namespace, common, metricName, type, value, Arrays.asList(tags)); - } - - public Metric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - final List tags) { - this.namespace = namespace; - this.common = common; - this.metricName = metricName; - this.type = type; - this.timestamp = System.currentTimeMillis() / 1000; - this.value = value; - this.tags = tags; - } + int RAW_QUEUE_SIZE = 1024; - @Override - public boolean equals(final Object o) { - if (this == o) return true; - if (!(o instanceof Metric)) return false; - Metric metric = (Metric) o; - return common == metric.common - && Objects.equals(metricName, metric.metricName) - && Objects.equals(namespace, metric.namespace) - && Objects.equals(tags, metric.tags); - } - - @Override - public int hashCode() { - return HashingUtils.hash(metricName, common, namespace, tags); - } + // TODO All implementations are based on queueing metrics and never care when the queue is full. + // TODO This leads to always resetting counters even if the related metrics could not be enqueued. + // TODO So we may loose metric information during this call. + void prepareMetrics(); - @Override - public String toString() { - return "Metric{" - + "metricName='" - + metricName - + '\'' - + ", common=" - + common - + ", namespace='" - + namespace - + '\'' - + ", type='" - + type - + '\'' - + ", timestamp=" - + timestamp - + ", value=" - + value - + ", tags=" - + tags - + '}'; - } - } - - class DistributionSeriesPoint { - public final String metricName; - public final boolean common; - public final String namespace; - public final int value; - public final List tags; - - public DistributionSeriesPoint( - String metricName, boolean common, String namespace, int value, List tags) { - this.metricName = metricName; - this.common = common; - this.namespace = namespace; - this.value = value; - this.tags = tags; - } + Collection drain(); - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - DistributionSeriesPoint that = (DistributionSeriesPoint) o; - return common == that.common - && Objects.equals(metricName, that.metricName) - && Objects.equals(namespace, that.namespace) - && Objects.equals(tags, that.tags); + default Collection drainDistributionSeries() { + return Collections.emptySet(); } - @Override - public int hashCode() { - return HashingUtils.hash(metricName, common, namespace, tags); + class Metric { + public final String metricName; + public final boolean common; + public final String namespace; + public final String type; + public final long timestamp; + public final Number value; + public final List tags; + + public Metric( + String namespace, boolean common, String metricName, String type, Number value, final String tag) { + this(namespace, common, metricName, type, value, tag == null ? emptyList() : singletonList(tag)); + } + + public Metric( + String namespace, boolean common, String metricName, String type, Number value, final String... tags) { + this(namespace, common, metricName, type, value, Arrays.asList(tags)); + } + + public Metric( + String namespace, + boolean common, + String metricName, + String type, + Number value, + final List tags) { + this.namespace = namespace; + this.common = common; + this.metricName = metricName; + this.type = type; + this.timestamp = System.currentTimeMillis() / 1000; + this.value = value; + this.tags = tags; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (!(o instanceof Metric)) return false; + Metric metric = (Metric) o; + return common == metric.common + && Objects.equals(metricName, metric.metricName) + && Objects.equals(namespace, metric.namespace) + && Objects.equals(tags, metric.tags); + } + + @Override + public int hashCode() { + return HashingUtils.hash(metricName, common, namespace, tags); + } + + @Override + public String toString() { + return "Metric{" + + "metricName='" + + metricName + + '\'' + + ", common=" + + common + + ", namespace='" + + namespace + + '\'' + + ", type='" + + type + + '\'' + + ", timestamp=" + + timestamp + + ", value=" + + value + + ", tags=" + + tags + + '}'; + } } - @Override - public String toString() { - return "DistributionSeriesPoint{" - + "metricName='" - + metricName - + '\'' - + ", common=" - + common - + ", namespace='" - + namespace - + '\'' - + ", value=" - + value - + ", tags=" - + tags - + '}'; + class DistributionSeriesPoint { + public final String metricName; + public final boolean common; + public final String namespace; + public final int value; + public final List tags; + + public DistributionSeriesPoint( + String metricName, boolean common, String namespace, int value, List tags) { + this.metricName = metricName; + this.common = common; + this.namespace = namespace; + this.value = value; + this.tags = tags; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DistributionSeriesPoint that = (DistributionSeriesPoint) o; + return common == that.common + && Objects.equals(metricName, that.metricName) + && Objects.equals(namespace, that.namespace) + && Objects.equals(tags, that.tags); + } + + @Override + public int hashCode() { + return HashingUtils.hash(metricName, common, namespace, tags); + } + + @Override + public String toString() { + return "DistributionSeriesPoint{" + + "metricName='" + + metricName + + '\'' + + ", common=" + + common + + ", namespace='" + + namespace + + '\'' + + ", value=" + + value + + ", tags=" + + tags + + '}'; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorImpl.java b/internal-api/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorImpl.java index 5f01d884a65..61baa8e61a3 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorImpl.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorImpl.java @@ -10,80 +10,69 @@ import org.slf4j.LoggerFactory; public class OtelEnvMetricCollectorImpl - implements MetricCollector, OtelEnvMetricCollector { - private static final Logger log = LoggerFactory.getLogger(OtelEnvMetricCollectorImpl.class); - private static final String OTEL_ENV_HIDING_METRIC_NAME = "otel.env.hiding"; - private static final String OTEL_ENV_INVALID_METRIC_NAME = "otel.env.invalid"; - private static final String OTEL_ENV_UNSUPPORTED_METRIC_NAME = "otel.env.unsupported"; - private static final String CONFIG_OTEL_KEY_TAG = "config_opentelemetry:"; - private static final String CONFIG_DATADOG_KEY_TAG = "config_datadog:"; - private static final String NAMESPACE = "tracers"; - private static final OtelEnvMetricCollectorImpl INSTANCE = new OtelEnvMetricCollectorImpl(); + implements MetricCollector, OtelEnvMetricCollector { + private static final Logger log = LoggerFactory.getLogger(OtelEnvMetricCollectorImpl.class); + private static final String OTEL_ENV_HIDING_METRIC_NAME = "otel.env.hiding"; + private static final String OTEL_ENV_INVALID_METRIC_NAME = "otel.env.invalid"; + private static final String OTEL_ENV_UNSUPPORTED_METRIC_NAME = "otel.env.unsupported"; + private static final String CONFIG_OTEL_KEY_TAG = "config_opentelemetry:"; + private static final String CONFIG_DATADOG_KEY_TAG = "config_datadog:"; + private static final String NAMESPACE = "tracers"; + private static final OtelEnvMetricCollectorImpl INSTANCE = new OtelEnvMetricCollectorImpl(); - private final BlockingQueue metricsQueue; + private final BlockingQueue metricsQueue; - private OtelEnvMetricCollectorImpl() { - this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); - } + private OtelEnvMetricCollectorImpl() { + this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); + } - public static OtelEnvMetricCollectorImpl getInstance() { - return INSTANCE; - } + public static OtelEnvMetricCollectorImpl getInstance() { + return INSTANCE; + } - @Override - public void setHidingOtelEnvVarMetric(String otelName, String ddName) { - setMetricOtelEnvVarMetric( - OTEL_ENV_HIDING_METRIC_NAME, - CONFIG_OTEL_KEY_TAG + otelName, - CONFIG_DATADOG_KEY_TAG + ddName); - } + @Override + public void setHidingOtelEnvVarMetric(String otelName, String ddName) { + setMetricOtelEnvVarMetric( + OTEL_ENV_HIDING_METRIC_NAME, CONFIG_OTEL_KEY_TAG + otelName, CONFIG_DATADOG_KEY_TAG + ddName); + } - @Override - public void setInvalidOtelEnvVarMetric(String otelName, String ddName) { - setMetricOtelEnvVarMetric( - OTEL_ENV_INVALID_METRIC_NAME, - CONFIG_OTEL_KEY_TAG + otelName, - CONFIG_DATADOG_KEY_TAG + ddName); - } + @Override + public void setInvalidOtelEnvVarMetric(String otelName, String ddName) { + setMetricOtelEnvVarMetric( + OTEL_ENV_INVALID_METRIC_NAME, CONFIG_OTEL_KEY_TAG + otelName, CONFIG_DATADOG_KEY_TAG + ddName); + } - @Override - public void setUnsupportedOtelEnvVarMetric(String otelName) { - setMetricOtelEnvVarMetric(OTEL_ENV_UNSUPPORTED_METRIC_NAME, CONFIG_OTEL_KEY_TAG + otelName); - } + @Override + public void setUnsupportedOtelEnvVarMetric(String otelName) { + setMetricOtelEnvVarMetric(OTEL_ENV_UNSUPPORTED_METRIC_NAME, CONFIG_OTEL_KEY_TAG + otelName); + } - private void setMetricOtelEnvVarMetric(String metricName, final String... tags) { - if (!metricsQueue.offer( - new OtelEnvMetricCollectorImpl.OtelEnvMetric( - NAMESPACE, true, metricName, "count", 1, tags))) { - log.debug("Unable to add telemetry metric {} for {}", metricName, tags[0]); + private void setMetricOtelEnvVarMetric(String metricName, final String... tags) { + if (!metricsQueue.offer( + new OtelEnvMetricCollectorImpl.OtelEnvMetric(NAMESPACE, true, metricName, "count", 1, tags))) { + log.debug("Unable to add telemetry metric {} for {}", metricName, tags[0]); + } } - } - @Override - public void prepareMetrics() { - // Nothing to do here - } + @Override + public void prepareMetrics() { + // Nothing to do here + } - @Override - public Collection drain() { - if (this.metricsQueue.isEmpty()) { - return Collections.emptyList(); + @Override + public Collection drain() { + if (this.metricsQueue.isEmpty()) { + return Collections.emptyList(); + } + List drained = new ArrayList<>(this.metricsQueue.size()); + this.metricsQueue.drainTo(drained); + return drained; } - List drained = - new ArrayList<>(this.metricsQueue.size()); - this.metricsQueue.drainTo(drained); - return drained; - } - public static class OtelEnvMetric extends MetricCollector.Metric { - public OtelEnvMetric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - final String... tags) { - super(namespace, common, metricName, type, value, tags); + public static class OtelEnvMetric extends MetricCollector.Metric { + public OtelEnvMetric( + String namespace, boolean common, String metricName, String type, Number value, final String... tags) { + super(namespace, common, metricName, type, value, tags); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/OtelSpiCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/OtelSpiCollector.java index 375b17b6893..563e25524f5 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/OtelSpiCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/OtelSpiCollector.java @@ -11,65 +11,59 @@ /** Collects telemetry about OpenTelemetry SPIs detected in the customer environment. */ public class OtelSpiCollector implements MetricCollector { - private static final Logger log = LoggerFactory.getLogger(OtelSpiCollector.class); - private static final String OTEL_SPI_DETECTED_METRIC_NAME = "otel.spi.detected"; - private static final String SPI_CLASS_TAG = "spi_class:"; - private static final String SOURCE_TAG = "source:"; - private static final String NAMESPACE = "tracers"; - private static final OtelSpiCollector INSTANCE = new OtelSpiCollector(); + private static final Logger log = LoggerFactory.getLogger(OtelSpiCollector.class); + private static final String OTEL_SPI_DETECTED_METRIC_NAME = "otel.spi.detected"; + private static final String SPI_CLASS_TAG = "spi_class:"; + private static final String SOURCE_TAG = "source:"; + private static final String NAMESPACE = "tracers"; + private static final OtelSpiCollector INSTANCE = new OtelSpiCollector(); - private final BlockingQueue metricsQueue; + private final BlockingQueue metricsQueue; - private OtelSpiCollector() { - this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); - } + private OtelSpiCollector() { + this.metricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); + } - public static OtelSpiCollector getInstance() { - return INSTANCE; - } + public static OtelSpiCollector getInstance() { + return INSTANCE; + } - public void recordSpiDetected(String spiFqn, String source) { - if (!metricsQueue.offer( - new OtelSpiMetric( - NAMESPACE, - true, - OTEL_SPI_DETECTED_METRIC_NAME, - "count", - 1, - SPI_CLASS_TAG + spiFqn, - SOURCE_TAG + source))) { - log.debug( - "Unable to add telemetry metric {} for spi_class={} source={}", - OTEL_SPI_DETECTED_METRIC_NAME, - spiFqn, - source); + public void recordSpiDetected(String spiFqn, String source) { + if (!metricsQueue.offer(new OtelSpiMetric( + NAMESPACE, + true, + OTEL_SPI_DETECTED_METRIC_NAME, + "count", + 1, + SPI_CLASS_TAG + spiFqn, + SOURCE_TAG + source))) { + log.debug( + "Unable to add telemetry metric {} for spi_class={} source={}", + OTEL_SPI_DETECTED_METRIC_NAME, + spiFqn, + source); + } } - } - @Override - public void prepareMetrics() { - // Nothing to do here - } + @Override + public void prepareMetrics() { + // Nothing to do here + } - @Override - public Collection drain() { - if (this.metricsQueue.isEmpty()) { - return Collections.emptyList(); + @Override + public Collection drain() { + if (this.metricsQueue.isEmpty()) { + return Collections.emptyList(); + } + List drained = new ArrayList<>(this.metricsQueue.size()); + this.metricsQueue.drainTo(drained); + return drained; } - List drained = new ArrayList<>(this.metricsQueue.size()); - this.metricsQueue.drainTo(drained); - return drained; - } - public static class OtelSpiMetric extends MetricCollector.Metric { - public OtelSpiMetric( - String namespace, - boolean common, - String metricName, - String type, - Number value, - final String... tags) { - super(namespace, common, metricName, type, value, tags); + public static class OtelSpiMetric extends MetricCollector.Metric { + public OtelSpiMetric( + String namespace, boolean common, String metricName, String type, Number value, final String... tags) { + super(namespace, common, metricName, type, value, tags); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/OtlpTelemetry.java b/internal-api/src/main/java/datadog/trace/api/telemetry/OtlpTelemetry.java index a36bcff67c8..57e69d6ab4b 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/OtlpTelemetry.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/OtlpTelemetry.java @@ -12,112 +12,112 @@ /** Collects telemetry metrics for the OTLP trace, metrics, and log exporters. */ public class OtlpTelemetry implements MetricCollector { - private static final String NAMESPACE = "tracers"; + private static final String NAMESPACE = "tracers"; - private static final OtlpTelemetry INSTANCE = new OtlpTelemetry(); + private static final OtlpTelemetry INSTANCE = new OtlpTelemetry(); - public static OtlpTelemetry getInstance() { - return INSTANCE; - } - - private final String[] tracesTags = tagsFor(Config.get().getOtlpTracesProtocol()); - private final String[] metricsTags = tagsFor(Config.get().getOtlpMetricsProtocol()); - private final String[] logsTags = tagsFor(Config.get().getOtlpLogsProtocol()); - - private final ExportCounters tracesExport = new ExportCounters("traces"); - private final ExportCounters metricsExport = new ExportCounters("metrics"); - private final LongAdder logRecords = new LongAdder(); + public static OtlpTelemetry getInstance() { + return INSTANCE; + } - private final BlockingQueue telemetryQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); + private final String[] tracesTags = tagsFor(Config.get().getOtlpTracesProtocol()); + private final String[] metricsTags = tagsFor(Config.get().getOtlpMetricsProtocol()); + private final String[] logsTags = tagsFor(Config.get().getOtlpLogsProtocol()); - private OtlpTelemetry() {} + private final ExportCounters tracesExport = new ExportCounters("traces"); + private final ExportCounters metricsExport = new ExportCounters("metrics"); + private final LongAdder logRecords = new LongAdder(); - public void onTracesExportAttempt() { - tracesExport.attempts.increment(); - } + private final BlockingQueue telemetryQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); - public void onTracesExportComplete(boolean success) { - tracesExport.complete(success); - } + private OtlpTelemetry() {} - public void onMetricsExportAttempt() { - metricsExport.attempts.increment(); - } + public void onTracesExportAttempt() { + tracesExport.attempts.increment(); + } - public void onMetricsExportComplete(boolean success) { - metricsExport.complete(success); - } + public void onTracesExportComplete(boolean success) { + tracesExport.complete(success); + } - public void onLogRecordsSubmitted(long count) { - if (count > 0) { - logRecords.add(count); + public void onMetricsExportAttempt() { + metricsExport.attempts.increment(); } - } - - private static String[] tagsFor(OtlpConfig.Protocol protocol) { - String protocolTag = protocol == OtlpConfig.Protocol.GRPC ? "grpc" : "http"; - String encodingTag = protocol == OtlpConfig.Protocol.HTTP_JSON ? "json" : "protobuf"; - return new String[] {"protocol:" + protocolTag, "encoding:" + encodingTag}; - } - - @Override - public void prepareMetrics() { - tracesExport.stageInto(telemetryQueue, tracesTags); - metricsExport.stageInto(telemetryQueue, metricsTags); - long logRecordCount = logRecords.sumThenReset(); - if (logRecordCount > 0) { - telemetryQueue.offer(new OtlpMetric("otel.log_records", logRecordCount, logsTags)); + + public void onMetricsExportComplete(boolean success) { + metricsExport.complete(success); } - } - @Override - public Collection drain() { - if (telemetryQueue.isEmpty()) { - return Collections.emptyList(); + public void onLogRecordsSubmitted(long count) { + if (count > 0) { + logRecords.add(count); + } } - List drained = new ArrayList<>(telemetryQueue.size()); - telemetryQueue.drainTo(drained); - return drained; - } - - /** Counters for a single signal's export attempts/successes/failures. */ - private static final class ExportCounters { - final String attemptsMetric; - final String successesMetric; - final String failuresMetric; - - final LongAdder attempts = new LongAdder(); - final LongAdder successes = new LongAdder(); - final LongAdder failures = new LongAdder(); - - ExportCounters(String signal) { - this.attemptsMetric = "otel." + signal + "_export_attempts"; - this.successesMetric = "otel." + signal + "_export_successes"; - this.failuresMetric = "otel." + signal + "_export_failures"; + + private static String[] tagsFor(OtlpConfig.Protocol protocol) { + String protocolTag = protocol == OtlpConfig.Protocol.GRPC ? "grpc" : "http"; + String encodingTag = protocol == OtlpConfig.Protocol.HTTP_JSON ? "json" : "protobuf"; + return new String[] {"protocol:" + protocolTag, "encoding:" + encodingTag}; } - void complete(boolean success) { - (success ? successes : failures).increment(); + @Override + public void prepareMetrics() { + tracesExport.stageInto(telemetryQueue, tracesTags); + metricsExport.stageInto(telemetryQueue, metricsTags); + long logRecordCount = logRecords.sumThenReset(); + if (logRecordCount > 0) { + telemetryQueue.offer(new OtlpMetric("otel.log_records", logRecordCount, logsTags)); + } } - void stageInto(BlockingQueue out, String[] tags) { - addIfNonZero(out, attemptsMetric, attempts, tags); - addIfNonZero(out, successesMetric, successes, tags); - addIfNonZero(out, failuresMetric, failures, tags); + @Override + public Collection drain() { + if (telemetryQueue.isEmpty()) { + return Collections.emptyList(); + } + List drained = new ArrayList<>(telemetryQueue.size()); + telemetryQueue.drainTo(drained); + return drained; } - private static void addIfNonZero( - BlockingQueue out, String metricName, LongAdder counter, String[] tags) { - long value = counter.sumThenReset(); - if (value > 0) { - out.offer(new OtlpMetric(metricName, value, tags)); - } + /** Counters for a single signal's export attempts/successes/failures. */ + private static final class ExportCounters { + final String attemptsMetric; + final String successesMetric; + final String failuresMetric; + + final LongAdder attempts = new LongAdder(); + final LongAdder successes = new LongAdder(); + final LongAdder failures = new LongAdder(); + + ExportCounters(String signal) { + this.attemptsMetric = "otel." + signal + "_export_attempts"; + this.successesMetric = "otel." + signal + "_export_successes"; + this.failuresMetric = "otel." + signal + "_export_failures"; + } + + void complete(boolean success) { + (success ? successes : failures).increment(); + } + + void stageInto(BlockingQueue out, String[] tags) { + addIfNonZero(out, attemptsMetric, attempts, tags); + addIfNonZero(out, successesMetric, successes, tags); + addIfNonZero(out, failuresMetric, failures, tags); + } + + private static void addIfNonZero( + BlockingQueue out, String metricName, LongAdder counter, String[] tags) { + long value = counter.sumThenReset(); + if (value > 0) { + out.offer(new OtlpMetric(metricName, value, tags)); + } + } } - } - public static class OtlpMetric extends MetricCollector.Metric { - public OtlpMetric(String metricName, long value, String... tags) { - super(NAMESPACE, true, metricName, "count", value, tags); + public static class OtlpMetric extends MetricCollector.Metric { + public OtlpMetric(String metricName, long value, String... tags) { + super(NAMESPACE, true, metricName, "count", value, tags); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChange.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChange.java index 0ef85a8b3c1..70fe6acea97 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChange.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChange.java @@ -2,40 +2,40 @@ public class ProductChange { - private ProductType productType; - private boolean enabled; + private ProductType productType; + private boolean enabled; - public ProductType getProductType() { - return productType; - } + public ProductType getProductType() { + return productType; + } - public boolean isEnabled() { - return enabled; - } + public boolean isEnabled() { + return enabled; + } - public ProductChange productType(ProductType productType) { - this.productType = productType; - return this; - } + public ProductChange productType(ProductType productType) { + this.productType = productType; + return this; + } - public ProductChange enabled(boolean enabled) { - this.enabled = enabled; - return this; - } + public ProductChange enabled(boolean enabled) { + this.enabled = enabled; + return this; + } - public enum ProductType { - APPSEC("appsec"), - PROFILER("profiler"), - DYNAMIC_INSTRUMENTATION("dynamic_instrumentation"); + public enum ProductType { + APPSEC("appsec"), + PROFILER("profiler"), + DYNAMIC_INSTRUMENTATION("dynamic_instrumentation"); - private final String name; + private final String name; - ProductType(String name) { - this.name = name; - } + ProductType(String name) { + this.name = name; + } - public String getName() { - return name; + public String getName() { + return name; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChangeCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChangeCollector.java index a3e38d25723..b958e54db43 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChangeCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ProductChangeCollector.java @@ -9,39 +9,39 @@ public class ProductChangeCollector { - private static final ProductChangeCollector INSTANCE = new ProductChangeCollector(); - private final Queue productChanges = new LinkedBlockingQueue<>(); - - private ProductChangeCollector() {} - - public static ProductChangeCollector get() { - return INSTANCE; - } - - @SuppressFBWarnings( - value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", - justification = - "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") - public synchronized void update(final ProductChange productChange) { - productChanges.offer(productChange); - } - - @SuppressFBWarnings( - value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", - justification = - "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") - public synchronized List drain() { - if (productChanges.isEmpty()) { - return Collections.emptyList(); - } + private static final ProductChangeCollector INSTANCE = new ProductChangeCollector(); + private final Queue productChanges = new LinkedBlockingQueue<>(); + + private ProductChangeCollector() {} - List list = new LinkedList<>(); + public static ProductChangeCollector get() { + return INSTANCE; + } - ProductChange productChange; - while ((productChange = productChanges.poll()) != null) { - list.add(productChange); + @SuppressFBWarnings( + value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", + justification = + "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") + public synchronized void update(final ProductChange productChange) { + productChanges.offer(productChange); } - return list; - } + @SuppressFBWarnings( + value = "USO_UNSAFE_METHOD_SYNCHRONIZATION", + justification = + "All production callers are agent-owned and do not synchronize on this monitor; locking prevents updates from interleaving with draining.") + public synchronized List drain() { + if (productChanges.isEmpty()) { + return Collections.emptyList(); + } + + List list = new LinkedList<>(); + + ProductChange productChange; + while ((productChange = productChanges.poll()) != null) { + list.add(productChange); + } + + return list; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/RuleType.java b/internal-api/src/main/java/datadog/trace/api/telemetry/RuleType.java index 4ae4e7d4a9d..78aea87da18 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/RuleType.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/RuleType.java @@ -3,63 +3,66 @@ import javax.annotation.Nullable; public enum RuleType { - LFI(Type.LFI), - SQL_INJECTION(Type.SQL_INJECTION), - SSRF_REQUEST(Type.SSRF, Variant.REQUEST), - SSRF_RESPONSE(Type.SSRF, Variant.RESPONSE), - SHELL_INJECTION(Type.COMMAND_INJECTION, Variant.SHELL), - COMMAND_INJECTION(Type.COMMAND_INJECTION, Variant.EXEC); - - public final Type type; - @Nullable public final Variant variant; - private static final int numValues = RuleType.values().length; - - RuleType(Type type) { - this(type, null); - } - - RuleType(Type type, @Nullable Variant variant) { - this.type = type; - this.variant = variant; - } - - public static int getNumValues() { - return numValues; - } - - public enum Type { - LFI("lfi"), - SQL_INJECTION("sql_injection"), - SSRF("ssrf"), - COMMAND_INJECTION("command_injection"); - - public final String name; - - Type(String name) { - this.name = name; + LFI(Type.LFI), + SQL_INJECTION(Type.SQL_INJECTION), + SSRF_REQUEST(Type.SSRF, Variant.REQUEST), + SSRF_RESPONSE(Type.SSRF, Variant.RESPONSE), + SHELL_INJECTION(Type.COMMAND_INJECTION, Variant.SHELL), + COMMAND_INJECTION(Type.COMMAND_INJECTION, Variant.EXEC); + + public final Type type; + + @Nullable + public final Variant variant; + + private static final int numValues = RuleType.values().length; + + RuleType(Type type) { + this(type, null); + } + + RuleType(Type type, @Nullable Variant variant) { + this.type = type; + this.variant = variant; } - @Override - public String toString() { - return name; + public static int getNumValues() { + return numValues; } - } - public enum Variant { - SHELL("shell"), - EXEC("exec"), - REQUEST("request"), - RESPONSE("response"); + public enum Type { + LFI("lfi"), + SQL_INJECTION("sql_injection"), + SSRF("ssrf"), + COMMAND_INJECTION("command_injection"); + + public final String name; - public final String name; + Type(String name) { + this.name = name; + } - Variant(String name) { - this.name = name; + @Override + public String toString() { + return name; + } } - @Override - public String toString() { - return name; + public enum Variant { + SHELL("shell"), + EXEC("exec"), + REQUEST("request"), + RESPONSE("response"); + + public final String name; + + Variant(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java index 316e1bce922..cdba90f3887 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistry.java @@ -30,219 +30,217 @@ */ public final class ScaReachabilityDependencyRegistry { - private static final Logger log = - LoggerFactory.getLogger(ScaReachabilityDependencyRegistry.class); - - public static final ScaReachabilityDependencyRegistry INSTANCE = - new ScaReachabilityDependencyRegistry(); - - private final int maxTrackedDependencies = Config.get().getAppSecScaMaxTrackedDependencies(); - private final AtomicBoolean capWarningLogged = new AtomicBoolean(false); - - /** Keyed by {@link #depKey(String, String)}. */ - private final ConcurrentHashMap dependencies = new ConcurrentHashMap<>(); - - public static String depKey(String artifact, String version) { - return artifact + "@" + version; - } - - /** - * Optional periodic work hook for retransformation of pending method-level classes. Registered by - * {@code ScaReachabilitySystem}, called by {@code ScaReachabilityPeriodicAction}. - */ - private volatile Runnable periodicWorkCallback; - - public void setPeriodicWorkCallback(Runnable callback) { - periodicWorkCallback = callback; - } - - public Runnable getPeriodicWorkCallback() { - return periodicWorkCallback; - } - - /** Clears all state. Used in tests to reset between test cases. */ - @VisibleForTesting - public void resetForTesting() { - dependencies.clear(); - capWarningLogged.set(false); - periodicWorkCallback = null; - } - - private ScaReachabilityDependencyRegistry() {} - - /** - * Registers a CVE for a dependency when a class from a vulnerable version is loaded. Creates a - * new entry with {@code reached=[]} if not already present. Marks the dependency as pending so - * the next heartbeat reports it (signalling that SCA is monitoring this CVE). - * - *

Called by {@code ScaReachabilityTransformer} on class load (class-level symbols) and before - * bytecode injection (method-level symbols). - */ - public void registerCve(String artifact, String version, String vulnId) { - String key = depKey(artifact, version); - if (isCapExceeded(key)) return; - DependencyState dep = - dependencies.computeIfAbsent(key, k -> new DependencyState(artifact, version)); - dep.registerCve(vulnId); - } - - /** - * Records the first callsite that triggered a vulnerable method. Only the first hit per CVE is - * stored (RFC: "reporting a single occurrence is sufficient"). Marks the dependency as pending. - * - *

Called by {@code ScaReachabilitySystem} handler when injected bytecode fires. - */ - public void recordHit( - String artifact, - String version, - String vulnId, - String callsiteClass, - String callsiteSymbol, - int callsiteLine) { - String key = depKey(artifact, version); - if (isCapExceeded(key)) return; - dependencies - .computeIfAbsent(key, k -> new DependencyState(artifact, version)) - .recordHit(vulnId, callsiteClass, callsiteSymbol, callsiteLine); - } - - private boolean isCapExceeded(String key) { - if (!dependencies.containsKey(key) && dependencies.size() >= maxTrackedDependencies) { - if (capWarningLogged.compareAndSet(false, true)) { - log.warn( - "SCA Reachability: dependency tracking cap ({}) reached, further dependencies will not be tracked. Increase DD_APPSEC_SCA_MAX_TRACKED_DEPENDENCIES to raise the limit.", - maxTrackedDependencies); - } - return true; - } - return false; - } - - /** - * Returns the current CVE snapshot for a dependency without clearing the pending flag. Used by - * {@code ScaReachabilityPeriodicAction} when a newly-resolved JAR is not in {@code snapshotByKey} - * (i.e., the CVE was drained in a prior heartbeat but DependencyService resolved the JAR only - * now). Without this peek the emission would carry {@code metadata:[]} and overwrite the CVE - * state already reported. - * - *

Returns {@code null} if the dependency has no CVE state. - */ - public DependencySnapshot peekSnapshot(String artifact, String version) { - DependencyState dep = dependencies.get(depKey(artifact, version)); - if (dep == null || dep.cves.isEmpty()) { - return null; - } - List cveSnapshots = new ArrayList<>(dep.cves.size()); - for (Map.Entry entry : dep.cves.entrySet()) { - cveSnapshots.add(new CveSnapshot(entry.getKey(), entry.getValue().hitRef.get())); - } - return new DependencySnapshot(artifact, version, Collections.unmodifiableList(cveSnapshots)); - } - - /** - * Returns a snapshot of all dependencies that have pending changes since the last drain, then - * clears the pending flag. Called by {@code ScaReachabilityPeriodicAction} on each heartbeat. - * - *

Each returned {@link DependencySnapshot} contains ALL CVEs for that dependency (both with - * and without callsite hits), as required by the RFC stateful model. - */ - public List drainPendingDependencies() { - List result = new ArrayList<>(); - for (DependencyState dep : dependencies.values()) { - DependencySnapshot snapshot = dep.drainIfPending(); - if (snapshot != null) { - result.add(snapshot); - } + private static final Logger log = LoggerFactory.getLogger(ScaReachabilityDependencyRegistry.class); + + public static final ScaReachabilityDependencyRegistry INSTANCE = new ScaReachabilityDependencyRegistry(); + + private final int maxTrackedDependencies = Config.get().getAppSecScaMaxTrackedDependencies(); + private final AtomicBoolean capWarningLogged = new AtomicBoolean(false); + + /** Keyed by {@link #depKey(String, String)}. */ + private final ConcurrentHashMap dependencies = new ConcurrentHashMap<>(); + + public static String depKey(String artifact, String version) { + return artifact + "@" + version; } - return result; - } - // --------------------------------------------------------------------------- - // Internal state classes - // --------------------------------------------------------------------------- + /** + * Optional periodic work hook for retransformation of pending method-level classes. Registered by + * {@code ScaReachabilitySystem}, called by {@code ScaReachabilityPeriodicAction}. + */ + private volatile Runnable periodicWorkCallback; + + public void setPeriodicWorkCallback(Runnable callback) { + periodicWorkCallback = callback; + } - /** Mutable state for one (artifact, version) dependency. Thread-safe. */ - public static final class DependencyState { - public final String artifact; - public final String version; + public Runnable getPeriodicWorkCallback() { + return periodicWorkCallback; + } - /** CVE ID → first callsite hit, or {@code null} if not yet reached. */ - private final ConcurrentHashMap cves = new ConcurrentHashMap<>(); + /** Clears all state. Used in tests to reset between test cases. */ + @VisibleForTesting + public void resetForTesting() { + dependencies.clear(); + capWarningLogged.set(false); + periodicWorkCallback = null; + } - private volatile boolean pendingReport = false; + private ScaReachabilityDependencyRegistry() {} - DependencyState(String artifact, String version) { - this.artifact = artifact; - this.version = version; + /** + * Registers a CVE for a dependency when a class from a vulnerable version is loaded. Creates a + * new entry with {@code reached=[]} if not already present. Marks the dependency as pending so + * the next heartbeat reports it (signalling that SCA is monitoring this CVE). + * + *

Called by {@code ScaReachabilityTransformer} on class load (class-level symbols) and before + * bytecode injection (method-level symbols). + */ + public void registerCve(String artifact, String version, String vulnId) { + String key = depKey(artifact, version); + if (isCapExceeded(key)) return; + DependencyState dep = dependencies.computeIfAbsent(key, k -> new DependencyState(artifact, version)); + dep.registerCve(vulnId); } - void registerCve(String vulnId) { - cves.computeIfAbsent(vulnId, k -> new CveState()); - pendingReport = true; + /** + * Records the first callsite that triggered a vulnerable method. Only the first hit per CVE is + * stored (RFC: "reporting a single occurrence is sufficient"). Marks the dependency as pending. + * + *

Called by {@code ScaReachabilitySystem} handler when injected bytecode fires. + */ + public void recordHit( + String artifact, + String version, + String vulnId, + String callsiteClass, + String callsiteSymbol, + int callsiteLine) { + String key = depKey(artifact, version); + if (isCapExceeded(key)) return; + dependencies + .computeIfAbsent(key, k -> new DependencyState(artifact, version)) + .recordHit(vulnId, callsiteClass, callsiteSymbol, callsiteLine); } - void recordHit(String vulnId, String callsiteClass, String callsiteSymbol, int callsiteLine) { - CveState state = cves.computeIfAbsent(vulnId, k -> new CveState()); - // compareAndSet guarantees exactly one callsite is stored (first hit wins). - // A plain volatile check-then-assign would allow two threads racing on different - // methods of the same CVE to both see null and both write, violating the invariant. - ScaReachabilityHit newHit = - new ScaReachabilityHit( - vulnId, artifact, version, callsiteClass, callsiteSymbol, callsiteLine); - if (state.hitRef.compareAndSet(null, newHit)) { - pendingReport = true; - } + private boolean isCapExceeded(String key) { + if (!dependencies.containsKey(key) && dependencies.size() >= maxTrackedDependencies) { + if (capWarningLogged.compareAndSet(false, true)) { + log.warn( + "SCA Reachability: dependency tracking cap ({}) reached, further dependencies will not be tracked. Increase DD_APPSEC_SCA_MAX_TRACKED_DEPENDENCIES to raise the limit.", + maxTrackedDependencies); + } + return true; + } + return false; } /** - * Returns a snapshot if pending, then clears the pending flag. Returns null if nothing to - * report. + * Returns the current CVE snapshot for a dependency without clearing the pending flag. Used by + * {@code ScaReachabilityPeriodicAction} when a newly-resolved JAR is not in {@code snapshotByKey} + * (i.e., the CVE was drained in a prior heartbeat but DependencyService resolved the JAR only + * now). Without this peek the emission would carry {@code metadata:[]} and overwrite the CVE + * state already reported. + * + *

Returns {@code null} if the dependency has no CVE state. */ - DependencySnapshot drainIfPending() { - if (!pendingReport) { - return null; - } - pendingReport = false; - List cveSnapshots = new ArrayList<>(cves.size()); - for (Map.Entry entry : cves.entrySet()) { - cveSnapshots.add(new CveSnapshot(entry.getKey(), entry.getValue().hitRef.get())); - } - return new DependencySnapshot(artifact, version, Collections.unmodifiableList(cveSnapshots)); + public DependencySnapshot peekSnapshot(String artifact, String version) { + DependencyState dep = dependencies.get(depKey(artifact, version)); + if (dep == null || dep.cves.isEmpty()) { + return null; + } + List cveSnapshots = new ArrayList<>(dep.cves.size()); + for (Map.Entry entry : dep.cves.entrySet()) { + cveSnapshots.add( + new CveSnapshot(entry.getKey(), entry.getValue().hitRef.get())); + } + return new DependencySnapshot(artifact, version, Collections.unmodifiableList(cveSnapshots)); } - } - /** Mutable state for one CVE within a dependency. */ - static final class CveState { /** - * First callsite hit, or {@code null} if not yet reached. AtomicReference ensures compareAndSet - * atomicity so exactly one thread wins the "first hit" race. + * Returns a snapshot of all dependencies that have pending changes since the last drain, then + * clears the pending flag. Called by {@code ScaReachabilityPeriodicAction} on each heartbeat. + * + *

Each returned {@link DependencySnapshot} contains ALL CVEs for that dependency (both with + * and without callsite hits), as required by the RFC stateful model. */ - final AtomicReference hitRef = new AtomicReference<>(null); - } + public List drainPendingDependencies() { + List result = new ArrayList<>(); + for (DependencyState dep : dependencies.values()) { + DependencySnapshot snapshot = dep.drainIfPending(); + if (snapshot != null) { + result.add(snapshot); + } + } + return result; + } + + // --------------------------------------------------------------------------- + // Internal state classes + // --------------------------------------------------------------------------- + + /** Mutable state for one (artifact, version) dependency. Thread-safe. */ + public static final class DependencyState { + public final String artifact; + public final String version; + + /** CVE ID → first callsite hit, or {@code null} if not yet reached. */ + private final ConcurrentHashMap cves = new ConcurrentHashMap<>(); + + private volatile boolean pendingReport = false; + + DependencyState(String artifact, String version) { + this.artifact = artifact; + this.version = version; + } + + void registerCve(String vulnId) { + cves.computeIfAbsent(vulnId, k -> new CveState()); + pendingReport = true; + } + + void recordHit(String vulnId, String callsiteClass, String callsiteSymbol, int callsiteLine) { + CveState state = cves.computeIfAbsent(vulnId, k -> new CveState()); + // compareAndSet guarantees exactly one callsite is stored (first hit wins). + // A plain volatile check-then-assign would allow two threads racing on different + // methods of the same CVE to both see null and both write, violating the invariant. + ScaReachabilityHit newHit = + new ScaReachabilityHit(vulnId, artifact, version, callsiteClass, callsiteSymbol, callsiteLine); + if (state.hitRef.compareAndSet(null, newHit)) { + pendingReport = true; + } + } + + /** + * Returns a snapshot if pending, then clears the pending flag. Returns null if nothing to + * report. + */ + DependencySnapshot drainIfPending() { + if (!pendingReport) { + return null; + } + pendingReport = false; + List cveSnapshots = new ArrayList<>(cves.size()); + for (Map.Entry entry : cves.entrySet()) { + cveSnapshots.add( + new CveSnapshot(entry.getKey(), entry.getValue().hitRef.get())); + } + return new DependencySnapshot(artifact, version, Collections.unmodifiableList(cveSnapshots)); + } + } + + /** Mutable state for one CVE within a dependency. */ + static final class CveState { + /** + * First callsite hit, or {@code null} if not yet reached. AtomicReference ensures compareAndSet + * atomicity so exactly one thread wins the "first hit" race. + */ + final AtomicReference hitRef = new AtomicReference<>(null); + } - /** Immutable snapshot of a dependency's CVE state at drain time. */ - public static final class DependencySnapshot { - public final String artifact; - public final String version; + /** Immutable snapshot of a dependency's CVE state at drain time. */ + public static final class DependencySnapshot { + public final String artifact; + public final String version; - /** All CVEs for this dependency: hit==null means known but not reached yet. */ - public final List cves; + /** All CVEs for this dependency: hit==null means known but not reached yet. */ + public final List cves; - DependencySnapshot(String artifact, String version, List cves) { - this.artifact = artifact; - this.version = version; - this.cves = cves; + DependencySnapshot(String artifact, String version, List cves) { + this.artifact = artifact; + this.version = version; + this.cves = cves; + } } - } - /** Snapshot of one CVE: hit is null if not yet reached. */ - public static final class CveSnapshot { - public final String vulnId; - public final ScaReachabilityHit hit; // null = reached:[] + /** Snapshot of one CVE: hit is null if not yet reached. */ + public static final class CveSnapshot { + public final String vulnId; + public final ScaReachabilityHit hit; // null = reached:[] - public CveSnapshot(String vulnId, ScaReachabilityHit hit) { - this.vulnId = vulnId; - this.hit = hit; + public CveSnapshot(String vulnId, ScaReachabilityHit hit) { + this.vulnId = vulnId; + this.hit = hit; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityHit.java b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityHit.java index f0e1e5f477c..ec53bb6841e 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityHit.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/ScaReachabilityHit.java @@ -7,60 +7,55 @@ */ public final class ScaReachabilityHit { - private final String vulnId; - private final String artifact; - private final String version; - // FQN of the APPLICATION class that called the vulnerable method (callsite, dot notation) - private final String className; - // The APPLICATION method that called the vulnerable method (callsite) - private final String symbolName; - // Line number in the application code where the call was made - private final int line; - - public ScaReachabilityHit( - String vulnId, - String artifact, - String version, - String className, - String symbolName, - int line) { - this.vulnId = vulnId; - this.artifact = artifact; - this.version = version; - this.className = className; - this.symbolName = symbolName; - this.line = line; - } - - /** GHSA identifier, e.g. {@code "GHSA-645p-88qh-w398"}. */ - public String vulnId() { - return vulnId; - } - - /** Maven coordinate, e.g. {@code "com.fasterxml.jackson.core:jackson-databind"}. */ - public String artifact() { - return artifact; - } - - public String version() { - return version; - } - - /** - * FQN of the APPLICATION class that called the vulnerable method (callsite, dot notation). This - * is the caller frame, not the vulnerable class itself. - */ - public String className() { - return className; - } - - /** The APPLICATION method that called the vulnerable method (callsite). */ - public String symbolName() { - return symbolName; - } - - /** Line number in the application code where the call was made. */ - public int line() { - return line; - } + private final String vulnId; + private final String artifact; + private final String version; + // FQN of the APPLICATION class that called the vulnerable method (callsite, dot notation) + private final String className; + // The APPLICATION method that called the vulnerable method (callsite) + private final String symbolName; + // Line number in the application code where the call was made + private final int line; + + public ScaReachabilityHit( + String vulnId, String artifact, String version, String className, String symbolName, int line) { + this.vulnId = vulnId; + this.artifact = artifact; + this.version = version; + this.className = className; + this.symbolName = symbolName; + this.line = line; + } + + /** GHSA identifier, e.g. {@code "GHSA-645p-88qh-w398"}. */ + public String vulnId() { + return vulnId; + } + + /** Maven coordinate, e.g. {@code "com.fasterxml.jackson.core:jackson-databind"}. */ + public String artifact() { + return artifact; + } + + public String version() { + return version; + } + + /** + * FQN of the APPLICATION class that called the vulnerable method (callsite, dot notation). This + * is the caller frame, not the vulnerable class itself. + */ + public String className() { + return className; + } + + /** The APPLICATION method that called the vulnerable method (callsite). */ + public String symbolName() { + return symbolName; + } + + /** Line number in the application code where the call was made. */ + public int line() { + return line; + } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/WafMetricCollector.java b/internal-api/src/main/java/datadog/trace/api/telemetry/WafMetricCollector.java index 98797a1979a..79d50a55fbe 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/WafMetricCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/WafMetricCollector.java @@ -19,392 +19,142 @@ public class WafMetricCollector implements MetricCollector { - private static final int MASK_STRING_TOO_LONG = 1; // 0b001 - private static final int MASK_LIST_MAP_TOO_LARGE = 1 << 1; // 0b010 - private static final int MASK_OBJECT_TOO_DEEP = 1 << 2; // 0b100 - - public static WafMetricCollector INSTANCE = new WafMetricCollector(); - - public static WafMetricCollector get() { - return WafMetricCollector.INSTANCE; - } - - private WafMetricCollector() { - // Prevent external instantiation - } - - private static final String NAMESPACE = "appsec"; - - /** - * AI Guard metrics live in their own telemetry namespace. The namespace and the metric name are - * reported as separate fields and joined downstream, so {@code ai_guard} + {@code requests} is - * what surfaces the {@code ai_guard.requests} metric the AI Guard RFC specifies. - */ - private static final String AI_GUARD_NAMESPACE = "ai_guard"; - - /** Hoisted because {@link Enum#values()} clones its backing array on every call. */ - private static final AIGuardRedaction[] REDACTION_VALUES = AIGuardRedaction.values(); - - /** Hoisted because {@link Enum#values()} clones its backing array on every call. */ - private static final AIGuardError[] AI_GUARD_ERROR_VALUES = AIGuardError.values(); - - private static final BlockingQueue rawMetricsQueue = - new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); - - private static final int WAF_REQUEST_COMBINATIONS = 256; // 2^8 - private final AtomicLongArray wafRequestCounter = new AtomicLongArray(WAF_REQUEST_COMBINATIONS); - - private static final AtomicLongArray wafInputTruncatedCounter = - new AtomicLongArray(1 << 3); // 3 flags → 2^3 = 8 possible bit combinations - - private static final AtomicLongArray raspRuleEvalCounter = - new AtomicLongArray(RuleType.getNumValues()); - private static final AtomicLongArray raspRuleSkippedCounter = - new AtomicLongArray(RuleType.getNumValues()); - private static final AtomicLongArray raspRuleMatchCounter = - new AtomicLongArray(RuleType.getNumValues() * 2); - private static final AtomicLongArray raspTimeoutCounter = - new AtomicLongArray(RuleType.getNumValues()); - private static final AtomicLongArray raspErrorCodeCounter = - new AtomicLongArray(WafErrorCode.values().length * RuleType.getNumValues()); - private static final AtomicLongArray wafErrorCodeCounter = - new AtomicLongArray(WafErrorCode.values().length); - private static final AtomicLongArray missingUserLoginQueue = - new AtomicLongArray(LoginFramework.getNumValues() * LoginEvent.getNumValues()); - private static final AtomicLongArray missingUserIdQueue = - new AtomicLongArray(LoginFramework.getNumValues()); - private static final AtomicLongArray appSecSdkEventQueue = - new AtomicLongArray(LoginEvent.getNumValues() * LoginVersion.getNumValues()); - private static final AtomicInteger wafConfigErrorCounter = new AtomicInteger(); - private static final AtomicInteger contextClosedRaceCounter = new AtomicInteger(); - private static final AtomicLongArray aiGuardRequests = - new AtomicLongArray( - AIGuard.Action.values().length - * 2 - * REDACTION_VALUES.length); // actions * block * redaction state - private static final AtomicInteger aiGuardErrors = new AtomicInteger(); - private static final AtomicLongArray aiGuardErrorTypes = - new AtomicLongArray(AI_GUARD_ERROR_VALUES.length); - private static final AtomicLongArray aiGuardTruncated = - new AtomicLongArray(AIGuardTruncationType.values().length); - - /** - * Per-framework counters for requests where API Security could not resolve a route. Aggregated - * in-memory and drained on {@link #prepareMetrics()} instead of enqueueing on every request, - * since this call site has no sampling gate and could otherwise saturate {@link #rawMetricsQueue} - * under load (e.g. scanner traffic hitting unresolvable routes). - */ - private static final String UNKNOWN_FRAMEWORK = "unknown"; - - /** - * Cap on distinct framework keys tracked per counter map, {@link #UNKNOWN_FRAMEWORK} included. - * {@code framework} is read from the span's {@code component} tag, which is not guaranteed to - * come from a bounded, known set (e.g. custom/manual instrumentation could set it per-request) — - * without a cap the maps would never shrink, since {@link #prepareMetrics()} only resets counters - * to zero, it never removes keys. Frameworks beyond the cap are folded into {@link - * #UNKNOWN_FRAMEWORK}. Admission is synchronized per-map in {@link #counterFor} so the cap is - * enforced atomically instead of racing on {@code size()}. - */ - private static final int MAX_FRAMEWORK_CARDINALITY = 64; - - private static final ConcurrentHashMap apiSecurityMissingRouteCounters = - newFrameworkCounters(); - - /** - * Per-framework counters for sampled requests with/without an extracted API Security schema. - * Aggregated in-memory and drained on {@link #prepareMetrics()} for the same reason as {@link - * #apiSecurityMissingRouteCounters}: a burst of sampled requests across many distinct routes - * between telemetry heartbeats could otherwise saturate {@link #rawMetricsQueue} one request at a - * time. - */ - private static final ConcurrentHashMap apiSecurityRequestSchemaCounters = - newFrameworkCounters(); - - private static final ConcurrentHashMap apiSecurityRequestNoSchemaCounters = - newFrameworkCounters(); - - /** Reserves the {@link #UNKNOWN_FRAMEWORK} bucket within the cardinality cap up front. */ - private static ConcurrentHashMap newFrameworkCounters() { - final ConcurrentHashMap counters = new ConcurrentHashMap<>(); - counters.put(UNKNOWN_FRAMEWORK, new AtomicLong()); - return counters; - } - - /** WAF version that will be initialized with wafInit and reused for all metrics. */ - private static String wafVersion = ""; - - /** - * Rules version that will be updated on each wafInit and wafUpdates. This is not entirely - * accurate, since wafRequest metrics might be collected for a period where a rules update happens - * and some requests will be incorrectly reported with the old or new rules version. - */ - private static String rulesVersion = ""; - - public void wafInit(final String wafVersion, final String rulesVersion, final boolean success) { - WafMetricCollector.wafVersion = wafVersion; - WafMetricCollector.rulesVersion = rulesVersion; - rawMetricsQueue.offer(new WafInitRawMetric(1L, wafVersion, rulesVersion, success)); - } - - public void wafUpdates(final String rulesVersion, final boolean success) { - rawMetricsQueue.offer(new WafUpdatesRawMetric(1L, wafVersion, rulesVersion, success)); - - // Flush request metrics to get the new version. - if (rulesVersion != null - && WafMetricCollector.rulesVersion != null - && !rulesVersion.equals(WafMetricCollector.rulesVersion)) { - WafMetricCollector.get().prepareMetrics(); - } - WafMetricCollector.rulesVersion = rulesVersion; - } - - public void wafRequest( - final boolean ruleTriggered, - final boolean requestBlocked, - final boolean wafError, - final boolean wafTimeout, - final boolean blockFailure, - final boolean rateLimited, - final boolean inputTruncated, - final boolean requestExcluded) { - int index = - computeWafRequestIndex( - ruleTriggered, - requestBlocked, - wafError, - wafTimeout, - blockFailure, - rateLimited, - inputTruncated, - requestExcluded); - wafRequestCounter.incrementAndGet(index); - } - - public void wafInputTruncated( - final boolean stringTooLong, final boolean listMapTooLarge, final boolean objectTooDeep) { - int index = computeWafInputTruncatedIndex(stringTooLong, listMapTooLarge, objectTooDeep); - wafInputTruncatedCounter.incrementAndGet(index); - } - - static int computeWafRequestIndex( - boolean ruleTriggered, - boolean requestBlocked, - boolean wafError, - boolean wafTimeout, - boolean blockFailure, - boolean rateLimited, - boolean inputTruncated, - boolean requestExcluded) { - int index = 0; - if (ruleTriggered) index |= 1; - if (requestBlocked) index |= 1 << 1; - if (wafError) index |= 1 << 2; - if (wafTimeout) index |= 1 << 3; - if (blockFailure) index |= 1 << 4; - if (rateLimited) index |= 1 << 5; - if (inputTruncated) index |= 1 << 6; - if (requestExcluded) index |= 1 << 7; - return index; - } - - static int computeWafInputTruncatedIndex( - boolean stringTooLong, boolean listMapTooLarge, boolean objectTooDeep) { - int index = 0; - if (stringTooLong) index |= MASK_STRING_TOO_LONG; - if (listMapTooLarge) index |= MASK_LIST_MAP_TOO_LARGE; - if (objectTooDeep) index |= MASK_OBJECT_TOO_DEEP; - return index; - } - - public void raspRuleEval(final RuleType ruleType) { - raspRuleEvalCounter.incrementAndGet(ruleType.ordinal()); - } - - public void raspRuleSkipped(final RuleType ruleType) { - raspRuleSkippedCounter.incrementAndGet(ruleType.ordinal()); - } - - public void raspRuleMatch(final RuleType ruleType, final boolean blocked) { - raspRuleMatchCounter.incrementAndGet(ruleType.ordinal() * 2 + (blocked ? 1 : 0)); - } - - public void raspTimeout(final RuleType ruleType) { - raspTimeoutCounter.incrementAndGet(ruleType.ordinal()); - } - - public void raspErrorCode(RuleType ruleType, final int errorCode) { - WafErrorCode wafErrorCode = WafErrorCode.fromCode(errorCode); - // Unsupported waf error code - if (wafErrorCode == null) { - return; - } - int index = wafErrorCode.ordinal() * RuleType.getNumValues() + ruleType.ordinal(); - raspErrorCodeCounter.incrementAndGet(index); - } - - public void wafErrorCode(final int errorCode) { - WafErrorCode wafErrorCode = WafErrorCode.fromCode(errorCode); - // Unsupported waf error code - if (wafErrorCode == null) { - return; - } - wafErrorCodeCounter.incrementAndGet(wafErrorCode.ordinal()); - } - - public void missingUserLogin(final LoginFramework framework, final LoginEvent eventType) { - missingUserLoginQueue.incrementAndGet( - framework.ordinal() * LoginEvent.getNumValues() + eventType.ordinal()); - } - - public void missingUserId(final LoginFramework framework) { - missingUserIdQueue.incrementAndGet(framework.ordinal()); - } - - public void appSecSdkEvent(final LoginEvent event, final LoginVersion version) { - final int index = event.ordinal() * LoginVersion.getNumValues() + version.ordinal(); - appSecSdkEventQueue.incrementAndGet(index); - } - - public void aiGuardRequest( - final AIGuard.Action action, final boolean block, final AIGuardRedaction redaction) { - aiGuardRequests.incrementAndGet(aiGuardRequestIndex(action, block, redaction)); - } - - private static int aiGuardRequestIndex( - final AIGuard.Action action, final boolean block, final AIGuardRedaction redaction) { - return (action.ordinal() * 2 + (block ? 1 : 0)) * REDACTION_VALUES.length + redaction.ordinal(); - } - - /** - * Reports an evaluation that failed. The failure is counted twice on purpose, mirroring every - * other tracer: as {@code error:true} on {@code ai_guard.requests}, which keeps the request count - * complete, and under {@code ai_guard.error} with the {@code type} that classifies it. - */ - public void aiGuardError(final AIGuardError type) { - aiGuardErrors.incrementAndGet(); - aiGuardErrorTypes.incrementAndGet(type.ordinal()); - } - - /** - * Reports {@code count} replacements that redaction could not apply. Unlike {@link - * #aiGuardError(AIGuardError)} this does not count a failed request: redaction is best effort and - * never fails the evaluation it rode in on. - */ - public void aiGuardRedactionErrors(final long count) { - aiGuardErrorTypes.addAndGet(AIGuardError.REDACTION_ERROR.ordinal(), count); - } - - public void aiGuardTruncated(final AIGuardTruncationType type) { - aiGuardTruncated.incrementAndGet(type.ordinal()); - } - - /** - * Reports a request for which API Security could not resolve a route, and therefore could not be - * considered for schema extraction sampling. - */ - public void apiSecurityMissingRoute(final String framework) { - counterFor(apiSecurityMissingRouteCounters, framework).incrementAndGet(); - } - - /** Reports a sampled request for which at least one API Security schema was extracted. */ - public void apiSecurityRequestSchema(final String framework) { - counterFor(apiSecurityRequestSchemaCounters, framework).incrementAndGet(); - } - - /** Reports a sampled request for which no API Security schema was extracted. */ - public void apiSecurityRequestNoSchema(final String framework) { - counterFor(apiSecurityRequestNoSchemaCounters, framework).incrementAndGet(); - } - - /** - * Normalizes a framework (span component) value, mapping null or blank values to "unknown" and - * sanitizing the rest via {@link TagsHelper#sanitize}, which also bounds its length. {@code - * framework} can originate from manual/custom instrumentation setting the {@code component} tag - * per request, so without this the retained-but-cardinality-capped keys could still consume - * unbounded heap in bytes even though their count is bounded. - */ - static String normalizeFramework(final String framework) { - if (framework == null || framework.trim().isEmpty()) { - return UNKNOWN_FRAMEWORK; - } - return TagsHelper.sanitize(framework.trim()); - } - - /** - * Returns the counter for {@code framework} in {@code counters}, capping the map at {@link - * #MAX_FRAMEWORK_CARDINALITY} distinct keys ({@link #UNKNOWN_FRAMEWORK} pre-reserved by {@link - * #newFrameworkCounters()}). Once the cap is reached, any framework not already tracked is folded - * into {@link #UNKNOWN_FRAMEWORK} instead of growing the map further. The fast path for an - * already-tracked key is lock-free; admission of a never-seen key is synchronized on {@code - * counters} so the size check and the insertion happen atomically, closing the race where - * concurrent first-seen frameworks could otherwise all pass the check and overshoot the cap. - * - *

Before normalizing, we first probe {@code counters} with the raw, unsanitized {@code - * framework} value. Well-known frameworks (e.g. "netty") are already in sanitized form, so once - * admitted, this raw lookup hits directly and skips {@link #normalizeFramework}'s allocation (via - * {@link TagsHelper#sanitize}) on every subsequent call for that framework. - */ - @SuppressFBWarnings("JLM_JSR166_UTILCONCURRENT_MONITORENTER") - private static AtomicLong counterFor( - final ConcurrentHashMap counters, final String framework) { - if (framework != null) { - final AtomicLong rawHit = counters.get(framework); - if (rawHit != null) { - return rawHit; - } + private static final int MASK_STRING_TOO_LONG = 1; // 0b001 + private static final int MASK_LIST_MAP_TOO_LARGE = 1 << 1; // 0b010 + private static final int MASK_OBJECT_TOO_DEEP = 1 << 2; // 0b100 + + public static WafMetricCollector INSTANCE = new WafMetricCollector(); + + public static WafMetricCollector get() { + return WafMetricCollector.INSTANCE; } - final String key = normalizeFramework(framework); - final AtomicLong existing = counters.get(key); - if (existing != null) { - return existing; + + private WafMetricCollector() { + // Prevent external instantiation } - synchronized (counters) { - final AtomicLong existingSync = counters.get(key); - if (existingSync != null) { - return existingSync; - } - if (counters.size() >= MAX_FRAMEWORK_CARDINALITY) { - return counters.get(UNKNOWN_FRAMEWORK); - } - final AtomicLong created = new AtomicLong(); - counters.put(key, created); - return created; + + private static final String NAMESPACE = "appsec"; + + /** + * AI Guard metrics live in their own telemetry namespace. The namespace and the metric name are + * reported as separate fields and joined downstream, so {@code ai_guard} + {@code requests} is + * what surfaces the {@code ai_guard.requests} metric the AI Guard RFC specifies. + */ + private static final String AI_GUARD_NAMESPACE = "ai_guard"; + + /** Hoisted because {@link Enum#values()} clones its backing array on every call. */ + private static final AIGuardRedaction[] REDACTION_VALUES = AIGuardRedaction.values(); + + /** Hoisted because {@link Enum#values()} clones its backing array on every call. */ + private static final AIGuardError[] AI_GUARD_ERROR_VALUES = AIGuardError.values(); + + private static final BlockingQueue rawMetricsQueue = new ArrayBlockingQueue<>(RAW_QUEUE_SIZE); + + private static final int WAF_REQUEST_COMBINATIONS = 256; // 2^8 + private final AtomicLongArray wafRequestCounter = new AtomicLongArray(WAF_REQUEST_COMBINATIONS); + + private static final AtomicLongArray wafInputTruncatedCounter = + new AtomicLongArray(1 << 3); // 3 flags → 2^3 = 8 possible bit combinations + + private static final AtomicLongArray raspRuleEvalCounter = new AtomicLongArray(RuleType.getNumValues()); + private static final AtomicLongArray raspRuleSkippedCounter = new AtomicLongArray(RuleType.getNumValues()); + private static final AtomicLongArray raspRuleMatchCounter = new AtomicLongArray(RuleType.getNumValues() * 2); + private static final AtomicLongArray raspTimeoutCounter = new AtomicLongArray(RuleType.getNumValues()); + private static final AtomicLongArray raspErrorCodeCounter = + new AtomicLongArray(WafErrorCode.values().length * RuleType.getNumValues()); + private static final AtomicLongArray wafErrorCodeCounter = new AtomicLongArray(WafErrorCode.values().length); + private static final AtomicLongArray missingUserLoginQueue = + new AtomicLongArray(LoginFramework.getNumValues() * LoginEvent.getNumValues()); + private static final AtomicLongArray missingUserIdQueue = new AtomicLongArray(LoginFramework.getNumValues()); + private static final AtomicLongArray appSecSdkEventQueue = + new AtomicLongArray(LoginEvent.getNumValues() * LoginVersion.getNumValues()); + private static final AtomicInteger wafConfigErrorCounter = new AtomicInteger(); + private static final AtomicInteger contextClosedRaceCounter = new AtomicInteger(); + private static final AtomicLongArray aiGuardRequests = new AtomicLongArray( + AIGuard.Action.values().length * 2 * REDACTION_VALUES.length); // actions * block * redaction state + private static final AtomicInteger aiGuardErrors = new AtomicInteger(); + private static final AtomicLongArray aiGuardErrorTypes = new AtomicLongArray(AI_GUARD_ERROR_VALUES.length); + private static final AtomicLongArray aiGuardTruncated = new AtomicLongArray(AIGuardTruncationType.values().length); + + /** + * Per-framework counters for requests where API Security could not resolve a route. Aggregated + * in-memory and drained on {@link #prepareMetrics()} instead of enqueueing on every request, + * since this call site has no sampling gate and could otherwise saturate {@link #rawMetricsQueue} + * under load (e.g. scanner traffic hitting unresolvable routes). + */ + private static final String UNKNOWN_FRAMEWORK = "unknown"; + + /** + * Cap on distinct framework keys tracked per counter map, {@link #UNKNOWN_FRAMEWORK} included. + * {@code framework} is read from the span's {@code component} tag, which is not guaranteed to + * come from a bounded, known set (e.g. custom/manual instrumentation could set it per-request) — + * without a cap the maps would never shrink, since {@link #prepareMetrics()} only resets counters + * to zero, it never removes keys. Frameworks beyond the cap are folded into {@link + * #UNKNOWN_FRAMEWORK}. Admission is synchronized per-map in {@link #counterFor} so the cap is + * enforced atomically instead of racing on {@code size()}. + */ + private static final int MAX_FRAMEWORK_CARDINALITY = 64; + + private static final ConcurrentHashMap apiSecurityMissingRouteCounters = newFrameworkCounters(); + + /** + * Per-framework counters for sampled requests with/without an extracted API Security schema. + * Aggregated in-memory and drained on {@link #prepareMetrics()} for the same reason as {@link + * #apiSecurityMissingRouteCounters}: a burst of sampled requests across many distinct routes + * between telemetry heartbeats could otherwise saturate {@link #rawMetricsQueue} one request at a + * time. + */ + private static final ConcurrentHashMap apiSecurityRequestSchemaCounters = + newFrameworkCounters(); + + private static final ConcurrentHashMap apiSecurityRequestNoSchemaCounters = + newFrameworkCounters(); + + /** Reserves the {@link #UNKNOWN_FRAMEWORK} bucket within the cardinality cap up front. */ + private static ConcurrentHashMap newFrameworkCounters() { + final ConcurrentHashMap counters = new ConcurrentHashMap<>(); + counters.put(UNKNOWN_FRAMEWORK, new AtomicLong()); + return counters; } - } - - @Override - public Collection drain() { - if (!rawMetricsQueue.isEmpty()) { - List list = new LinkedList<>(); - int drained = rawMetricsQueue.drainTo(list); - if (drained > 0) { - return list; - } + + /** WAF version that will be initialized with wafInit and reused for all metrics. */ + private static String wafVersion = ""; + + /** + * Rules version that will be updated on each wafInit and wafUpdates. This is not entirely + * accurate, since wafRequest metrics might be collected for a period where a rules update happens + * and some requests will be incorrectly reported with the old or new rules version. + */ + private static String rulesVersion = ""; + + public void wafInit(final String wafVersion, final String rulesVersion, final boolean success) { + WafMetricCollector.wafVersion = wafVersion; + WafMetricCollector.rulesVersion = rulesVersion; + rawMetricsQueue.offer(new WafInitRawMetric(1L, wafVersion, rulesVersion, success)); } - return Collections.emptyList(); - } - - @Override - public void prepareMetrics() { - - // Requests - for (int i = 0; i < WAF_REQUEST_COMBINATIONS; i++) { - long counter = wafRequestCounter.getAndSet(i, 0); - if (counter > 0) { - boolean ruleTriggered = (i & 1) != 0; - boolean requestBlocked = (i & (1 << 1)) != 0; - boolean wafError = (i & (1 << 2)) != 0; - boolean wafTimeout = (i & (1 << 3)) != 0; - boolean blockFailure = (i & (1 << 4)) != 0; - boolean rateLimited = (i & (1 << 5)) != 0; - boolean inputTruncated = (i & (1 << 6)) != 0; - boolean requestExcluded = (i & (1 << 7)) != 0; - - if (!rawMetricsQueue.offer( - new WafRequestsRawMetric( - counter, - WafMetricCollector.wafVersion, - WafMetricCollector.rulesVersion, + + public void wafUpdates(final String rulesVersion, final boolean success) { + rawMetricsQueue.offer(new WafUpdatesRawMetric(1L, wafVersion, rulesVersion, success)); + + // Flush request metrics to get the new version. + if (rulesVersion != null + && WafMetricCollector.rulesVersion != null + && !rulesVersion.equals(WafMetricCollector.rulesVersion)) { + WafMetricCollector.get().prepareMetrics(); + } + WafMetricCollector.rulesVersion = rulesVersion; + } + + public void wafRequest( + final boolean ruleTriggered, + final boolean requestBlocked, + final boolean wafError, + final boolean wafTimeout, + final boolean blockFailure, + final boolean rateLimited, + final boolean inputTruncated, + final boolean requestExcluded) { + int index = computeWafRequestIndex( ruleTriggered, requestBlocked, wafError, @@ -412,642 +162,840 @@ public void prepareMetrics() { blockFailure, rateLimited, inputTruncated, - requestExcluded))) { - return; - } - } + requestExcluded); + wafRequestCounter.incrementAndGet(index); } - // WAF input truncated - for (int i = 0; i < (1 << 3); i++) { - long counter = wafInputTruncatedCounter.getAndSet(i, 0); - if (counter > 0) { - if (!rawMetricsQueue.offer(new WafInputTruncated(counter, i))) { - return; - } - } + public void wafInputTruncated( + final boolean stringTooLong, final boolean listMapTooLarge, final boolean objectTooDeep) { + int index = computeWafInputTruncatedIndex(stringTooLong, listMapTooLarge, objectTooDeep); + wafInputTruncatedCounter.incrementAndGet(index); } - // RASP rule eval per rule type - for (RuleType ruleType : RuleType.values()) { - long counter = raspRuleEvalCounter.getAndSet(ruleType.ordinal(), 0); - if (counter > 0) { - if (!rawMetricsQueue.offer( - new RaspRuleEval(counter, ruleType, WafMetricCollector.wafVersion))) { - return; - } - } + static int computeWafRequestIndex( + boolean ruleTriggered, + boolean requestBlocked, + boolean wafError, + boolean wafTimeout, + boolean blockFailure, + boolean rateLimited, + boolean inputTruncated, + boolean requestExcluded) { + int index = 0; + if (ruleTriggered) index |= 1; + if (requestBlocked) index |= 1 << 1; + if (wafError) index |= 1 << 2; + if (wafTimeout) index |= 1 << 3; + if (blockFailure) index |= 1 << 4; + if (rateLimited) index |= 1 << 5; + if (inputTruncated) index |= 1 << 6; + if (requestExcluded) index |= 1 << 7; + return index; } - // RASP rule match per rule type: two slots per RuleType: ordinal*2 (non-blocked), - // ordinal*2+1 (blocked) - for (RuleType ruleType : RuleType.values()) { - long blockedCount = raspRuleMatchCounter.getAndSet(ruleType.ordinal() * 2 + 1, 0); - if (blockedCount > 0) { - if (!rawMetricsQueue.offer( - new RaspRuleMatch(blockedCount, ruleType, WafMetricCollector.wafVersion, true))) { - return; - } - } - long nonBlockedCount = raspRuleMatchCounter.getAndSet(ruleType.ordinal() * 2, 0); - if (nonBlockedCount > 0) { - if (!rawMetricsQueue.offer( - new RaspRuleMatch(nonBlockedCount, ruleType, WafMetricCollector.wafVersion, false))) { - return; - } - } + static int computeWafInputTruncatedIndex(boolean stringTooLong, boolean listMapTooLarge, boolean objectTooDeep) { + int index = 0; + if (stringTooLong) index |= MASK_STRING_TOO_LONG; + if (listMapTooLarge) index |= MASK_LIST_MAP_TOO_LARGE; + if (objectTooDeep) index |= MASK_OBJECT_TOO_DEEP; + return index; } - // RASP timeout per rule type - for (RuleType ruleType : RuleType.values()) { - long counter = raspTimeoutCounter.getAndSet(ruleType.ordinal(), 0); - if (counter > 0) { - if (!rawMetricsQueue.offer( - new RaspTimeout(counter, ruleType, WafMetricCollector.wafVersion))) { - return; - } - } + public void raspRuleEval(final RuleType ruleType) { + raspRuleEvalCounter.incrementAndGet(ruleType.ordinal()); + } + + public void raspRuleSkipped(final RuleType ruleType) { + raspRuleSkippedCounter.incrementAndGet(ruleType.ordinal()); } - // RASP rule type for each possible error code - for (WafErrorCode errorCode : WafErrorCode.values()) { - for (RuleType ruleType : RuleType.values()) { - int index = errorCode.ordinal() * RuleType.getNumValues() + ruleType.ordinal(); - long count = raspErrorCodeCounter.getAndSet(index, 0); - if (count > 0) { - if (!rawMetricsQueue.offer( - new RaspError(count, ruleType, WafMetricCollector.wafVersion, errorCode.getCode()))) { + public void raspRuleMatch(final RuleType ruleType, final boolean blocked) { + raspRuleMatchCounter.incrementAndGet(ruleType.ordinal() * 2 + (blocked ? 1 : 0)); + } + + public void raspTimeout(final RuleType ruleType) { + raspTimeoutCounter.incrementAndGet(ruleType.ordinal()); + } + + public void raspErrorCode(RuleType ruleType, final int errorCode) { + WafErrorCode wafErrorCode = WafErrorCode.fromCode(errorCode); + // Unsupported waf error code + if (wafErrorCode == null) { return; - } } - } + int index = wafErrorCode.ordinal() * RuleType.getNumValues() + ruleType.ordinal(); + raspErrorCodeCounter.incrementAndGet(index); } - // Missing user login - for (LoginFramework framework : LoginFramework.values()) { - for (LoginEvent event : LoginEvent.values()) { - final int ordinal = framework.ordinal() * LoginEvent.getNumValues() + event.ordinal(); - long counter = missingUserLoginQueue.getAndSet(ordinal, 0); - if (counter > 0) { - if (!rawMetricsQueue.offer( - new MissingUserLoginMetric(counter, framework.getTag(), event.getTag()))) { + public void wafErrorCode(final int errorCode) { + WafErrorCode wafErrorCode = WafErrorCode.fromCode(errorCode); + // Unsupported waf error code + if (wafErrorCode == null) { return; - } } - } + wafErrorCodeCounter.incrementAndGet(wafErrorCode.ordinal()); } - // Missing user id - for (LoginFramework framework : LoginFramework.values()) { - long counter = missingUserIdQueue.getAndSet(framework.ordinal(), 0); - if (counter > 0) { - if (!rawMetricsQueue.offer(new MissingUserIdMetric(counter, framework.getTag()))) { - return; - } - } + public void missingUserLogin(final LoginFramework framework, final LoginEvent eventType) { + missingUserLoginQueue.incrementAndGet(framework.ordinal() * LoginEvent.getNumValues() + eventType.ordinal()); } - // ATO login events - for (LoginEvent event : LoginEvent.values()) { - for (LoginVersion version : LoginVersion.values()) { - final int ordinal = event.ordinal() * LoginVersion.getNumValues() + version.ordinal(); - long counter = appSecSdkEventQueue.getAndSet(ordinal, 0); - if (counter > 0) { - if (!rawMetricsQueue.offer( - new AppSecSdkEvent(counter, event.getTag(), version.getTag()))) { - return; - } - } - } + public void missingUserId(final LoginFramework framework) { + missingUserIdQueue.incrementAndGet(framework.ordinal()); } - // WAF rule type for each possible error code - for (WafErrorCode errorCode : WafErrorCode.values()) { - long count = wafErrorCodeCounter.getAndSet(errorCode.ordinal(), 0); - if (count > 0) { - if (!rawMetricsQueue.offer( - new WafError(count, WafMetricCollector.wafVersion, errorCode.getCode()))) { - return; - } - } + public void appSecSdkEvent(final LoginEvent event, final LoginVersion version) { + final int index = event.ordinal() * LoginVersion.getNumValues() + version.ordinal(); + appSecSdkEventQueue.incrementAndGet(index); } - // RASP rule skipped per rule type for after-request reason - for (RuleType ruleType : RuleType.values()) { - long counter = raspRuleSkippedCounter.getAndSet(ruleType.ordinal(), 0); - if (counter > 0) { - if (!rawMetricsQueue.offer(new AfterRequestRaspRuleSkipped(counter, ruleType))) { - return; - } - } + public void aiGuardRequest(final AIGuard.Action action, final boolean block, final AIGuardRedaction redaction) { + aiGuardRequests.incrementAndGet(aiGuardRequestIndex(action, block, redaction)); } - // WAF config errors - int configErrors = wafConfigErrorCounter.getAndSet(0); - if (configErrors > 0) { - if (!rawMetricsQueue.offer( - new WafConfigError( - configErrors, WafMetricCollector.wafVersion, WafMetricCollector.rulesVersion))) { - return; - } + private static int aiGuardRequestIndex( + final AIGuard.Action action, final boolean block, final AIGuardRedaction redaction) { + return (action.ordinal() * 2 + (block ? 1 : 0)) * REDACTION_VALUES.length + redaction.ordinal(); } - // WafContext closed-concurrently race (APPSEC-69085) - int contextClosedRace = contextClosedRaceCounter.getAndSet(0); - if (contextClosedRace > 0) { - if (!rawMetricsQueue.offer(new ContextClosedRace(contextClosedRace))) { - return; - } + /** + * Reports an evaluation that failed. The failure is counted twice on purpose, mirroring every + * other tracer: as {@code error:true} on {@code ai_guard.requests}, which keeps the request count + * complete, and under {@code ai_guard.error} with the {@code type} that classifies it. + */ + public void aiGuardError(final AIGuardError type) { + aiGuardErrors.incrementAndGet(); + aiGuardErrorTypes.incrementAndGet(type.ordinal()); } - // AI Guard successful requests - aiGuardSuccesses: - for (final AIGuard.Action action : AIGuard.Action.values()) { - for (int blockFlag = 1; blockFlag >= 0; blockFlag--) { - final boolean block = blockFlag == 1; - for (final AIGuardRedaction redaction : REDACTION_VALUES) { - final long count = - aiGuardRequests.getAndSet(aiGuardRequestIndex(action, block, redaction), 0); - if (count > 0) { - if (!rawMetricsQueue.offer(AIGuardRequests.success(count, action, block, redaction))) { - break aiGuardSuccesses; - } - } - } - } + /** + * Reports {@code count} replacements that redaction could not apply. Unlike {@link + * #aiGuardError(AIGuardError)} this does not count a failed request: redaction is best effort and + * never fails the evaluation it rode in on. + */ + public void aiGuardRedactionErrors(final long count) { + aiGuardErrorTypes.addAndGet(AIGuardError.REDACTION_ERROR.ordinal(), count); } - // AI Guard failed requests - final int aiGuardErrorRequests = aiGuardErrors.getAndSet(0); - if (aiGuardErrorRequests > 0) { - if (!rawMetricsQueue.offer(AIGuardRequests.error(aiGuardErrorRequests))) { - return; - } + public void aiGuardTruncated(final AIGuardTruncationType type) { + aiGuardTruncated.incrementAndGet(type.ordinal()); } - // AI Guard errors, per type - for (final AIGuardError type : AI_GUARD_ERROR_VALUES) { - final long count = aiGuardErrorTypes.getAndSet(type.ordinal(), 0); - if (count > 0) { - if (!rawMetricsQueue.offer(new AIGuardErrors(count, type))) { - return; - } - } + /** + * Reports a request for which API Security could not resolve a route, and therefore could not be + * considered for schema extraction sampling. + */ + public void apiSecurityMissingRoute(final String framework) { + counterFor(apiSecurityMissingRouteCounters, framework).incrementAndGet(); } - // AI Guard truncated messages - for (final AIGuardTruncationType type : AIGuardTruncationType.values()) { - final long count = aiGuardTruncated.getAndSet(type.ordinal(), 0); - if (count > 0) { - if (!rawMetricsQueue.offer(new AIGuardTruncated(count, type))) { - return; - } - } + /** Reports a sampled request for which at least one API Security schema was extracted. */ + public void apiSecurityRequestSchema(final String framework) { + counterFor(apiSecurityRequestSchemaCounters, framework).incrementAndGet(); } - // API Security missing route, per framework - for (final Map.Entry entry : apiSecurityMissingRouteCounters.entrySet()) { - final long count = entry.getValue().getAndSet(0); - if (count > 0) { - if (!rawMetricsQueue.offer(new ApiSecurityMissingRoute(count, entry.getKey()))) { - return; + /** Reports a sampled request for which no API Security schema was extracted. */ + public void apiSecurityRequestNoSchema(final String framework) { + counterFor(apiSecurityRequestNoSchemaCounters, framework).incrementAndGet(); + } + + /** + * Normalizes a framework (span component) value, mapping null or blank values to "unknown" and + * sanitizing the rest via {@link TagsHelper#sanitize}, which also bounds its length. {@code + * framework} can originate from manual/custom instrumentation setting the {@code component} tag + * per request, so without this the retained-but-cardinality-capped keys could still consume + * unbounded heap in bytes even though their count is bounded. + */ + static String normalizeFramework(final String framework) { + if (framework == null || framework.trim().isEmpty()) { + return UNKNOWN_FRAMEWORK; } - } + return TagsHelper.sanitize(framework.trim()); } - // API Security request schema, per framework - for (final Map.Entry entry : apiSecurityRequestSchemaCounters.entrySet()) { - final long count = entry.getValue().getAndSet(0); - if (count > 0) { - if (!rawMetricsQueue.offer(new ApiSecurityRequestSchema(count, entry.getKey()))) { - return; + /** + * Returns the counter for {@code framework} in {@code counters}, capping the map at {@link + * #MAX_FRAMEWORK_CARDINALITY} distinct keys ({@link #UNKNOWN_FRAMEWORK} pre-reserved by {@link + * #newFrameworkCounters()}). Once the cap is reached, any framework not already tracked is folded + * into {@link #UNKNOWN_FRAMEWORK} instead of growing the map further. The fast path for an + * already-tracked key is lock-free; admission of a never-seen key is synchronized on {@code + * counters} so the size check and the insertion happen atomically, closing the race where + * concurrent first-seen frameworks could otherwise all pass the check and overshoot the cap. + * + *

Before normalizing, we first probe {@code counters} with the raw, unsanitized {@code + * framework} value. Well-known frameworks (e.g. "netty") are already in sanitized form, so once + * admitted, this raw lookup hits directly and skips {@link #normalizeFramework}'s allocation (via + * {@link TagsHelper#sanitize}) on every subsequent call for that framework. + */ + @SuppressFBWarnings("JLM_JSR166_UTILCONCURRENT_MONITORENTER") + private static AtomicLong counterFor(final ConcurrentHashMap counters, final String framework) { + if (framework != null) { + final AtomicLong rawHit = counters.get(framework); + if (rawHit != null) { + return rawHit; + } + } + final String key = normalizeFramework(framework); + final AtomicLong existing = counters.get(key); + if (existing != null) { + return existing; + } + synchronized (counters) { + final AtomicLong existingSync = counters.get(key); + if (existingSync != null) { + return existingSync; + } + if (counters.size() >= MAX_FRAMEWORK_CARDINALITY) { + return counters.get(UNKNOWN_FRAMEWORK); + } + final AtomicLong created = new AtomicLong(); + counters.put(key, created); + return created; } - } } - // API Security request no schema, per framework - for (final Map.Entry entry : - apiSecurityRequestNoSchemaCounters.entrySet()) { - final long count = entry.getValue().getAndSet(0); - if (count > 0) { - if (!rawMetricsQueue.offer(new ApiSecurityRequestNoSchema(count, entry.getKey()))) { - return; + @Override + public Collection drain() { + if (!rawMetricsQueue.isEmpty()) { + List list = new LinkedList<>(); + int drained = rawMetricsQueue.drainTo(list); + if (drained > 0) { + return list; + } + } + return Collections.emptyList(); + } + + @Override + public void prepareMetrics() { + + // Requests + for (int i = 0; i < WAF_REQUEST_COMBINATIONS; i++) { + long counter = wafRequestCounter.getAndSet(i, 0); + if (counter > 0) { + boolean ruleTriggered = (i & 1) != 0; + boolean requestBlocked = (i & (1 << 1)) != 0; + boolean wafError = (i & (1 << 2)) != 0; + boolean wafTimeout = (i & (1 << 3)) != 0; + boolean blockFailure = (i & (1 << 4)) != 0; + boolean rateLimited = (i & (1 << 5)) != 0; + boolean inputTruncated = (i & (1 << 6)) != 0; + boolean requestExcluded = (i & (1 << 7)) != 0; + + if (!rawMetricsQueue.offer(new WafRequestsRawMetric( + counter, + WafMetricCollector.wafVersion, + WafMetricCollector.rulesVersion, + ruleTriggered, + requestBlocked, + wafError, + wafTimeout, + blockFailure, + rateLimited, + inputTruncated, + requestExcluded))) { + return; + } + } + } + + // WAF input truncated + for (int i = 0; i < (1 << 3); i++) { + long counter = wafInputTruncatedCounter.getAndSet(i, 0); + if (counter > 0) { + if (!rawMetricsQueue.offer(new WafInputTruncated(counter, i))) { + return; + } + } + } + + // RASP rule eval per rule type + for (RuleType ruleType : RuleType.values()) { + long counter = raspRuleEvalCounter.getAndSet(ruleType.ordinal(), 0); + if (counter > 0) { + if (!rawMetricsQueue.offer(new RaspRuleEval(counter, ruleType, WafMetricCollector.wafVersion))) { + return; + } + } + } + + // RASP rule match per rule type: two slots per RuleType: ordinal*2 (non-blocked), + // ordinal*2+1 (blocked) + for (RuleType ruleType : RuleType.values()) { + long blockedCount = raspRuleMatchCounter.getAndSet(ruleType.ordinal() * 2 + 1, 0); + if (blockedCount > 0) { + if (!rawMetricsQueue.offer( + new RaspRuleMatch(blockedCount, ruleType, WafMetricCollector.wafVersion, true))) { + return; + } + } + long nonBlockedCount = raspRuleMatchCounter.getAndSet(ruleType.ordinal() * 2, 0); + if (nonBlockedCount > 0) { + if (!rawMetricsQueue.offer( + new RaspRuleMatch(nonBlockedCount, ruleType, WafMetricCollector.wafVersion, false))) { + return; + } + } + } + + // RASP timeout per rule type + for (RuleType ruleType : RuleType.values()) { + long counter = raspTimeoutCounter.getAndSet(ruleType.ordinal(), 0); + if (counter > 0) { + if (!rawMetricsQueue.offer(new RaspTimeout(counter, ruleType, WafMetricCollector.wafVersion))) { + return; + } + } + } + + // RASP rule type for each possible error code + for (WafErrorCode errorCode : WafErrorCode.values()) { + for (RuleType ruleType : RuleType.values()) { + int index = errorCode.ordinal() * RuleType.getNumValues() + ruleType.ordinal(); + long count = raspErrorCodeCounter.getAndSet(index, 0); + if (count > 0) { + if (!rawMetricsQueue.offer( + new RaspError(count, ruleType, WafMetricCollector.wafVersion, errorCode.getCode()))) { + return; + } + } + } + } + + // Missing user login + for (LoginFramework framework : LoginFramework.values()) { + for (LoginEvent event : LoginEvent.values()) { + final int ordinal = framework.ordinal() * LoginEvent.getNumValues() + event.ordinal(); + long counter = missingUserLoginQueue.getAndSet(ordinal, 0); + if (counter > 0) { + if (!rawMetricsQueue.offer( + new MissingUserLoginMetric(counter, framework.getTag(), event.getTag()))) { + return; + } + } + } + } + + // Missing user id + for (LoginFramework framework : LoginFramework.values()) { + long counter = missingUserIdQueue.getAndSet(framework.ordinal(), 0); + if (counter > 0) { + if (!rawMetricsQueue.offer(new MissingUserIdMetric(counter, framework.getTag()))) { + return; + } + } + } + + // ATO login events + for (LoginEvent event : LoginEvent.values()) { + for (LoginVersion version : LoginVersion.values()) { + final int ordinal = event.ordinal() * LoginVersion.getNumValues() + version.ordinal(); + long counter = appSecSdkEventQueue.getAndSet(ordinal, 0); + if (counter > 0) { + if (!rawMetricsQueue.offer(new AppSecSdkEvent(counter, event.getTag(), version.getTag()))) { + return; + } + } + } + } + + // WAF rule type for each possible error code + for (WafErrorCode errorCode : WafErrorCode.values()) { + long count = wafErrorCodeCounter.getAndSet(errorCode.ordinal(), 0); + if (count > 0) { + if (!rawMetricsQueue.offer(new WafError(count, WafMetricCollector.wafVersion, errorCode.getCode()))) { + return; + } + } + } + + // RASP rule skipped per rule type for after-request reason + for (RuleType ruleType : RuleType.values()) { + long counter = raspRuleSkippedCounter.getAndSet(ruleType.ordinal(), 0); + if (counter > 0) { + if (!rawMetricsQueue.offer(new AfterRequestRaspRuleSkipped(counter, ruleType))) { + return; + } + } + } + + // WAF config errors + int configErrors = wafConfigErrorCounter.getAndSet(0); + if (configErrors > 0) { + if (!rawMetricsQueue.offer( + new WafConfigError(configErrors, WafMetricCollector.wafVersion, WafMetricCollector.rulesVersion))) { + return; + } + } + + // WafContext closed-concurrently race (APPSEC-69085) + int contextClosedRace = contextClosedRaceCounter.getAndSet(0); + if (contextClosedRace > 0) { + if (!rawMetricsQueue.offer(new ContextClosedRace(contextClosedRace))) { + return; + } + } + + // AI Guard successful requests + aiGuardSuccesses: + for (final AIGuard.Action action : AIGuard.Action.values()) { + for (int blockFlag = 1; blockFlag >= 0; blockFlag--) { + final boolean block = blockFlag == 1; + for (final AIGuardRedaction redaction : REDACTION_VALUES) { + final long count = aiGuardRequests.getAndSet(aiGuardRequestIndex(action, block, redaction), 0); + if (count > 0) { + if (!rawMetricsQueue.offer(AIGuardRequests.success(count, action, block, redaction))) { + break aiGuardSuccesses; + } + } + } + } + } + + // AI Guard failed requests + final int aiGuardErrorRequests = aiGuardErrors.getAndSet(0); + if (aiGuardErrorRequests > 0) { + if (!rawMetricsQueue.offer(AIGuardRequests.error(aiGuardErrorRequests))) { + return; + } + } + + // AI Guard errors, per type + for (final AIGuardError type : AI_GUARD_ERROR_VALUES) { + final long count = aiGuardErrorTypes.getAndSet(type.ordinal(), 0); + if (count > 0) { + if (!rawMetricsQueue.offer(new AIGuardErrors(count, type))) { + return; + } + } + } + + // AI Guard truncated messages + for (final AIGuardTruncationType type : AIGuardTruncationType.values()) { + final long count = aiGuardTruncated.getAndSet(type.ordinal(), 0); + if (count > 0) { + if (!rawMetricsQueue.offer(new AIGuardTruncated(count, type))) { + return; + } + } + } + + // API Security missing route, per framework + for (final Map.Entry entry : apiSecurityMissingRouteCounters.entrySet()) { + final long count = entry.getValue().getAndSet(0); + if (count > 0) { + if (!rawMetricsQueue.offer(new ApiSecurityMissingRoute(count, entry.getKey()))) { + return; + } + } + } + + // API Security request schema, per framework + for (final Map.Entry entry : apiSecurityRequestSchemaCounters.entrySet()) { + final long count = entry.getValue().getAndSet(0); + if (count > 0) { + if (!rawMetricsQueue.offer(new ApiSecurityRequestSchema(count, entry.getKey()))) { + return; + } + } + } + + // API Security request no schema, per framework + for (final Map.Entry entry : apiSecurityRequestNoSchemaCounters.entrySet()) { + final long count = entry.getValue().getAndSet(0); + if (count > 0) { + if (!rawMetricsQueue.offer(new ApiSecurityRequestNoSchema(count, entry.getKey()))) { + return; + } + } } - } } - } - public abstract static class WafMetric extends MetricCollector.Metric { + public abstract static class WafMetric extends MetricCollector.Metric { + + public WafMetric(String metricName, long counter, String... tags) { + this(NAMESPACE, metricName, counter, tags); + } - public WafMetric(String metricName, long counter, String... tags) { - this(NAMESPACE, metricName, counter, tags); + protected WafMetric(String namespace, String metricName, long counter, String... tags) { + super(namespace, true, metricName, "count", counter, tags); + } } - protected WafMetric(String namespace, String metricName, long counter, String... tags) { - super(namespace, true, metricName, "count", counter, tags); + public static class WafInitRawMetric extends WafMetric { + public WafInitRawMetric( + final long counter, final String wafVersion, final String rulesVersion, final boolean success) { + super( + "waf.init", + counter, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion, + "success:" + success); + } } - } - - public static class WafInitRawMetric extends WafMetric { - public WafInitRawMetric( - final long counter, - final String wafVersion, - final String rulesVersion, - final boolean success) { - super( - "waf.init", - counter, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion, - "success:" + success); + + public static class WafUpdatesRawMetric extends WafMetric { + public WafUpdatesRawMetric( + final long counter, final String wafVersion, final String rulesVersion, final boolean success) { + super( + "waf.updates", + counter, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion, + "success:" + success); + } } - } - - public static class WafUpdatesRawMetric extends WafMetric { - public WafUpdatesRawMetric( - final long counter, - final String wafVersion, - final String rulesVersion, - final boolean success) { - super( - "waf.updates", - counter, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion, - "success:" + success); + + public static class MissingUserLoginMetric extends WafMetric { + + public MissingUserLoginMetric(long counter, String framework, String type) { + super("instrum.user_auth.missing_user_login", counter, "framework:" + framework, "event_type:" + type); + } } - } - public static class MissingUserLoginMetric extends WafMetric { + public static class MissingUserIdMetric extends WafMetric { - public MissingUserLoginMetric(long counter, String framework, String type) { - super( - "instrum.user_auth.missing_user_login", - counter, - "framework:" + framework, - "event_type:" + type); + public MissingUserIdMetric(long counter, String framework) { + super( + "instrum.user_auth.missing_user_id", + counter, + "framework:" + framework, + "event_type:authenticated_request"); + } } - } - public static class MissingUserIdMetric extends WafMetric { + public static class AppSecSdkEvent extends WafMetric { - public MissingUserIdMetric(long counter, String framework) { - super( - "instrum.user_auth.missing_user_id", - counter, - "framework:" + framework, - "event_type:authenticated_request"); + public AppSecSdkEvent(long counter, String event, final String version) { + super("sdk.event", counter, "event_type:" + event, "sdk_version:" + version); + } } - } - public static class AppSecSdkEvent extends WafMetric { + public static class WafRequestsRawMetric extends WafMetric { + public WafRequestsRawMetric( + final long counter, + final String wafVersion, + final String rulesVersion, + final boolean triggered, + final boolean blocked, + final boolean wafError, + final boolean wafTimeout, + final boolean blockFailure, + final boolean rateLimited, + final boolean inputTruncated, + final boolean requestExcluded) { + super( + "waf.requests", + counter, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion, + "rule_triggered:" + triggered, + "request_blocked:" + blocked, + "waf_error:" + wafError, + "waf_timeout:" + wafTimeout, + "block_failure:" + blockFailure, + "rate_limited:" + rateLimited, + "input_truncated:" + inputTruncated, + "request_excluded:" + (requestExcluded ? "full" : "none")); + } + } - public AppSecSdkEvent(long counter, String event, final String version) { - super("sdk.event", counter, "event_type:" + event, "sdk_version:" + version); + public void addWafConfigError(int nbErrors) { + wafConfigErrorCounter.addAndGet(nbErrors); } - } - - public static class WafRequestsRawMetric extends WafMetric { - public WafRequestsRawMetric( - final long counter, - final String wafVersion, - final String rulesVersion, - final boolean triggered, - final boolean blocked, - final boolean wafError, - final boolean wafTimeout, - final boolean blockFailure, - final boolean rateLimited, - final boolean inputTruncated, - final boolean requestExcluded) { - super( - "waf.requests", - counter, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion, - "rule_triggered:" + triggered, - "request_blocked:" + blocked, - "waf_error:" + wafError, - "waf_timeout:" + wafTimeout, - "block_failure:" + blockFailure, - "rate_limited:" + rateLimited, - "input_truncated:" + inputTruncated, - "request_excluded:" + (requestExcluded ? "full" : "none")); + + /** + * Records that {@code getOrCreateWafContext} rejected a run because the request's {@code + * WafContext} was already closed concurrently (APPSEC-69085). Used to measure the frequency of + * this race in production. + */ + public void wafContextClosedRace() { + contextClosedRaceCounter.incrementAndGet(); } - } - - public void addWafConfigError(int nbErrors) { - wafConfigErrorCounter.addAndGet(nbErrors); - } - - /** - * Records that {@code getOrCreateWafContext} rejected a run because the request's {@code - * WafContext} was already closed concurrently (APPSEC-69085). Used to measure the frequency of - * this race in production. - */ - public void wafContextClosedRace() { - contextClosedRaceCounter.incrementAndGet(); - } - - public static class ContextClosedRace extends WafMetric { - public ContextClosedRace(final long counter) { - super("waf.context_closed_race", counter); + + public static class ContextClosedRace extends WafMetric { + public ContextClosedRace(final long counter) { + super("waf.context_closed_race", counter); + } } - } - - public static class WafConfigError extends WafMetric { - public WafConfigError(final long counter, final String wafVersion, final String rulesVersion) { - super( - "waf.config_errors", - counter, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion); + + public static class WafConfigError extends WafMetric { + public WafConfigError(final long counter, final String wafVersion, final String rulesVersion) { + super("waf.config_errors", counter, "waf_version:" + wafVersion, "event_rules_version:" + rulesVersion); + } } - } - - public static class RaspRuleEval extends WafMetric { - public RaspRuleEval(final long counter, final RuleType ruleType, final String wafVersion) { - super( - "rasp.rule.eval", - counter, - ruleType.variant != null - ? new String[] { - "rule_type:" + ruleType.type, - "rule_variant:" + ruleType.variant, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion - } - : new String[] {"rule_type:" + ruleType.type, "waf_version:" + wafVersion}); + + public static class RaspRuleEval extends WafMetric { + public RaspRuleEval(final long counter, final RuleType ruleType, final String wafVersion) { + super( + "rasp.rule.eval", + counter, + ruleType.variant != null + ? new String[] { + "rule_type:" + ruleType.type, + "rule_variant:" + ruleType.variant, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion + } + : new String[] {"rule_type:" + ruleType.type, "waf_version:" + wafVersion}); + } } - } - - // Although rasp.rule.skipped reason could be before-request, there is no real case scenario - public static class AfterRequestRaspRuleSkipped extends WafMetric { - public AfterRequestRaspRuleSkipped(final long counter, final RuleType ruleType) { - super( - "rasp.rule.skipped", - counter, - ruleType.variant != null - ? new String[] { - "rule_type:" + ruleType.type, - "rule_variant:" + ruleType.variant, - "reason:" + "after-request" - } - : new String[] {"rule_type:" + ruleType.type, "reason:" + "after-request"}); + + // Although rasp.rule.skipped reason could be before-request, there is no real case scenario + public static class AfterRequestRaspRuleSkipped extends WafMetric { + public AfterRequestRaspRuleSkipped(final long counter, final RuleType ruleType) { + super( + "rasp.rule.skipped", + counter, + ruleType.variant != null + ? new String[] { + "rule_type:" + ruleType.type, + "rule_variant:" + ruleType.variant, + "reason:" + "after-request" + } + : new String[] {"rule_type:" + ruleType.type, "reason:" + "after-request"}); + } } - } - - public static class RaspRuleMatch extends WafMetric { - public RaspRuleMatch( - final long counter, - final RuleType ruleType, - final String wafVersion, - final boolean blocked) { - super( - "rasp.rule.match", - counter, - ruleType.variant != null - ? new String[] { - "rule_type:" + ruleType.type, - "rule_variant:" + ruleType.variant, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion, - "block:" + blocked - } - : new String[] { - "rule_type:" + ruleType.type, "waf_version:" + wafVersion, "block:" + blocked - }); + + public static class RaspRuleMatch extends WafMetric { + public RaspRuleMatch( + final long counter, final RuleType ruleType, final String wafVersion, final boolean blocked) { + super( + "rasp.rule.match", + counter, + ruleType.variant != null + ? new String[] { + "rule_type:" + ruleType.type, + "rule_variant:" + ruleType.variant, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion, + "block:" + blocked + } + : new String[] { + "rule_type:" + ruleType.type, "waf_version:" + wafVersion, "block:" + blocked + }); + } } - } - - public static class RaspTimeout extends WafMetric { - public RaspTimeout(final long counter, final RuleType ruleType, final String wafVersion) { - super( - "rasp.timeout", - counter, - ruleType.variant != null - ? new String[] { - "rule_type:" + ruleType.type, - "rule_variant:" + ruleType.variant, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion - } - : new String[] {"rule_type:" + ruleType.type, "waf_version:" + wafVersion}); + + public static class RaspTimeout extends WafMetric { + public RaspTimeout(final long counter, final RuleType ruleType, final String wafVersion) { + super( + "rasp.timeout", + counter, + ruleType.variant != null + ? new String[] { + "rule_type:" + ruleType.type, + "rule_variant:" + ruleType.variant, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion + } + : new String[] {"rule_type:" + ruleType.type, "waf_version:" + wafVersion}); + } } - } - - public static class RaspError extends WafMetric { - public RaspError( - final long counter, - final RuleType ruleType, - final String wafVersion, - final Integer ddwafRunError) { - super( - "rasp.error", - counter, - ruleType.variant != null - ? new String[] { - "rule_type:" + ruleType.type, - "rule_variant:" + ruleType.variant, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion, - "waf_error:" + ddwafRunError - } - : new String[] { - "rule_type:" + ruleType.type, - "waf_version:" + wafVersion, - "waf_error:" + ddwafRunError - }); + + public static class RaspError extends WafMetric { + public RaspError( + final long counter, final RuleType ruleType, final String wafVersion, final Integer ddwafRunError) { + super( + "rasp.error", + counter, + ruleType.variant != null + ? new String[] { + "rule_type:" + ruleType.type, + "rule_variant:" + ruleType.variant, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion, + "waf_error:" + ddwafRunError + } + : new String[] { + "rule_type:" + ruleType.type, "waf_version:" + wafVersion, "waf_error:" + ddwafRunError + }); + } } - } - - public static class WafError extends WafMetric { - public WafError(final long counter, final String wafVersion, final Integer ddwafRunError) { - super( - "waf.error", - counter, - "waf_version:" + wafVersion, - "event_rules_version:" + rulesVersion, - "waf_error:" + ddwafRunError); + + public static class WafError extends WafMetric { + public WafError(final long counter, final String wafVersion, final Integer ddwafRunError) { + super( + "waf.error", + counter, + "waf_version:" + wafVersion, + "event_rules_version:" + rulesVersion, + "waf_error:" + ddwafRunError); + } } - } - public static class WafInputTruncated extends WafMetric { - public WafInputTruncated(final long counter, final int bitfield) { - super("waf.input_truncated", counter, "truncation_reason:" + bitfield); + public static class WafInputTruncated extends WafMetric { + public WafInputTruncated(final long counter, final int bitfield) { + super("waf.input_truncated", counter, "truncation_reason:" + bitfield); + } } - } - /** Base class for the metrics reported under the {@code ai_guard} namespace. */ - public abstract static class AIGuardMetric extends WafMetric { + /** Base class for the metrics reported under the {@code ai_guard} namespace. */ + public abstract static class AIGuardMetric extends WafMetric { - /** - * Call-path tags the AI Guard specification requires on every metric in this namespace. Both - * are constant here: the JVM tracer only reaches an evaluation through a direct SDK call, so - * there is no auto-instrumented integration to name. They become dimensional once AI Guard - * auto-instrumentation lands. - */ - private static final String[] CALL_PATH_TAGS = {"source:sdk", "integration:none"}; + /** + * Call-path tags the AI Guard specification requires on every metric in this namespace. Both + * are constant here: the JVM tracer only reaches an evaluation through a direct SDK call, so + * there is no auto-instrumented integration to name. They become dimensional once AI Guard + * auto-instrumentation lands. + */ + private static final String[] CALL_PATH_TAGS = {"source:sdk", "integration:none"}; - protected AIGuardMetric(final String metricName, final long counter, final String... tags) { - super(AI_GUARD_NAMESPACE, metricName, counter, withCallPath(tags)); - } + protected AIGuardMetric(final String metricName, final long counter, final String... tags) { + super(AI_GUARD_NAMESPACE, metricName, counter, withCallPath(tags)); + } - private static String[] withCallPath(final String[] tags) { - final String[] result = Arrays.copyOf(tags, tags.length + CALL_PATH_TAGS.length); - System.arraycopy(CALL_PATH_TAGS, 0, result, tags.length, CALL_PATH_TAGS.length); - return result; + private static String[] withCallPath(final String[] tags) { + final String[] result = Arrays.copyOf(tags, tags.length + CALL_PATH_TAGS.length); + System.arraycopy(CALL_PATH_TAGS, 0, result, tags.length, CALL_PATH_TAGS.length); + return result; + } } - } - public static class AIGuardRequests extends AIGuardMetric { - private AIGuardRequests(final long count, final String... tags) { - super("requests", count, tags); - } + public static class AIGuardRequests extends AIGuardMetric { + private AIGuardRequests(final long count, final String... tags) { + super("requests", count, tags); + } + + public static AIGuardRequests success( + final long count, final AIGuard.Action action, final boolean block, final AIGuardRedaction redaction) { + if (redaction == AIGuardRedaction.DISABLED) { + // No redacted tag at all, so its absence stays distinguishable from a false value. + return new AIGuardRequests(count, "action:" + action, "block:" + block, "error:false"); + } + return new AIGuardRequests( + count, + "action:" + action, + "block:" + block, + "error:false", + "redacted:" + (redaction == AIGuardRedaction.APPLIED)); + } - public static AIGuardRequests success( - final long count, - final AIGuard.Action action, - final boolean block, - final AIGuardRedaction redaction) { - if (redaction == AIGuardRedaction.DISABLED) { - // No redacted tag at all, so its absence stays distinguishable from a false value. - return new AIGuardRequests(count, "action:" + action, "block:" + block, "error:false"); - } - return new AIGuardRequests( - count, - "action:" + action, - "block:" + block, - "error:false", - "redacted:" + (redaction == AIGuardRedaction.APPLIED)); + public static AIGuardRequests error(final long count) { + return new AIGuardRequests(count, "error:true"); + } } - public static AIGuardRequests error(final long count) { - return new AIGuardRequests(count, "error:true"); + /** + * Failures reported under {@code ai_guard.error}, classified by {@link AIGuardError}. An + * evaluation failure is reported here and as {@code error:true} on {@code + * ai_guard.requests}; a redaction error is reported only here. + */ + public static class AIGuardErrors extends AIGuardMetric { + public AIGuardErrors(final long count, final AIGuardError type) { + super("error", count, "type:" + type.tagValue); + } } - } - - /** - * Failures reported under {@code ai_guard.error}, classified by {@link AIGuardError}. An - * evaluation failure is reported here and as {@code error:true} on {@code - * ai_guard.requests}; a redaction error is reported only here. - */ - public static class AIGuardErrors extends AIGuardMetric { - public AIGuardErrors(final long count, final AIGuardError type) { - super("error", count, "type:" + type.tagValue); + + public static class AIGuardTruncated extends AIGuardMetric { + public AIGuardTruncated(final long count, final AIGuardTruncationType type) { + super("truncated", count, "type:" + type.tagValue); + } } - } - public static class AIGuardTruncated extends AIGuardMetric { - public AIGuardTruncated(final long count, final AIGuardTruncationType type) { - super("truncated", count, "type:" + type.tagValue); + public static class ApiSecurityMissingRoute extends WafMetric { + public ApiSecurityMissingRoute(final long counter, final String framework) { + super("api_security.missing_route", counter, "framework:" + framework); + } } - } - public static class ApiSecurityMissingRoute extends WafMetric { - public ApiSecurityMissingRoute(final long counter, final String framework) { - super("api_security.missing_route", counter, "framework:" + framework); + public static class ApiSecurityRequestSchema extends WafMetric { + public ApiSecurityRequestSchema(final long counter, final String framework) { + super("api_security.request.schema", counter, "framework:" + framework); + } } - } - public static class ApiSecurityRequestSchema extends WafMetric { - public ApiSecurityRequestSchema(final long counter, final String framework) { - super("api_security.request.schema", counter, "framework:" + framework); + public static class ApiSecurityRequestNoSchema extends WafMetric { + public ApiSecurityRequestNoSchema(final long counter, final String framework) { + super("api_security.request.no_schema", counter, "framework:" + framework); + } } - } - public static class ApiSecurityRequestNoSchema extends WafMetric { - public ApiSecurityRequestNoSchema(final long counter, final String framework) { - super("api_security.request.no_schema", counter, "framework:" + framework); + /** + * Whether an evaluation redacted anything, as reported by the {@code redacted} tag on {@code + * ai_guard.requests}. {@link #DISABLED} reports no tag at all, so an absent tag means "redaction + * is off" and stays distinguishable from {@code redacted:false}. + */ + public enum AIGuardRedaction { + /** Redaction is disabled locally, so nothing was even attempted. */ + DISABLED, + /** Redaction is enabled and at least one replacement was applied. */ + APPLIED, + /** Redaction is enabled but nothing was redacted. */ + NOT_APPLIED } - } - - /** - * Whether an evaluation redacted anything, as reported by the {@code redacted} tag on {@code - * ai_guard.requests}. {@link #DISABLED} reports no tag at all, so an absent tag means "redaction - * is off" and stays distinguishable from {@code redacted:false}. - */ - public enum AIGuardRedaction { - /** Redaction is disabled locally, so nothing was even attempted. */ - DISABLED, - /** Redaction is enabled and at least one replacement was applied. */ - APPLIED, - /** Redaction is enabled but nothing was redacted. */ - NOT_APPLIED - } - - /** - * Classification of the failures reported under {@code ai_guard.error}, as the {@code type} tag. - */ - public enum AIGuardError { - /** A transport failure, or any error the tracer could not attribute to the response. */ - CLIENT_ERROR("client_error"), - /** The service answered with a status code that explains the failure on its own. */ - BAD_STATUS("bad_status"), - /** The service answered successfully, with a body the tracer cannot use. */ - BAD_RESPONSE("bad_response"), - /** A redaction replacement the tracer could not apply. Never fails the evaluation. */ - REDACTION_ERROR("redaction_error"); - - public final String tagValue; - - AIGuardError(final String tagValue) { - this.tagValue = tagValue; + + /** + * Classification of the failures reported under {@code ai_guard.error}, as the {@code type} tag. + */ + public enum AIGuardError { + /** A transport failure, or any error the tracer could not attribute to the response. */ + CLIENT_ERROR("client_error"), + /** The service answered with a status code that explains the failure on its own. */ + BAD_STATUS("bad_status"), + /** The service answered successfully, with a body the tracer cannot use. */ + BAD_RESPONSE("bad_response"), + /** A redaction replacement the tracer could not apply. Never fails the evaluation. */ + REDACTION_ERROR("redaction_error"); + + public final String tagValue; + + AIGuardError(final String tagValue) { + this.tagValue = tagValue; + } } - } - public enum AIGuardTruncationType { - MESSAGES("messages"), - CONTENT("content"); - public final String tagValue; + public enum AIGuardTruncationType { + MESSAGES("messages"), + CONTENT("content"); + public final String tagValue; - AIGuardTruncationType(final String tagValue) { - this.tagValue = tagValue; - } - } - - /** - * Mirror of the {@code WafErrorCode} enum defined in the {@code libddwaf-java} module. - * - *

This enum is duplicated here to avoid adding a dependency on the native bindings module - * (`libddwaf-java`) within the {@code internal-api} module. - * - *

IMPORTANT: If the {@code WafErrorCode} definition in {@code libddwaf-java} is updated, this - * enum must be kept in sync manually to ensure correct behavior and compatibility. - * - *

Each enum value represents a specific WAF error condition, typically returned when running a - * WAF rule evaluation. - */ - public enum WafErrorCode { - INVALID_ARGUMENT(-1), - INVALID_OBJECT(-2), - INTERNAL_ERROR(-3), - BINDING_ERROR( - -127); // This is a special error code that is not returned by the WAF, is used to signal a - // binding error - - private final int code; - - private static final Map CODE_MAP; - - static { - Map map = new HashMap<>(); - for (WafErrorCode errorCode : values()) { - map.put(errorCode.code, errorCode); - } - CODE_MAP = Collections.unmodifiableMap(map); + AIGuardTruncationType(final String tagValue) { + this.tagValue = tagValue; + } } - WafErrorCode(int code) { - this.code = code; - } + /** + * Mirror of the {@code WafErrorCode} enum defined in the {@code libddwaf-java} module. + * + *

This enum is duplicated here to avoid adding a dependency on the native bindings module + * (`libddwaf-java`) within the {@code internal-api} module. + * + *

IMPORTANT: If the {@code WafErrorCode} definition in {@code libddwaf-java} is updated, this + * enum must be kept in sync manually to ensure correct behavior and compatibility. + * + *

Each enum value represents a specific WAF error condition, typically returned when running a + * WAF rule evaluation. + */ + public enum WafErrorCode { + INVALID_ARGUMENT(-1), + INVALID_OBJECT(-2), + INTERNAL_ERROR(-3), + BINDING_ERROR(-127); // This is a special error code that is not returned by the WAF, is used to signal a + // binding error - public int getCode() { - return code; - } + private final int code; + + private static final Map CODE_MAP; + + static { + Map map = new HashMap<>(); + for (WafErrorCode errorCode : values()) { + map.put(errorCode.code, errorCode); + } + CODE_MAP = Collections.unmodifiableMap(map); + } + + WafErrorCode(int code) { + this.code = code; + } - public static WafErrorCode fromCode(int code) { - return CODE_MAP.get(code); + public int getCode() { + return code; + } + + public static WafErrorCode fromCode(int code) { + return CODE_MAP.get(code); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/api/telemetry/WafTruncatedType.java b/internal-api/src/main/java/datadog/trace/api/telemetry/WafTruncatedType.java index e2051425fac..ff4282ef009 100644 --- a/internal-api/src/main/java/datadog/trace/api/telemetry/WafTruncatedType.java +++ b/internal-api/src/main/java/datadog/trace/api/telemetry/WafTruncatedType.java @@ -1,17 +1,17 @@ package datadog.trace.api.telemetry; public enum WafTruncatedType { - STRING_TOO_LONG(1), - LIST_MAP_TOO_LARGE(2), - OBJECT_TOO_DEEP(4); + STRING_TOO_LONG(1), + LIST_MAP_TOO_LARGE(2), + OBJECT_TOO_DEEP(4); - private final int value; + private final int value; - WafTruncatedType(int value) { - this.value = value; - } + WafTruncatedType(int value) { + this.value = value; + } - public int getValue() { - return this.value; - } + public int getValue() { + return this.value; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/ActiveSubsystems.java b/internal-api/src/main/java/datadog/trace/bootstrap/ActiveSubsystems.java index eb811a678fb..62720665db8 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/ActiveSubsystems.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/ActiveSubsystems.java @@ -2,5 +2,5 @@ /** Runtime activation state of the subsystems that can be switched on and off after start-up. */ public class ActiveSubsystems { - public static volatile boolean APPSEC_ACTIVE; + public static volatile boolean APPSEC_ACTIVE; } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/ContextStore.java b/internal-api/src/main/java/datadog/trace/bootstrap/ContextStore.java index 18dd8344a5c..8ef15caba8e 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/ContextStore.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/ContextStore.java @@ -14,78 +14,78 @@ */ public interface ContextStore { - /** - * Factory interface to create context instances - * - * @param context type - */ - interface Factory extends Function { - /** - * @return new context instance + * Factory interface to create context instances + * + * @param context type */ - C create(); + interface Factory extends Function { + + /** + * @return new context instance + */ + C create(); - default C apply(Object key) { - return create(); + default C apply(Object key) { + return create(); + } } - } - /** - * Get context instance for the given key. - * - * @param key the context key - * @return context instance; {@code null} if the key had no context - */ - @Nullable - C get(K key); + /** + * Get context instance for the given key. + * + * @param key the context key + * @return context instance; {@code null} if the key had no context + */ + @Nullable + C get(K key); - /** - * Unconditionally put new context instance for the given key. - * - * @param key the context key - * @param context context instance to save - */ - void put(K key, C context); + /** + * Unconditionally put new context instance for the given key. + * + * @param key the context key + * @param context context instance to save + */ + void put(K key, C context); - /** - * Gets the context instance for the given key. If no context exists then associate it with the - * new context. - * - * @param key the context key - * @param context new context instance - * @return existing context instance if present; otherwise new instance - */ - C getOrPut(K key, C context); + /** + * Gets the context instance for the given key. If no context exists then associate it with the + * new context. + * + * @param key the context key + * @param context new context instance + * @return existing context instance if present; otherwise new instance + */ + C getOrPut(K key, C context); - /** - * Gets the context instance for the given key. If no context exists then create one using the - * given factory and associate it with the key. - * - * @param key the context key - * @param contextFactory factory instance to produce new context instances - * @return existing context instance if present; otherwise new instance - */ - default C getOrCreate(K key, Factory contextFactory) { - return getOrCompute(key, contextFactory); - } + /** + * Gets the context instance for the given key. If no context exists then create one using the + * given factory and associate it with the key. + * + * @param key the context key + * @param contextFactory factory instance to produce new context instances + * @return existing context instance if present; otherwise new instance + */ + default C getOrCreate(K key, Factory contextFactory) { + return getOrCompute(key, contextFactory); + } - /** - * Gets the context instance for the given key. If no context exists then create one using the - * given factory and associate it with the key. - * - * @param key the context key - * @param contextFactory factory instance to produce new context instances - * @return existing context instance if present; otherwise new instance - */ - C getOrCompute(K key, Function contextFactory); + /** + * Gets the context instance for the given key. If no context exists then create one using the + * given factory and associate it with the key. + * + * @param key the context key + * @param contextFactory factory instance to produce new context instances + * @return existing context instance if present; otherwise new instance + */ + C getOrCompute(K key, Function contextFactory); - /** - * Removes the context instance for the given key. - * - * @param key the context key - * @return removed context instance; {@code null} if the key had no context - */ - @Nullable - C remove(K key); + /** + * Removes the context instance for the given key. + * + * @param key the context key + * @return removed context instance; {@code null} if the key had no context + */ + @Nullable + C remove(K key); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java index 46a01b3f70f..cdcca877069 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java @@ -14,63 +14,61 @@ import javax.annotation.ParametersAreNonnullByDefault; public final class AgentPropagation { - public static final Concern TRACING_CONCERN = named("tracing"); - // TODO: Baggage propagator should run after tracing so it can link baggage with the span context - // TODO: remove this priority once we have a story for replacing TagContext with the Context API - public static final Concern BAGGAGE_CONCERN = withPriority("baggage", 105); - public static final Concern XRAY_TRACING_CONCERN = named("tracing-xray"); - public static final Concern INFERRED_PROXY_CONCERN = named("inferred-proxy"); - // TODO DSM propagator should run after the other propagators as it stores the pathway context - // TODO into the span context for now. Remove priority after the migration is complete. - public static final Concern DSM_CONCERN = withPriority("data-stream-monitoring", 110); + public static final Concern TRACING_CONCERN = named("tracing"); + // TODO: Baggage propagator should run after tracing so it can link baggage with the span context + // TODO: remove this priority once we have a story for replacing TagContext with the Context API + public static final Concern BAGGAGE_CONCERN = withPriority("baggage", 105); + public static final Concern XRAY_TRACING_CONCERN = named("tracing-xray"); + public static final Concern INFERRED_PROXY_CONCERN = named("inferred-proxy"); + // TODO DSM propagator should run after the other propagators as it stores the pathway context + // TODO into the span context for now. Remove priority after the migration is complete. + public static final Concern DSM_CONCERN = withPriority("data-stream-monitoring", 110); - private AgentPropagation() {} - - /** - * @deprecated Use {@link Propagators} API instead. - */ - @Deprecated - public static AgentSpanContext.Extracted extractContextAndGetSpanContext( - final C carrier, final ContextVisitor getter) { - Context extracted = Propagators.defaultPropagator().extract(root(), carrier, getter); - AgentSpan extractedSpan = fromContext(extracted); - return extractedSpan == null ? null : (AgentSpanContext.Extracted) extractedSpan.spanContext(); - } - - public interface KeyClassifier { - boolean accept(String key, String value); + private AgentPropagation() {} /** - * Variant of {@link #accept(String, String)} for carriers that store header values in a raw - * form (e.g. {@code byte[]}) and want to defer string conversion until after the key is known - * to be relevant. - * - *

The default implementation applies {@code transformer} eagerly and delegates to {@link - * #accept(String, String)}, so existing classifiers work without any changes. - * - * @param key the header name - * @param value the raw header value, in whatever form the carrier provides - * @param transformer converts {@code value} to a string; called at most once by the default - * implementation - * @return {@code false} to stop iteration, {@code true} to continue + * @deprecated Use {@link Propagators} API instead. */ - default boolean accept(String key, T value, Function transformer) { - return accept(key, transformer.apply(value)); + @Deprecated + public static AgentSpanContext.Extracted extractContextAndGetSpanContext( + final C carrier, final ContextVisitor getter) { + Context extracted = Propagators.defaultPropagator().extract(root(), carrier, getter); + AgentSpan extractedSpan = fromContext(extracted); + return extractedSpan == null ? null : (AgentSpanContext.Extracted) extractedSpan.spanContext(); + } + + public interface KeyClassifier { + boolean accept(String key, String value); + + /** + * Variant of {@link #accept(String, String)} for carriers that store header values in a raw + * form (e.g. {@code byte[]}) and want to defer string conversion until after the key is known + * to be relevant. + * + *

The default implementation applies {@code transformer} eagerly and delegates to {@link + * #accept(String, String)}, so existing classifiers work without any changes. + * + * @param key the header name + * @param value the raw header value, in whatever form the carrier provides + * @param transformer converts {@code value} to a string; called at most once by the default + * implementation + * @return {@code false} to stop iteration, {@code true} to continue + */ + default boolean accept(String key, T value, Function transformer) { + return accept(key, transformer.apply(value)); + } } - } - public interface ContextVisitor extends CarrierVisitor { - void forEachKey(C carrier, KeyClassifier classifier); + public interface ContextVisitor extends CarrierVisitor { + void forEachKey(C carrier, KeyClassifier classifier); - @ParametersAreNonnullByDefault - @Override - default void forEachKeyValue(C carrier, BiConsumer visitor) { - forEachKey( - carrier, - (key, value) -> { - visitor.accept(key, value); - return true; - }); + @ParametersAreNonnullByDefault + @Override + default void forEachKeyValue(C carrier, BiConsumer visitor) { + forEachKey(carrier, (key, value) -> { + visitor.accept(key, value); + return true; + }); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java index 94c0f80572a..471d1b67b62 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpan.java @@ -19,352 +19,344 @@ import javax.annotation.Nullable; public interface AgentSpan - extends MutableSpan, - AppendableSpanLinks, - ImplicitContextKeyed, - Context, - IGSpanInfo, - WithAgentSpan { - - /** - * Extracts the span from the current {@link Context}. - * - * @return the current span if existing, {@code null} otherwise. - */ - static AgentSpan current() { - return Context.current().get(SPAN_KEY); - } - - /** - * Extracts the span from the given {@link Context}. - * - * @param context the context to extract the span from. - * @return the span if existing, {@code null} otherwise. - */ - static AgentSpan fromContext(Context context) { - return context == null ? null : context.get(SPAN_KEY); - } - - /** - * Extracts the span from the given {@link ContextScope}. - * - * @param scope the context scope to extract the span from. - * @return the span if existing, {@code null} otherwise. - */ - static AgentSpan fromScope(ContextScope scope) { - return scope == null ? null : scope.context().get(SPAN_KEY); - } - - /** - * Creates a span wrapper from a span context. - * - *

Creating such span will not create a tracing span to complete a local root trace. It gives a - * span instance based on a span context for span-based API. It is usually used with an extracted - * span context as parameter to represent a remote span. - * - * @param spanContext the span context to get a full-fledged span. - * @return a span wrapper based on a span context. - */ - static AgentSpan fromSpanContext(AgentSpanContext spanContext) { - if (spanContext == null || spanContext == NoopSpanContext.INSTANCE) { - return NoopSpan.INSTANCE; + extends MutableSpan, AppendableSpanLinks, ImplicitContextKeyed, Context, IGSpanInfo, WithAgentSpan { + + /** + * Extracts the span from the current {@link Context}. + * + * @return the current span if existing, {@code null} otherwise. + */ + static AgentSpan current() { + return Context.current().get(SPAN_KEY); } - return new ExtractedSpan(spanContext); - } - DDTraceId getTraceId(); + /** + * Extracts the span from the given {@link Context}. + * + * @param context the context to extract the span from. + * @return the span if existing, {@code null} otherwise. + */ + static AgentSpan fromContext(Context context) { + return context == null ? null : context.get(SPAN_KEY); + } - long getSpanId(); + /** + * Extracts the span from the given {@link ContextScope}. + * + * @param scope the context scope to extract the span from. + * @return the span if existing, {@code null} otherwise. + */ + static AgentSpan fromScope(ContextScope scope) { + return scope == null ? null : scope.context().get(SPAN_KEY); + } - /** - * Checks whether a span is considered valid by having valid trace and span identifiers. - * - * @return {@code true} if the span is considered valid, {@code false} otherwise. - */ - default boolean isValid() { - return getTraceId() != DDTraceId.ZERO && getSpanId() != DDSpanId.ZERO; - } + /** + * Creates a span wrapper from a span context. + * + *

Creating such span will not create a tracing span to complete a local root trace. It gives a + * span instance based on a span context for span-based API. It is usually used with an extracted + * span context as parameter to represent a remote span. + * + * @param spanContext the span context to get a full-fledged span. + * @return a span wrapper based on a span context. + */ + static AgentSpan fromSpanContext(AgentSpanContext spanContext) { + if (spanContext == null || spanContext == NoopSpanContext.INSTANCE) { + return NoopSpan.INSTANCE; + } + return new ExtractedSpan(spanContext); + } - @Override - AgentSpan setTag(String key, boolean value); + DDTraceId getTraceId(); - AgentSpan setTag(String key, int value); + long getSpanId(); - AgentSpan setTag(String key, long value); + /** + * Checks whether a span is considered valid by having valid trace and span identifiers. + * + * @return {@code true} if the span is considered valid, {@code false} otherwise. + */ + default boolean isValid() { + return getTraceId() != DDTraceId.ZERO && getSpanId() != DDSpanId.ZERO; + } - AgentSpan setTag(String key, float value); + @Override + AgentSpan setTag(String key, boolean value); - AgentSpan setTag(String key, double value); + AgentSpan setTag(String key, int value); - @Override - AgentSpan setTag(String key, String value); + AgentSpan setTag(String key, long value); - AgentSpan setTag(String key, CharSequence value); + AgentSpan setTag(String key, float value); - AgentSpan setTag(String key, Object value); + AgentSpan setTag(String key, double value); - /** entry may be null - in which case the tags remained unchanged */ - AgentSpan setTag(TagMap.EntryReader entry); + @Override + AgentSpan setTag(String key, String value); - AgentSpan setAllTags(Map map); + AgentSpan setTag(String key, CharSequence value); - @Override - AgentSpan setTag(String key, Number value); + AgentSpan setTag(String key, Object value); - @Override - AgentSpan setMetric(CharSequence key, int value); + /** entry may be null - in which case the tags remained unchanged */ + AgentSpan setTag(TagMap.EntryReader entry); - @Override - AgentSpan setMetric(CharSequence key, long value); + AgentSpan setAllTags(Map map); - @Override - AgentSpan setMetric(CharSequence key, float value); + @Override + AgentSpan setTag(String key, Number value); - @Override - AgentSpan setMetric(CharSequence key, double value); + @Override + AgentSpan setMetric(CharSequence key, int value); - /** metricEntry may be null - in which case the tags remained unchanged */ - AgentSpan setMetric(TagMap.EntryReader metricEntry); + @Override + AgentSpan setMetric(CharSequence key, long value); - @Override - AgentSpan setSpanType(final CharSequence type); + @Override + AgentSpan setMetric(CharSequence key, float value); - @Override - TagMap getTags(); + @Override + AgentSpan setMetric(CharSequence key, double value); - Object getTag(String key); + /** metricEntry may be null - in which case the tags remained unchanged */ + AgentSpan setMetric(TagMap.EntryReader metricEntry); - @Override - AgentSpan setError(boolean error); + @Override + AgentSpan setSpanType(final CharSequence type); - AgentSpan setError(boolean error, byte priority); + @Override + TagMap getTags(); - AgentSpan setMeasured(boolean measured); + Object getTag(String key); - AgentSpan setErrorMessage(String errorMessage); + @Override + AgentSpan setError(boolean error); - AgentSpan addThrowable(Throwable throwable); + AgentSpan setError(boolean error, byte priority); - AgentSpan addThrowable(Throwable throwable, byte errorPriority); + AgentSpan setMeasured(boolean measured); - @Override - AgentSpan getLocalRootSpan(); + AgentSpan setErrorMessage(String errorMessage); - boolean isSameTrace(AgentSpan otherSpan); + AgentSpan addThrowable(Throwable throwable); - AgentSpanContext spanContext(); + AgentSpan addThrowable(Throwable throwable, byte errorPriority); - String getBaggageItem(String key); + @Override + AgentSpan getLocalRootSpan(); - AgentSpan setBaggageItem(String key, String value); + boolean isSameTrace(AgentSpan otherSpan); - AgentSpan setHttpStatusCode(int statusCode); + AgentSpanContext spanContext(); - short getHttpStatusCode(); + String getBaggageItem(String key); - void finish(); + AgentSpan setBaggageItem(String key, String value); - void finish(long finishMicros); + AgentSpan setHttpStatusCode(int statusCode); - void finishWithDuration(long durationNanos); + short getHttpStatusCode(); - /** Marks the start of a message pipeline where we want to track end-to-end processing time. */ - void beginEndToEnd(); + void finish(); - /** - * Marks the end of a message pipeline where the final end-to-end processing time is recorded. - * - *

Note: this will also finish the span and publish it. - */ - void finishWithEndToEnd(); + void finish(long finishMicros); - /** - * Finishes the span but does not publish it. The {@link #publish()} method MUST be called once - * otherwise the trace will not be reported. - * - * @return true if the span was successfully finished, false if it was already finished. - */ - boolean phasedFinish(); + void finishWithDuration(long durationNanos); - /** - * Publish a span that was previously finished by calling {@link #phasedFinish()}. Must be called - * once and only once per span. - */ - void publish(); + /** Marks the start of a message pipeline where we want to track end-to-end processing time. */ + void beginEndToEnd(); - CharSequence getSpanName(); + /** + * Marks the end of a message pipeline where the final end-to-end processing time is recorded. + * + *

Note: this will also finish the span and publish it. + */ + void finishWithEndToEnd(); - void setSpanName(CharSequence spanName); + /** + * Finishes the span but does not publish it. The {@link #publish()} method MUST be called once + * otherwise the trace will not be reported. + * + * @return true if the span was successfully finished, false if it was already finished. + */ + boolean phasedFinish(); - /** - * Deprecated in favor of setResourceName(final CharSequence resourceName, byte priority) or using - * getResourceNamePriority() for comparisons. - */ - @Deprecated - boolean hasResourceName(); + /** + * Publish a span that was previously finished by calling {@link #phasedFinish()}. Must be called + * once and only once per span. + */ + void publish(); - byte getResourceNamePriority(); + CharSequence getSpanName(); - @Override - AgentSpan setResourceName(final CharSequence resourceName); + void setSpanName(CharSequence spanName); - /** - * Implementation note: two calls with the same priority will result in the second resource name - * being used - */ - AgentSpan setResourceName(final CharSequence resourceName, byte priority); + /** + * Deprecated in favor of setResourceName(final CharSequence resourceName, byte priority) or using + * getResourceNamePriority() for comparisons. + */ + @Deprecated + boolean hasResourceName(); - /** RequestContext for the Instrumentation Gateway */ - RequestContext getRequestContext(); + byte getResourceNamePriority(); - Integer forceSamplingDecision(); + @Override + AgentSpan setResourceName(final CharSequence resourceName); - AgentSpan setSamplingPriority(final int newPriority, int samplingMechanism); + /** + * Implementation note: two calls with the same priority will result in the second resource name + * being used + */ + AgentSpan setResourceName(final CharSequence resourceName, byte priority); - @Nonnull - TraceConfig traceConfig(); + /** RequestContext for the Instrumentation Gateway */ + RequestContext getRequestContext(); - void addLink(AgentSpanLink link); + Integer forceSamplingDecision(); - AgentSpan setMetaStruct(final String field, final Object value); + AgentSpan setSamplingPriority(final int newPriority, int samplingMechanism); - boolean isOutbound(); + @Nonnull + TraceConfig traceConfig(); - /** - * Applies a {@link SpanPrototype} as fallback defaults: stamps its span type, constant tags, and - * integration name only where this span has not already set them. Prototype values are the lowest - * precedence -- anything explicitly set wins -- so {@code apply} never clobbers, is - * order-independent, and self-neutralizes once construction has already seeded the same - * prototype. - * - *

This is the single seam through which a prototype's constant initial state is applied, - * shared by the construction path (buildSpan/startSpan) and decorator {@code afterStart}. Core - * spans override to route straight to the context, which owns the tag map and will host the - * eventual fast path (bulk share / identity short-circuit); this default is the best-effort - * fallback for other span implementations. - */ - default void apply(@Nonnull final SpanPrototype prototype) { - if (getSpanType() == null) { - final CharSequence spanType = prototype.spanType(); - if (spanType != null) { - setSpanType(spanType); - } - } + void addLink(AgentSpanLink link); + + AgentSpan setMetaStruct(final String field, final Object value); + + boolean isOutbound(); - // Prototype tags are fallback defaults: only fill keys this span has not already set. - prototype - .tags() - .forEach( - (tag, value) -> { - if (getTag(tag) == null) { + /** + * Applies a {@link SpanPrototype} as fallback defaults: stamps its span type, constant tags, and + * integration name only where this span has not already set them. Prototype values are the lowest + * precedence -- anything explicitly set wins -- so {@code apply} never clobbers, is + * order-independent, and self-neutralizes once construction has already seeded the same + * prototype. + * + *

This is the single seam through which a prototype's constant initial state is applied, + * shared by the construction path (buildSpan/startSpan) and decorator {@code afterStart}. Core + * spans override to route straight to the context, which owns the tag map and will host the + * eventual fast path (bulk share / identity short-circuit); this default is the best-effort + * fallback for other span implementations. + */ + default void apply(@Nonnull final SpanPrototype prototype) { + if (getSpanType() == null) { + final CharSequence spanType = prototype.spanType(); + if (spanType != null) { + setSpanType(spanType); + } + } + + // Prototype tags are fallback defaults: only fill keys this span has not already set. + prototype.tags().forEach((tag, value) -> { + if (getTag(tag) == null) { setTag(tag, value); - } - }); - - // Never-clobber: only stamp the integration name when the context reports none. - if (spanContext().getIntegrationName() == null) { - final CharSequence integrationName = prototype.integrationName(); - if (integrationName != null) { - spanContext().setIntegrationName(integrationName); - } + } + }); + + // Never-clobber: only stamp the integration name when the context reports none. + if (spanContext().getIntegrationName() == null) { + final CharSequence integrationName = prototype.integrationName(); + if (integrationName != null) { + spanContext().setIntegrationName(integrationName); + } + } + } + + default AgentSpan asAgentSpan() { + return this; + } + + default void copyPropagationAndBaggage(final AgentSpan source) { + // no op default + } + + @Override + default Context storeInto(@Nonnull Context context) { + return context == Context.root() ? this : context.with(SPAN_KEY, this); + } + + @Nullable + @Override + default T get(@Nonnull ContextKey key) { + // noinspection unchecked + return SPAN_KEY == key ? (T) this : Context.root().get(key); + } + + @Override + default Context with(@Nonnull ContextKey key, @Nullable T value) { + return SPAN_KEY == key ? (Context) value : Context.root().with(SPAN_KEY, this, key, value); + } + + /** + * Attaches a context containing just the span to the current execution unit. Use this when you + * want to temporarily suppress any surrounding custom context during the span's scope. + * + * @return a scope to be closed when the span context is invalid. + */ + @Override + default ContextScope attach() { + return Context.super.attach(); + } + + /** + * Attaches a context combining the span with the current context to the current execution unit. + * Use this when you want to maintain any surrounding custom context during the span's scope. + * + * @return a scope to be closed when the combined context is invalid. + */ + default ContextScope attachWithContext() { + return storeInto(Context.current()).attach(); + } + + /** + * Captures a continuation of just the span so it can be resumed in another execution unit. Use + * this when you want to temporarily suppress any surrounding custom context during the span's + * continuation. + * + *

If async propagation is disabled nothing is captured and a no-op continuation is returned. + * + * @return a continuation capturing only this span; no-op continuation if async propagation + * disabled + */ + @Override + default ContextContinuation capture() { + return Context.super.capture(); + } + + /** + * Captures a continuation combining the span with the current context so it can be resumed in + * another execution unit. Use this when you want to maintain any surrounding custom context + * during the span's continuation + * + *

If async propagation is disabled nothing is captured and a no-op continuation is returned. + * + * @return a continuation capturing this span and any custom context; no-op continuation if async + * propagation disabled + */ + default ContextContinuation captureWithContext() { + return storeInto(Context.current()).capture(); + } + + /** + * Sets the service name without tracking it's source. + * + *

Note: please use setServiceName(String, CharSequence) instead. + * + * @param serviceName the service name. + * @return the span itself. + */ + @Override + @Deprecated + MutableSpan setServiceName(final String serviceName); + + /** + * Set the service name specifying the source (origin) of this name + * + * @param serviceName the service name + * @param source the source. Can be typically the name of the integration that overrides the + * default name. + */ + default void setServiceName(@Nonnull String serviceName, @Nonnull CharSequence source) { + setServiceName(serviceName); } - } - - default AgentSpan asAgentSpan() { - return this; - } - - default void copyPropagationAndBaggage(final AgentSpan source) { - // no op default - } - - @Override - default Context storeInto(@Nonnull Context context) { - return context == Context.root() ? this : context.with(SPAN_KEY, this); - } - - @Nullable - @Override - default T get(@Nonnull ContextKey key) { - // noinspection unchecked - return SPAN_KEY == key ? (T) this : Context.root().get(key); - } - - @Override - default Context with(@Nonnull ContextKey key, @Nullable T value) { - return SPAN_KEY == key ? (Context) value : Context.root().with(SPAN_KEY, this, key, value); - } - - /** - * Attaches a context containing just the span to the current execution unit. Use this when you - * want to temporarily suppress any surrounding custom context during the span's scope. - * - * @return a scope to be closed when the span context is invalid. - */ - @Override - default ContextScope attach() { - return Context.super.attach(); - } - - /** - * Attaches a context combining the span with the current context to the current execution unit. - * Use this when you want to maintain any surrounding custom context during the span's scope. - * - * @return a scope to be closed when the combined context is invalid. - */ - default ContextScope attachWithContext() { - return storeInto(Context.current()).attach(); - } - - /** - * Captures a continuation of just the span so it can be resumed in another execution unit. Use - * this when you want to temporarily suppress any surrounding custom context during the span's - * continuation. - * - *

If async propagation is disabled nothing is captured and a no-op continuation is returned. - * - * @return a continuation capturing only this span; no-op continuation if async propagation - * disabled - */ - @Override - default ContextContinuation capture() { - return Context.super.capture(); - } - - /** - * Captures a continuation combining the span with the current context so it can be resumed in - * another execution unit. Use this when you want to maintain any surrounding custom context - * during the span's continuation - * - *

If async propagation is disabled nothing is captured and a no-op continuation is returned. - * - * @return a continuation capturing this span and any custom context; no-op continuation if async - * propagation disabled - */ - default ContextContinuation captureWithContext() { - return storeInto(Context.current()).capture(); - } - - /** - * Sets the service name without tracking it's source. - * - *

Note: please use setServiceName(String, CharSequence) instead. - * - * @param serviceName the service name. - * @return the span itself. - */ - @Override - @Deprecated - MutableSpan setServiceName(final String serviceName); - - /** - * Set the service name specifying the source (origin) of this name - * - * @param serviceName the service name - * @param source the source. Can be typically the name of the integration that overrides the - * default name. - */ - default void setServiceName(@Nonnull String serviceName, @Nonnull CharSequence source) { - setServiceName(serviceName); - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java index 6e950925305..9483e0692e5 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java @@ -15,98 +15,98 @@ */ public interface AgentSpanContext { - /** - * Gets the TraceId of the span's trace. - * - * @return The TraceId of the span's trace, or {@link DDTraceId#ZERO} if not set. - */ - DDTraceId getTraceId(); - - /** - * Gets the SpanId. - * - * @return The span identifier, or {@link datadog.trace.api.DDSpanId#ZERO} if not set. - */ - long getSpanId(); - - /** - * Get the span's trace collector. - * - * @return The span's trace, or a noop {@link AgentTracer.NoopAgentTraceCollector#INSTANCE} if the - * trace is not valid. - */ - AgentTraceCollector getTraceCollector(); - - /** - * Gets the trace sampling priority of the span's trace. - * - *

Check {@link PrioritySampling} for possible values. - * - * @return The trace sampling priority of the span's trace, or {@link PrioritySampling#UNSET} if - * no priority has been set. - */ - int getSamplingPriority(); - - Iterable> baggageItems(); - - PathwayContext getPathwayContext(); - - default void mergePathwayContext(PathwayContext pathwayContext) {} - - default void setIntegrationName(CharSequence componentName) {} - - /** - * The integration name recorded on this context, or {@code null} if none. Default {@code null} - * mirrors {@link #setIntegrationName}'s no-op default: contexts that do not track an integration - * name report absence, which lets never-clobber callers guard before setting. - */ - default CharSequence getIntegrationName() { - return null; - } - - /** - * Gets whether the span context used is part of the local trace or from another service - * - * @return boolean representing if the span context is part of the local trace - */ - boolean isRemote(); - - interface Extracted extends AgentSpanContext { /** - * Gets the span links related to the other terminated context. + * Gets the TraceId of the span's trace. * - * @return The span links to other extracted contexts found but terminated. + * @return The TraceId of the span's trace, or {@link DDTraceId#ZERO} if not set. */ - List getTerminatedSpanLinks(); + DDTraceId getTraceId(); - String getForwarded(); + /** + * Gets the SpanId. + * + * @return The span identifier, or {@link datadog.trace.api.DDSpanId#ZERO} if not set. + */ + long getSpanId(); + + /** + * Get the span's trace collector. + * + * @return The span's trace, or a noop {@link AgentTracer.NoopAgentTraceCollector#INSTANCE} if the + * trace is not valid. + */ + AgentTraceCollector getTraceCollector(); + + /** + * Gets the trace sampling priority of the span's trace. + * + *

Check {@link PrioritySampling} for possible values. + * + * @return The trace sampling priority of the span's trace, or {@link PrioritySampling#UNSET} if + * no priority has been set. + */ + int getSamplingPriority(); + + Iterable> baggageItems(); + + PathwayContext getPathwayContext(); + + default void mergePathwayContext(PathwayContext pathwayContext) {} + + default void setIntegrationName(CharSequence componentName) {} + + /** + * The integration name recorded on this context, or {@code null} if none. Default {@code null} + * mirrors {@link #setIntegrationName}'s no-op default: contexts that do not track an integration + * name report absence, which lets never-clobber callers guard before setting. + */ + default CharSequence getIntegrationName() { + return null; + } + + /** + * Gets whether the span context used is part of the local trace or from another service + * + * @return boolean representing if the span context is part of the local trace + */ + boolean isRemote(); + + interface Extracted extends AgentSpanContext { + /** + * Gets the span links related to the other terminated context. + * + * @return The span links to other extracted contexts found but terminated. + */ + List getTerminatedSpanLinks(); + + String getForwarded(); - String getFastlyClientIp(); + String getFastlyClientIp(); - String getCfConnectingIp(); + String getCfConnectingIp(); - String getCfConnectingIpv6(); + String getCfConnectingIpv6(); - String getXForwardedProto(); + String getXForwardedProto(); - String getXForwardedHost(); + String getXForwardedHost(); - String getXForwardedPort(); + String getXForwardedPort(); - String getForwardedFor(); + String getForwardedFor(); - String getXForwardedFor(); + String getXForwardedFor(); - String getXClusterClientIp(); + String getXClusterClientIp(); - String getXRealIp(); + String getXRealIp(); - String getXClientIp(); + String getXClientIp(); - String getUserAgent(); + String getUserAgent(); - String getTrueClientIp(); + String getTrueClientIp(); - String getCustomIpHeader(); - } + String getCustomIpHeader(); + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanLink.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanLink.java index 8974ae4dfc7..dcecba58979 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanLink.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanLink.java @@ -7,48 +7,48 @@ * or not. */ public interface AgentSpanLink { - /** The default trace flags (no flag enabled). */ - byte DEFAULT_FLAGS = 0; - - /** The sampled flag denotes that the caller may have recorded trace data. */ - byte SAMPLED_FLAG = 1; - - /** - * Gets the trace identifier of the linked span. - * - * @return The trace identifier of the linked span. - */ - DDTraceId traceId(); - - /** - * Gets the span identifier of the linked span. - * - * @return The span identifier of the linked span as an unsigned 64-bit long. - */ - long spanId(); - - /** - * Gets the 8-bit field that controls tracing flags such as sampling, trace level, etc. - * - * @return The 8-bit field that controls tracing flags such as sampling, trace level, etc. - * @see Trace flag header W3C - * Specification - */ - byte traceFlags(); - - /** - * Gets the vendor-specific trace information as defined per W3C standard. - * - * @return The vendor-specific trace state. - * @see Trace state header W3C - * Specification - */ - String traceState(); - - /** - * Gets the link attributes. - * - * @return The link attributes. - */ - SpanAttributes attributes(); + /** The default trace flags (no flag enabled). */ + byte DEFAULT_FLAGS = 0; + + /** The sampled flag denotes that the caller may have recorded trace data. */ + byte SAMPLED_FLAG = 1; + + /** + * Gets the trace identifier of the linked span. + * + * @return The trace identifier of the linked span. + */ + DDTraceId traceId(); + + /** + * Gets the span identifier of the linked span. + * + * @return The span identifier of the linked span as an unsigned 64-bit long. + */ + long spanId(); + + /** + * Gets the 8-bit field that controls tracing flags such as sampling, trace level, etc. + * + * @return The 8-bit field that controls tracing flags such as sampling, trace level, etc. + * @see Trace flag header W3C + * Specification + */ + byte traceFlags(); + + /** + * Gets the vendor-specific trace information as defined per W3C standard. + * + * @return The vendor-specific trace state. + * @see Trace state header W3C + * Specification + */ + String traceState(); + + /** + * Gets the link attributes. + * + * @return The link attributes. + */ + SpanAttributes attributes(); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTraceCollector.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTraceCollector.java index b018795ffd1..df1dbb22305 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTraceCollector.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTraceCollector.java @@ -3,7 +3,7 @@ import datadog.context.ContextContinuation; public interface AgentTraceCollector { - void registerContinuation(ContextContinuation continuation); + void registerContinuation(ContextContinuation continuation); - void removeContinuation(ContextContinuation continuation); + void removeContinuation(ContextContinuation continuation); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 71767ea7fae..7e74228ce2d 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -33,823 +33,808 @@ public class AgentTracer { - /** - * @see TracerAPI#startSpan(String, CharSequence) - */ - public static AgentSpan startSpan(final String instrumentationName, final CharSequence spanName) { - return get().startSpan(instrumentationName, spanName); - } - - /** - * @see TracerAPI#startSpan(String, CharSequence, long) - */ - public static AgentSpan startSpan( - final String instrumentationName, final CharSequence spanName, final long startTimeMicros) { - return get().startSpan(instrumentationName, spanName, startTimeMicros); - } - - /** - * @see TracerAPI#startSpan(String, CharSequence, AgentSpanContext) - */ - public static AgentSpan startSpan( - final String instrumentationName, - final CharSequence spanName, - final AgentSpanContext parent) { - return get().startSpan(instrumentationName, spanName, parent); - } - - /** - * @see TracerAPI#startSpan(String, CharSequence, AgentSpanContext, long) - */ - public static AgentSpan startSpan( - final String instrumentationName, - final CharSequence spanName, - final AgentSpanContext parent, - final long startTimeMicros) { - return get().startSpan(instrumentationName, spanName, parent, startTimeMicros); - } - - /** - * @see TracerAPI#startSpan(SpanPrototype, CharSequence) - */ - public static AgentSpan startSpan( - @Nonnull final SpanPrototype prototype, final CharSequence operationName) { - return get().startSpan(prototype, operationName); - } - - public static ContextScope activateSpan(final AgentSpan span) { - return get().activateSpan(span); - } - - /** - * Activate a span which will be closed by {@link #closeActive()} instead of a scope. - * - * @deprecated This should only be used when the instrumented code doesn't align with a scope. - */ - @Deprecated - public static void activateSpanWithoutScope(final AgentSpan span) { - get().activateSpanWithoutScope(span); - } - - /** - * Checkpoints the active scope. A subsequent call to {@link #rollbackActiveToCheckpoint()} closes - * outstanding scopes up to but not including the most recent checkpointed scope. - * - * @deprecated This should only be used when scopes might leak onto the scope stack which cannot - * be cleaned up by other means. - */ - @Deprecated - public static void checkpointActiveForRollback() { - get().checkpointActiveForRollback(); - } - - /** - * Closes outstanding scopes up to but not including the most recent scope checkpointed with - * {@link #checkpointActiveForRollback()}. Closes all scopes if none have been checkpointed. - * - * @deprecated This should only be used when scopes have leaked onto the scope stack that cannot - * be cleaned up by other means. - */ - @Deprecated - public static void rollbackActiveToCheckpoint() { - get().rollbackActiveToCheckpoint(); - } - - /** - * Closes the scope for the currently active span. - * - * @deprecated This should only be used when the span was previously activated with {@link - * #activateSpanWithoutScope} because the instrumented code didn't align with a scope. - */ - @Deprecated - public static void closeActive() { - get().closeActive(); - } - - /** - * Closes the immediately previous iteration scope. Should be called before creating a new span - * for {@link #activateNext(AgentSpan)}. - */ - public static void closePrevious(final boolean finishSpan) { - get().closePrevious(finishSpan); - } - - /** - * Activates a new iteration scope; closes automatically after a fixed period. - * - * @see datadog.trace.api.config.TracerConfig#SCOPE_ITERATION_KEEP_ALIVE - */ - public static ContextScope activateNext(final AgentSpan span) { - return get().activateNext(span); - } - - public static TraceConfig traceConfig(final AgentSpan span) { - return null != span ? span.traceConfig() : traceConfig(); - } - - public static TraceConfig traceConfig() { - return get().captureTraceConfig(); - } - - public static AgentSpan activeSpan() { - return get().activeSpan(); - } - - /** - * Checks whether asynchronous propagation is enabled, meaning this context will propagate across - * asynchronous boundaries. - * - * @return {@code true} if asynchronous propagation is enabled, {@code false} otherwise. - */ - public static boolean isAsyncPropagationEnabled() { - return get().isAsyncPropagationEnabled(); - } - - /** - * Enables or disables asynchronous propagation for the active span. - * - *

Asynchronous propagation is enabled by default from {@link - * ConfigDefaults#DEFAULT_ASYNC_PROPAGATING}. - * - * @param asyncPropagationEnabled {@code true} to enable asynchronous propagation, {@code false} - * to disable it. - */ - public static void setAsyncPropagationEnabled(boolean asyncPropagationEnabled) { - get().setAsyncPropagationEnabled(asyncPropagationEnabled); - } - - /** - * Returns the noop span instance. - * - *

This instance will always be the same, and can be safely tested using object identity (ie - * {@code ==}). - * - * @return the noop span instance. - */ - public static AgentSpan noopSpan() { - return NoopSpan.INSTANCE; - } - - public static AgentSpan blackholeSpan() { - return get().blackholeSpan(); - } - - /** - * Returns the noop span context instance. - * - *

This instance will always be the same, and can be safely tested using object identity (ie - * {@code ==}). - * - * @return the noop scope instance. - */ - public static AgentSpanContext noopSpanContext() { - return NoopSpanContext.INSTANCE; - } - - public static final TracerAPI NOOP_TRACER = new NoopTracerAPI(); - - private static volatile TracerAPI provider = NOOP_TRACER; - - public static boolean isRegistered() { - return provider != NOOP_TRACER; - } - - @SuppressFBWarnings( - value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", - justification = "Agent-internal static holder; class lock guards private static provider") - public static synchronized void registerIfAbsent(final TracerAPI tracer) { - if (tracer != null && tracer != NOOP_TRACER) { - provider = tracer; - } - } - - @SuppressFBWarnings( - value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", - justification = "Agent-internal static holder; class lock guards private static provider") - public static synchronized void forceRegister(TracerAPI tracer) { - if (tracer == null) { - throw new IllegalArgumentException("tracer must not be null, use NOOP_TRACER instead"); - } - provider = tracer; - } - - public static TracerAPI get() { - return provider; - } - - // Not intended to be constructed. - private AgentTracer() {} - - public interface TracerAPI - extends datadog.trace.api.Tracer, InternalTracer, EndpointCheckpointer { - /** - * Create and start a new span. - * - * @param instrumentationName The instrumentation creating the span. - * @param spanName The span operation name. - * @return The new started span. + * @see TracerAPI#startSpan(String, CharSequence) */ - AgentSpan startSpan(String instrumentationName, CharSequence spanName); + public static AgentSpan startSpan(final String instrumentationName, final CharSequence spanName) { + return get().startSpan(instrumentationName, spanName); + } /** - * Create and start a new span with a given start time. - * - * @param instrumentationName The instrumentation creating the span. - * @param spanName The span operation name. - * @param startTimeMicros The span start time, in microseconds. - * @return The new started span. + * @see TracerAPI#startSpan(String, CharSequence, long) */ - AgentSpan startSpan(String instrumentationName, CharSequence spanName, long startTimeMicros); + public static AgentSpan startSpan( + final String instrumentationName, final CharSequence spanName, final long startTimeMicros) { + return get().startSpan(instrumentationName, spanName, startTimeMicros); + } /** - * Create and start a new span with an explicit parent. - * - * @param instrumentationName The instrumentation creating the span. - * @param spanName The span operation name. - * @param parent The parent span context. - * @return The new started span. + * @see TracerAPI#startSpan(String, CharSequence, AgentSpanContext) */ - AgentSpan startSpan(String instrumentationName, CharSequence spanName, AgentSpanContext parent); + public static AgentSpan startSpan( + final String instrumentationName, final CharSequence spanName, final AgentSpanContext parent) { + return get().startSpan(instrumentationName, spanName, parent); + } /** - * Create and start a new span with an explicit parent and a given start time. - * - * @param instrumentationName The instrumentation creating the span. - * @param spanName The span operation name. - * @param parent The parent span context. - * @param startTimeMicros The span start time, in microseconds. - * @return The new started span. + * @see TracerAPI#startSpan(String, CharSequence, AgentSpanContext, long) */ - AgentSpan startSpan( - String instrumentationName, - CharSequence spanName, - AgentSpanContext parent, - long startTimeMicros); - - /** Activate a span from inside auto-instrumentation. */ - ContextScope activateSpan(AgentSpan span); - - /** Activate a span from outside auto-instrumentation, i.e. a manual or custom span. */ - ContextScope activateManualSpan(AgentSpan span); - - /** Activate a span which will be closed by {@link #closeActive()} instead of a scope. */ - void activateSpanWithoutScope(AgentSpan span); - - @Override - default TraceScope.Continuation captureActiveSpan() { - Context current = currentContext(); - if (AgentSpan.fromContext(current) == null) { - return NoopTraceScope.NoopContinuation.INSTANCE; - } - // captureActiveSpan is deprecated; use a best-effort wrapper - return new TraceScopeContinuationWrapper(capture(current)); + public static AgentSpan startSpan( + final String instrumentationName, + final CharSequence spanName, + final AgentSpanContext parent, + final long startTimeMicros) { + return get().startSpan(instrumentationName, spanName, parent, startTimeMicros); } - void checkpointActiveForRollback(); - - void rollbackActiveToCheckpoint(); - - void closeActive(); - - void closePrevious(boolean finishSpan); - - ContextScope activateNext(AgentSpan span); - - AgentSpan activeSpan(); + /** + * @see TracerAPI#startSpan(SpanPrototype, CharSequence) + */ + public static AgentSpan startSpan(@Nonnull final SpanPrototype prototype, final CharSequence operationName) { + return get().startSpan(prototype, operationName); + } - default AgentSpan blackholeSpan() { - final AgentSpan active = activeSpan(); - return new BlackHoleSpan(active != null ? active.getTraceId() : DDTraceId.ZERO); + public static ContextScope activateSpan(final AgentSpan span) { + return get().activateSpan(span); } /** - * Returns a SpanBuilder that can be used to produce multiple spans. To minimize overhead, use - * of {@link #singleSpanBuilder(String, CharSequence)} is preferred when only a single span is - * being built. + * Activate a span which will be closed by {@link #closeActive()} instead of a scope. + * + * @deprecated This should only be used when the instrumented code doesn't align with a scope. */ - SpanBuilder buildSpan(String instrumentationName, CharSequence spanName); + @Deprecated + public static void activateSpanWithoutScope(final AgentSpan span) { + get().activateSpanWithoutScope(span); + } /** - * Returns a SpanBuilder seeded from a {@link SpanPrototype}: the prototype supplies the - * instrumentation name, span type, and constant tags. {@code operationName} overrides the - * prototype's when non-null — the explicit value wins, the prototype is the fallback. + * Checkpoints the active scope. A subsequent call to {@link #rollbackActiveToCheckpoint()} closes + * outstanding scopes up to but not including the most recent checkpointed scope. * - *

This default seeds identity only; a real tracer should override it to also seed the - * prototype's tags (see {@code CoreTracer}). The no-op tracer discards tags, so identity-only - * is correct there. + * @deprecated This should only be used when scopes might leak onto the scope stack which cannot + * be cleaned up by other means. */ - default SpanBuilder buildSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) { - return buildSpan( - prototype.instrumentationName(), - operationName != null ? operationName : prototype.operationName()); + @Deprecated + public static void checkpointActiveForRollback() { + get().checkpointActiveForRollback(); } /** - * Creates and starts a span seeded from a {@link SpanPrototype}. This is the - * auto-instrumentation entry point mirroring {@link #startSpan(String, CharSequence)}; see - * {@link #buildSpan(SpanPrototype, CharSequence)}. + * Closes outstanding scopes up to but not including the most recent scope checkpointed with + * {@link #checkpointActiveForRollback()}. Closes all scopes if none have been checkpointed. + * + * @deprecated This should only be used when scopes have leaked onto the scope stack that cannot + * be cleaned up by other means. */ - default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) { - return buildSpan(prototype, operationName).start(); + @Deprecated + public static void rollbackActiveToCheckpoint() { + get().rollbackActiveToCheckpoint(); } /** - * Returns a SpanBuilder that can be used to produce one and only one span. By imposing the - * single span creation limitation, this method is more efficient than {@link #buildSpan} + * Closes the scope for the currently active span. + * + * @deprecated This should only be used when the span was previously activated with {@link + * #activateSpanWithoutScope} because the instrumented code didn't align with a scope. */ - SpanBuilder singleSpanBuilder(String instrumentationName, CharSequence spanName); - - void close(); + @Deprecated + public static void closeActive() { + get().closeActive(); + } /** - * Attach a scope listener to the global scope manager - * - * @param listener listener to attach + * Closes the immediately previous iteration scope. Should be called before creating a new span + * for {@link #activateNext(AgentSpan)}. */ - void addScopeListener(ScopeListener listener); - - SubscriptionService getSubscriptionService(RequestContextSlot slot); - - CallbackProvider getCallbackProvider(RequestContextSlot slot); - - CallbackProvider getUniversalCallbackProvider(); - - AgentSpanContext notifyLambdaStart(Object event, String lambdaRequestId); - - void notifyExtensionEnd(AgentSpan span, Object result, boolean isError, String lambdaRequestId); - - void notifyAppSecEnd(AgentSpan span, Object result); - - AgentDataStreamsMonitoring getDataStreamsMonitoring(); - - String getTraceId(AgentSpan span); - - String getSpanId(AgentSpan span); - - TraceConfig captureTraceConfig(); - - ProfilingContextIntegration getProfilingContext(); + public static void closePrevious(final boolean finishSpan) { + get().closePrevious(finishSpan); + } /** - * Sets the new service name to be used as a default. + * Activates a new iteration scope; closes automatically after a fixed period. * - * @param serviceName The service name to use as default. - * @param source the source which is setting it. + * @see datadog.trace.api.config.TracerConfig#SCOPE_ITERATION_KEEP_ALIVE */ - void updatePreferredServiceName(String serviceName, CharSequence source); - - void addShutdownListener(Runnable listener); - - // these methods are only used for legacy context manager migration - - @Deprecated - Context currentContext(); - - @Deprecated - ContextScope attach(Context context); - - @Deprecated - Context swap(Context context); - - @Deprecated - ContextContinuation capture(Context context); - } - - public interface SpanBuilder { - AgentSpan start(); - - SpanBuilder asChildOf(AgentSpanContext toContext); - - SpanBuilder ignoreActiveSpan(); - - SpanBuilder withTag(String key, String value); - - SpanBuilder withTag(String key, boolean value); - - SpanBuilder withTag(String key, Number value); - - SpanBuilder withTag(String tag, Object value); - - SpanBuilder withStartTimestamp(long microseconds); - - SpanBuilder withServiceName(String serviceName); - - SpanBuilder withResourceName(String resourceName); - - SpanBuilder withErrorFlag(); - - SpanBuilder withSpanType(CharSequence spanType); - - SpanBuilder withRequestContextData(RequestContextSlot slot, T data); - - SpanBuilder withLink(AgentSpanLink link); - - SpanBuilder withSpanId(long spanId); - } - - static class NoopTracerAPI implements TracerAPI { - - protected NoopTracerAPI() {} - - @Override - public AgentSpan startSpan(final String instrumentationName, final CharSequence spanName) { - return NoopSpan.INSTANCE; + public static ContextScope activateNext(final AgentSpan span) { + return get().activateNext(span); } - @Override - public AgentSpan startSpan( - @Nonnull final SpanPrototype prototype, final CharSequence operationName) { - // The default routes through buildSpan(String,...), which is null on the noop tracer -> NPE. - return NoopSpan.INSTANCE; + public static TraceConfig traceConfig(final AgentSpan span) { + return null != span ? span.traceConfig() : traceConfig(); } - @Override - public AgentSpan startSpan( - final String instrumentationName, final CharSequence spanName, final long startTimeMicros) { - return NoopSpan.INSTANCE; + public static TraceConfig traceConfig() { + return get().captureTraceConfig(); } - @Override - public AgentSpan startSpan( - final String instrumentationName, - final CharSequence spanName, - final AgentSpanContext parent) { - return NoopSpan.INSTANCE; + public static AgentSpan activeSpan() { + return get().activeSpan(); } - @Override - public AgentSpan startSpan( - final String instrumentationName, - final CharSequence spanName, - final AgentSpanContext parent, - final long startTimeMicros) { - return NoopSpan.INSTANCE; + /** + * Checks whether asynchronous propagation is enabled, meaning this context will propagate across + * asynchronous boundaries. + * + * @return {@code true} if asynchronous propagation is enabled, {@code false} otherwise. + */ + public static boolean isAsyncPropagationEnabled() { + return get().isAsyncPropagationEnabled(); } - @Override - public ContextScope activateSpan(final AgentSpan span) { - return NoopScope.INSTANCE; + /** + * Enables or disables asynchronous propagation for the active span. + * + *

Asynchronous propagation is enabled by default from {@link + * ConfigDefaults#DEFAULT_ASYNC_PROPAGATING}. + * + * @param asyncPropagationEnabled {@code true} to enable asynchronous propagation, {@code false} + * to disable it. + */ + public static void setAsyncPropagationEnabled(boolean asyncPropagationEnabled) { + get().setAsyncPropagationEnabled(asyncPropagationEnabled); } - @Override - public ContextScope activateManualSpan(final AgentSpan span) { - return NoopScope.INSTANCE; + /** + * Returns the noop span instance. + * + *

This instance will always be the same, and can be safely tested using object identity (ie + * {@code ==}). + * + * @return the noop span instance. + */ + public static AgentSpan noopSpan() { + return NoopSpan.INSTANCE; } - @Override - public void activateSpanWithoutScope(final AgentSpan span) {} + public static AgentSpan blackholeSpan() { + return get().blackholeSpan(); + } - @Override - public boolean isAsyncPropagationEnabled() { - return false; + /** + * Returns the noop span context instance. + * + *

This instance will always be the same, and can be safely tested using object identity (ie + * {@code ==}). + * + * @return the noop scope instance. + */ + public static AgentSpanContext noopSpanContext() { + return NoopSpanContext.INSTANCE; + } + + public static final TracerAPI NOOP_TRACER = new NoopTracerAPI(); + + private static volatile TracerAPI provider = NOOP_TRACER; + + public static boolean isRegistered() { + return provider != NOOP_TRACER; } - @Override - public void setAsyncPropagationEnabled(boolean asyncPropagationEnabled) {} + @SuppressFBWarnings( + value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", + justification = "Agent-internal static holder; class lock guards private static provider") + public static synchronized void registerIfAbsent(final TracerAPI tracer) { + if (tracer != null && tracer != NOOP_TRACER) { + provider = tracer; + } + } + + @SuppressFBWarnings( + value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", + justification = "Agent-internal static holder; class lock guards private static provider") + public static synchronized void forceRegister(TracerAPI tracer) { + if (tracer == null) { + throw new IllegalArgumentException("tracer must not be null, use NOOP_TRACER instead"); + } + provider = tracer; + } + + public static TracerAPI get() { + return provider; + } + + // Not intended to be constructed. + private AgentTracer() {} + + public interface TracerAPI extends datadog.trace.api.Tracer, InternalTracer, EndpointCheckpointer { + + /** + * Create and start a new span. + * + * @param instrumentationName The instrumentation creating the span. + * @param spanName The span operation name. + * @return The new started span. + */ + AgentSpan startSpan(String instrumentationName, CharSequence spanName); + + /** + * Create and start a new span with a given start time. + * + * @param instrumentationName The instrumentation creating the span. + * @param spanName The span operation name. + * @param startTimeMicros The span start time, in microseconds. + * @return The new started span. + */ + AgentSpan startSpan(String instrumentationName, CharSequence spanName, long startTimeMicros); + + /** + * Create and start a new span with an explicit parent. + * + * @param instrumentationName The instrumentation creating the span. + * @param spanName The span operation name. + * @param parent The parent span context. + * @return The new started span. + */ + AgentSpan startSpan(String instrumentationName, CharSequence spanName, AgentSpanContext parent); + + /** + * Create and start a new span with an explicit parent and a given start time. + * + * @param instrumentationName The instrumentation creating the span. + * @param spanName The span operation name. + * @param parent The parent span context. + * @param startTimeMicros The span start time, in microseconds. + * @return The new started span. + */ + AgentSpan startSpan( + String instrumentationName, CharSequence spanName, AgentSpanContext parent, long startTimeMicros); + + /** Activate a span from inside auto-instrumentation. */ + ContextScope activateSpan(AgentSpan span); - @Override - public void checkpointActiveForRollback() {} + /** Activate a span from outside auto-instrumentation, i.e. a manual or custom span. */ + ContextScope activateManualSpan(AgentSpan span); - @Override - public void rollbackActiveToCheckpoint() {} + /** Activate a span which will be closed by {@link #closeActive()} instead of a scope. */ + void activateSpanWithoutScope(AgentSpan span); + + @Override + default TraceScope.Continuation captureActiveSpan() { + Context current = currentContext(); + if (AgentSpan.fromContext(current) == null) { + return NoopTraceScope.NoopContinuation.INSTANCE; + } + // captureActiveSpan is deprecated; use a best-effort wrapper + return new TraceScopeContinuationWrapper(capture(current)); + } - @Override - public void closeActive() {} + void checkpointActiveForRollback(); - @Override - public void closePrevious(final boolean finishSpan) {} + void rollbackActiveToCheckpoint(); - @Override - public ContextScope activateNext(final AgentSpan span) { - return NoopScope.INSTANCE; - } + void closeActive(); - @Override - public AgentSpan activeSpan() { - return NoopSpan.INSTANCE; - } + void closePrevious(boolean finishSpan); - @Override - public AgentSpan blackholeSpan() { - return NoopSpan.INSTANCE; // no-op tracer stays no-op - } + ContextScope activateNext(AgentSpan span); - @Override - public SpanBuilder buildSpan(final String instrumentationName, final CharSequence spanName) { - return null; - } + AgentSpan activeSpan(); - @Override - public SpanBuilder buildSpan( - @Nonnull final SpanPrototype prototype, final CharSequence operationName) { - // Mirrors buildSpan(String,...): the noop tracer returns a null builder. Callers that need a - // noop-safe entry point use startSpan(...), which is overridden above. A chainable - // NoopSpanBuilder would fix the null-vs-NoopSpan asymmetry, but that is a separate PR. - return null; - } + default AgentSpan blackholeSpan() { + final AgentSpan active = activeSpan(); + return new BlackHoleSpan(active != null ? active.getTraceId() : DDTraceId.ZERO); + } - @Override - public SpanBuilder singleSpanBuilder( - final String instrumentationName, final CharSequence spanName) { - return null; - } + /** + * Returns a SpanBuilder that can be used to produce multiple spans. To minimize overhead, use + * of {@link #singleSpanBuilder(String, CharSequence)} is preferred when only a single span is + * being built. + */ + SpanBuilder buildSpan(String instrumentationName, CharSequence spanName); - @Override - public void close() {} + /** + * Returns a SpanBuilder seeded from a {@link SpanPrototype}: the prototype supplies the + * instrumentation name, span type, and constant tags. {@code operationName} overrides the + * prototype's when non-null — the explicit value wins, the prototype is the fallback. + * + *

This default seeds identity only; a real tracer should override it to also seed the + * prototype's tags (see {@code CoreTracer}). The no-op tracer discards tags, so identity-only + * is correct there. + */ + default SpanBuilder buildSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) { + return buildSpan( + prototype.instrumentationName(), operationName != null ? operationName : prototype.operationName()); + } - @Override - public void addScopeListener( - Runnable afterScopeActivatedCallback, Runnable afterScopeClosedCallback) {} + /** + * Creates and starts a span seeded from a {@link SpanPrototype}. This is the + * auto-instrumentation entry point mirroring {@link #startSpan(String, CharSequence)}; see + * {@link #buildSpan(SpanPrototype, CharSequence)}. + */ + default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence operationName) { + return buildSpan(prototype, operationName).start(); + } - @Override - public void flush() {} + /** + * Returns a SpanBuilder that can be used to produce one and only one span. By imposing the + * single span creation limitation, this method is more efficient than {@link #buildSpan} + */ + SpanBuilder singleSpanBuilder(String instrumentationName, CharSequence spanName); - @Override - public void flushMetrics() {} + void close(); - @Override - public void flushLogs() {} + /** + * Attach a scope listener to the global scope manager + * + * @param listener listener to attach + */ + void addScopeListener(ScopeListener listener); - @Override - public ProfilingContextIntegration getProfilingContext() { - return ProfilingContextIntegration.NoOp.INSTANCE; - } + SubscriptionService getSubscriptionService(RequestContextSlot slot); - @Override - public TraceSegment getTraceSegment() { - return null; - } + CallbackProvider getCallbackProvider(RequestContextSlot slot); - @Override - public String getTraceId() { - return null; - } + CallbackProvider getUniversalCallbackProvider(); - @Override - public String getSpanId() { - return null; - } + AgentSpanContext notifyLambdaStart(Object event, String lambdaRequestId); - @Override - public String getTraceId(AgentSpan span) { - return null; - } + void notifyExtensionEnd(AgentSpan span, Object result, boolean isError, String lambdaRequestId); - @Override - public String getSpanId(AgentSpan span) { - return null; - } - - @Override - public boolean addTraceInterceptor(final TraceInterceptor traceInterceptor) { - return false; - } - - @Override - public TraceScope muteTracing() { - return NoopTraceScope.INSTANCE; - } + void notifyAppSecEnd(AgentSpan span, Object result); - @Override - public DataStreamsCheckpointer getDataStreamsCheckpointer() { - return getDataStreamsMonitoring(); - } + AgentDataStreamsMonitoring getDataStreamsMonitoring(); - @Override - public void addShutdownListener(Runnable listener) {} + String getTraceId(AgentSpan span); - @Override - public void addScopeListener(final ScopeListener listener) {} + String getSpanId(AgentSpan span); - @Override - public SubscriptionService getSubscriptionService(RequestContextSlot slot) { - return SubscriptionService.SubscriptionServiceNoop.INSTANCE; - } + TraceConfig captureTraceConfig(); - @Override - public CallbackProvider getCallbackProvider(RequestContextSlot slot) { - return CallbackProvider.CallbackProviderNoop.INSTANCE; - } + ProfilingContextIntegration getProfilingContext(); - @Override - public CallbackProvider getUniversalCallbackProvider() { - return CallbackProvider.CallbackProviderNoop.INSTANCE; - } + /** + * Sets the new service name to be used as a default. + * + * @param serviceName The service name to use as default. + * @param source the source which is setting it. + */ + void updatePreferredServiceName(String serviceName, CharSequence source); - @Override - public void onRootSpanFinished(AgentSpan root, EndpointTracker tracker) {} + void addShutdownListener(Runnable listener); - @Override - public EndpointTracker onRootSpanStarted(AgentSpan root) { - return EndpointTracker.NO_OP; - } + // these methods are only used for legacy context manager migration - @Override - public AgentSpanContext notifyLambdaStart(Object event, String lambdaRequestId) { - return null; - } + @Deprecated + Context currentContext(); - @Override - public void notifyExtensionEnd( - AgentSpan span, Object result, boolean isError, String lambdaRequestId) {} + @Deprecated + ContextScope attach(Context context); - @Override - public void notifyAppSecEnd(AgentSpan span, Object result) {} + @Deprecated + Context swap(Context context); - @Override - public AgentDataStreamsMonitoring getDataStreamsMonitoring() { - return NoopDataStreamsMonitoring.INSTANCE; + @Deprecated + ContextContinuation capture(Context context); } - @Override - public TraceConfig captureTraceConfig() { - return NoopTraceConfig.INSTANCE; - } + public interface SpanBuilder { + AgentSpan start(); - @Override - public void updatePreferredServiceName(String serviceName, CharSequence preferredServiceName) { - // no ops - } + SpanBuilder asChildOf(AgentSpanContext toContext); - @Override - public Context currentContext() { - return Context.root(); - } + SpanBuilder ignoreActiveSpan(); - @Override - public ContextScope attach(Context context) { - return NoopScope.INSTANCE; - } + SpanBuilder withTag(String key, String value); - @Override - public Context swap(Context context) { - return Context.root(); - } + SpanBuilder withTag(String key, boolean value); - @Override - public ContextContinuation capture(Context context) { - return NoopContinuation.INSTANCE; - } - } + SpanBuilder withTag(String key, Number value); - public static class NoopAgentTraceCollector implements AgentTraceCollector { - public static final NoopAgentTraceCollector INSTANCE = new NoopAgentTraceCollector(); + SpanBuilder withTag(String tag, Object value); - @Override - public void registerContinuation(final ContextContinuation continuation) {} + SpanBuilder withStartTimestamp(long microseconds); - @Override - public void removeContinuation(final ContextContinuation continuation) {} - } + SpanBuilder withServiceName(String serviceName); - /** TraceConfig when there is no tracer; this is not the same as a default config. */ - public static final class NoopTraceConfig implements TraceConfig { - public static final NoopTraceConfig INSTANCE = new NoopTraceConfig(); + SpanBuilder withResourceName(String resourceName); - @Override - public boolean isTraceEnabled() { - return false; - } + SpanBuilder withErrorFlag(); - @Override - public boolean isRuntimeMetricsEnabled() { - return false; - } + SpanBuilder withSpanType(CharSequence spanType); - @Override - public boolean isLogsInjectionEnabled() { - return false; - } + SpanBuilder withRequestContextData(RequestContextSlot slot, T data); - @Override - public boolean isDataStreamsEnabled() { - return false; - } + SpanBuilder withLink(AgentSpanLink link); - @Override - public Map getServiceMapping() { - return Collections.emptyMap(); + SpanBuilder withSpanId(long spanId); } - @Override - public Map getRequestHeaderTags() { - return Collections.emptyMap(); - } + static class NoopTracerAPI implements TracerAPI { - @Override - public Map getResponseHeaderTags() { - return Collections.emptyMap(); - } + protected NoopTracerAPI() {} - @Override - public Map getBaggageMapping() { - return Collections.emptyMap(); - } + @Override + public AgentSpan startSpan(final String instrumentationName, final CharSequence spanName) { + return NoopSpan.INSTANCE; + } - @Override - public Double getTraceSampleRate() { - return null; - } + @Override + public AgentSpan startSpan(@Nonnull final SpanPrototype prototype, final CharSequence operationName) { + // The default routes through buildSpan(String,...), which is null on the noop tracer -> NPE. + return NoopSpan.INSTANCE; + } - @Override - public Map getTracingTags() { - return Collections.emptyMap(); - } + @Override + public AgentSpan startSpan( + final String instrumentationName, final CharSequence spanName, final long startTimeMicros) { + return NoopSpan.INSTANCE; + } - @Override - public Pair getPreferredServiceNameAndSource() { - return null; - } + @Override + public AgentSpan startSpan( + final String instrumentationName, final CharSequence spanName, final AgentSpanContext parent) { + return NoopSpan.INSTANCE; + } - @Override - public List getSpanSamplingRules() { - return Collections.emptyList(); - } + @Override + public AgentSpan startSpan( + final String instrumentationName, + final CharSequence spanName, + final AgentSpanContext parent, + final long startTimeMicros) { + return NoopSpan.INSTANCE; + } - @Override - public List getTraceSamplingRules() { - return Collections.emptyList(); - } + @Override + public ContextScope activateSpan(final AgentSpan span) { + return NoopScope.INSTANCE; + } - @Override - public List getDataStreamsTransactionExtractors() { - return null; - } - } + @Override + public ContextScope activateManualSpan(final AgentSpan span) { + return NoopScope.INSTANCE; + } - /** - * Decides whether to install the legacy approach to managing contexts, which requires a tracer. - * - *

Must be called ahead of instrumentation, before any use of the Context API. - */ - public static void maybeInstallLegacyContextManager() { - installLegacyContextManager(); // install everywhere to begin with - } + @Override + public void activateSpanWithoutScope(final AgentSpan span) {} - /** - * Always install the legacy approach to managing contexts, which requires a tracer. - * - *

Must be called ahead of instrumentation, before any use of the Context API. - */ - public static void installLegacyContextManager() { - ContextManager.register(LegacyContextManager.INSTANCE); - } + @Override + public boolean isAsyncPropagationEnabled() { + return false; + } - /** Shim mapping new Context API to legacy scope manager. */ - static final class LegacyContextManager implements ContextManager { - static final LegacyContextManager INSTANCE = new LegacyContextManager(); + @Override + public void setAsyncPropagationEnabled(boolean asyncPropagationEnabled) {} - @Override - public Context current() { - return AgentTracer.get().currentContext(); - } + @Override + public void checkpointActiveForRollback() {} - @Override - public ContextScope attach(@Nonnull Context context) { - return AgentTracer.get().attach(context); - } + @Override + public void rollbackActiveToCheckpoint() {} - @Override - public Context swap(@Nonnull Context context) { - return AgentTracer.get().swap(context); - } + @Override + public void closeActive() {} - @Override - public ContextContinuation capture(@Nonnull Context context) { - return AgentTracer.get().capture(context); - } + @Override + public void closePrevious(final boolean finishSpan) {} - @Override - public void addListener(@Nonnull ContextListener listener) { - // this method is never used in legacy mode... - } - } + @Override + public ContextScope activateNext(final AgentSpan span) { + return NoopScope.INSTANCE; + } - /** Adapts a {@link ContextContinuation} to the deprecated {@link TraceScope.Continuation} SPI. */ - private static final class TraceScopeContinuationWrapper implements TraceScope.Continuation { - private final ContextContinuation continuation; + @Override + public AgentSpan activeSpan() { + return NoopSpan.INSTANCE; + } - TraceScopeContinuationWrapper(ContextContinuation continuation) { - this.continuation = continuation; - } + @Override + public AgentSpan blackholeSpan() { + return NoopSpan.INSTANCE; // no-op tracer stays no-op + } + + @Override + public SpanBuilder buildSpan(final String instrumentationName, final CharSequence spanName) { + return null; + } + + @Override + public SpanBuilder buildSpan(@Nonnull final SpanPrototype prototype, final CharSequence operationName) { + // Mirrors buildSpan(String,...): the noop tracer returns a null builder. Callers that need a + // noop-safe entry point use startSpan(...), which is overridden above. A chainable + // NoopSpanBuilder would fix the null-vs-NoopSpan asymmetry, but that is a separate PR. + return null; + } + + @Override + public SpanBuilder singleSpanBuilder(final String instrumentationName, final CharSequence spanName) { + return null; + } + + @Override + public void close() {} + + @Override + public void addScopeListener(Runnable afterScopeActivatedCallback, Runnable afterScopeClosedCallback) {} + + @Override + public void flush() {} + + @Override + public void flushMetrics() {} + + @Override + public void flushLogs() {} + + @Override + public ProfilingContextIntegration getProfilingContext() { + return ProfilingContextIntegration.NoOp.INSTANCE; + } + + @Override + public TraceSegment getTraceSegment() { + return null; + } + + @Override + public String getTraceId() { + return null; + } + + @Override + public String getSpanId() { + return null; + } + + @Override + public String getTraceId(AgentSpan span) { + return null; + } + + @Override + public String getSpanId(AgentSpan span) { + return null; + } + + @Override + public boolean addTraceInterceptor(final TraceInterceptor traceInterceptor) { + return false; + } + + @Override + public TraceScope muteTracing() { + return NoopTraceScope.INSTANCE; + } + + @Override + public DataStreamsCheckpointer getDataStreamsCheckpointer() { + return getDataStreamsMonitoring(); + } + + @Override + public void addShutdownListener(Runnable listener) {} + + @Override + public void addScopeListener(final ScopeListener listener) {} + + @Override + public SubscriptionService getSubscriptionService(RequestContextSlot slot) { + return SubscriptionService.SubscriptionServiceNoop.INSTANCE; + } + + @Override + public CallbackProvider getCallbackProvider(RequestContextSlot slot) { + return CallbackProvider.CallbackProviderNoop.INSTANCE; + } + + @Override + public CallbackProvider getUniversalCallbackProvider() { + return CallbackProvider.CallbackProviderNoop.INSTANCE; + } + + @Override + public void onRootSpanFinished(AgentSpan root, EndpointTracker tracker) {} + + @Override + public EndpointTracker onRootSpanStarted(AgentSpan root) { + return EndpointTracker.NO_OP; + } + + @Override + public AgentSpanContext notifyLambdaStart(Object event, String lambdaRequestId) { + return null; + } + + @Override + public void notifyExtensionEnd(AgentSpan span, Object result, boolean isError, String lambdaRequestId) {} + + @Override + public void notifyAppSecEnd(AgentSpan span, Object result) {} + + @Override + public AgentDataStreamsMonitoring getDataStreamsMonitoring() { + return NoopDataStreamsMonitoring.INSTANCE; + } + + @Override + public TraceConfig captureTraceConfig() { + return NoopTraceConfig.INSTANCE; + } + + @Override + public void updatePreferredServiceName(String serviceName, CharSequence preferredServiceName) { + // no ops + } + + @Override + public Context currentContext() { + return Context.root(); + } + + @Override + public ContextScope attach(Context context) { + return NoopScope.INSTANCE; + } + + @Override + public Context swap(Context context) { + return Context.root(); + } + + @Override + public ContextContinuation capture(Context context) { + return NoopContinuation.INSTANCE; + } + } + + public static class NoopAgentTraceCollector implements AgentTraceCollector { + public static final NoopAgentTraceCollector INSTANCE = new NoopAgentTraceCollector(); + + @Override + public void registerContinuation(final ContextContinuation continuation) {} + + @Override + public void removeContinuation(final ContextContinuation continuation) {} + } + + /** TraceConfig when there is no tracer; this is not the same as a default config. */ + public static final class NoopTraceConfig implements TraceConfig { + public static final NoopTraceConfig INSTANCE = new NoopTraceConfig(); + + @Override + public boolean isTraceEnabled() { + return false; + } + + @Override + public boolean isRuntimeMetricsEnabled() { + return false; + } + + @Override + public boolean isLogsInjectionEnabled() { + return false; + } + + @Override + public boolean isDataStreamsEnabled() { + return false; + } + + @Override + public Map getServiceMapping() { + return Collections.emptyMap(); + } + + @Override + public Map getRequestHeaderTags() { + return Collections.emptyMap(); + } + + @Override + public Map getResponseHeaderTags() { + return Collections.emptyMap(); + } + + @Override + public Map getBaggageMapping() { + return Collections.emptyMap(); + } + + @Override + public Double getTraceSampleRate() { + return null; + } + + @Override + public Map getTracingTags() { + return Collections.emptyMap(); + } + + @Override + public Pair getPreferredServiceNameAndSource() { + return null; + } + + @Override + public List getSpanSamplingRules() { + return Collections.emptyList(); + } + + @Override + public List getTraceSamplingRules() { + return Collections.emptyList(); + } - @Override - public TraceScope.Continuation hold() { - continuation.hold(); - return this; + @Override + public List getDataStreamsTransactionExtractors() { + return null; + } } - @Override - @SuppressWarnings("resource") - public TraceScope activate() { - ContextScope scope = continuation.resume(); - return scope instanceof TraceScope ? (TraceScope) scope : scope::close; + /** + * Decides whether to install the legacy approach to managing contexts, which requires a tracer. + * + *

Must be called ahead of instrumentation, before any use of the Context API. + */ + public static void maybeInstallLegacyContextManager() { + installLegacyContextManager(); // install everywhere to begin with } - @Override - public void cancel() { - continuation.release(); + /** + * Always install the legacy approach to managing contexts, which requires a tracer. + * + *

Must be called ahead of instrumentation, before any use of the Context API. + */ + public static void installLegacyContextManager() { + ContextManager.register(LegacyContextManager.INSTANCE); + } + + /** Shim mapping new Context API to legacy scope manager. */ + static final class LegacyContextManager implements ContextManager { + static final LegacyContextManager INSTANCE = new LegacyContextManager(); + + @Override + public Context current() { + return AgentTracer.get().currentContext(); + } + + @Override + public ContextScope attach(@Nonnull Context context) { + return AgentTracer.get().attach(context); + } + + @Override + public Context swap(@Nonnull Context context) { + return AgentTracer.get().swap(context); + } + + @Override + public ContextContinuation capture(@Nonnull Context context) { + return AgentTracer.get().capture(context); + } + + @Override + public void addListener(@Nonnull ContextListener listener) { + // this method is never used in legacy mode... + } + } + + /** Adapts a {@link ContextContinuation} to the deprecated {@link TraceScope.Continuation} SPI. */ + private static final class TraceScopeContinuationWrapper implements TraceScope.Continuation { + private final ContextContinuation continuation; + + TraceScopeContinuationWrapper(ContextContinuation continuation) { + this.continuation = continuation; + } + + @Override + public TraceScope.Continuation hold() { + continuation.hold(); + return this; + } + + @Override + @SuppressWarnings("resource") + public TraceScope activate() { + ContextScope scope = continuation.resume(); + return scope instanceof TraceScope ? (TraceScope) scope : scope::close; + } + + @Override + public void cancel() { + continuation.release(); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AppendableSpanLinks.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AppendableSpanLinks.java index f1cc5325695..e64e2fa5634 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AppendableSpanLinks.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AppendableSpanLinks.java @@ -3,5 +3,5 @@ /** Interface that provides the ability to append a span link */ @FunctionalInterface public interface AppendableSpanLinks { - void addLink(AgentSpanLink link); + void addLink(AgentSpanLink link); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AttachableWrapper.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AttachableWrapper.java index 61a835938e2..e7d40cc9bbe 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AttachableWrapper.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AttachableWrapper.java @@ -6,10 +6,10 @@ /** Cache interface for OT/OTel wrappers used by TypeConverters to avoid extra allocations. */ public interface AttachableWrapper { - /** Attaches a OT/OTel wrapper to a tracer object. */ - void attachWrapper(@Nonnull SpanWrapper wrapper); + /** Attaches a OT/OTel wrapper to a tracer object. */ + void attachWrapper(@Nonnull SpanWrapper wrapper); - /** Returns an attached OT/OTel wrapper or null. */ - @Nullable - SpanWrapper getWrapper(); + /** Returns an attached OT/OTel wrapper or null. */ + @Nullable + SpanWrapper getWrapper(); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Baggage.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Baggage.java index a66ec83a331..162582db9c0 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Baggage.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Baggage.java @@ -13,111 +13,111 @@ /** Baggage are key/value store which propagate alongside {@link Context}. */ public class Baggage implements ImplicitContextKeyed { - private static final ContextKey CONTEXT_KEY = named("baggage-key"); - private final Map items; + private static final ContextKey CONTEXT_KEY = named("baggage-key"); + private final Map items; - /** - * The W3C Baggage header representation of the - * baggage instance, {@code null} if not in sync with the current baggage items. - */ - private String w3cHeader; + /** + * The W3C Baggage header representation of the + * baggage instance, {@code null} if not in sync with the current baggage items. + */ + private String w3cHeader; - /** - * Gets baggage from context. - * - * @param context The context to get baggage from. - * @return The baggage from the given context if any, {@code null} if none. - */ - public static @Nullable Baggage fromContext(Context context) { - return context.get(CONTEXT_KEY); - } + /** + * Gets baggage from context. + * + * @param context The context to get baggage from. + * @return The baggage from the given context if any, {@code null} if none. + */ + public static @Nullable Baggage fromContext(Context context) { + return context.get(CONTEXT_KEY); + } - /** - * Create empty baggage. - * - * @return Empty baggage. - */ - public static Baggage empty() { - return create(new HashMap<>(), ""); - } + /** + * Create empty baggage. + * + * @return Empty baggage. + */ + public static Baggage empty() { + return create(new HashMap<>(), ""); + } - /** - * Create baggage from items. - * - * @param items The original baggage items. - * @return Baggage with the given items. - */ - public static Baggage create(Map items) { - return new Baggage(items, null); - } + /** + * Create baggage from items. + * + * @param items The original baggage items. + * @return Baggage with the given items. + */ + public static Baggage create(Map items) { + return new Baggage(items, null); + } - /** - * Create baggage from items. - * - * @param items The original baggage items. - * @param w3cHeader The W3C Baggage header representation. - * @return Baggage with the given items and its W3C header cached. - */ - public static Baggage create(Map items, String w3cHeader) { - return new Baggage(items, w3cHeader); - } + /** + * Create baggage from items. + * + * @param items The original baggage items. + * @param w3cHeader The W3C Baggage header representation. + * @return Baggage with the given items and its W3C header cached. + */ + public static Baggage create(Map items, String w3cHeader) { + return new Baggage(items, w3cHeader); + } - private Baggage(Map items, String w3cHeader) { - this.items = items; - this.w3cHeader = w3cHeader; - } + private Baggage(Map items, String w3cHeader) { + this.items = items; + this.w3cHeader = w3cHeader; + } - /** - * Adds a baggage item. - * - * @param key The item key. - * @param value The item value. - */ - public void addItem(String key, String value) { - this.items.put(key, value); - this.w3cHeader = null; - } + /** + * Adds a baggage item. + * + * @param key The item key. + * @param value The item value. + */ + public void addItem(String key, String value) { + this.items.put(key, value); + this.w3cHeader = null; + } - /** - * Removes a baggage item. - * - * @param key The item key to remove. - */ - public void removeItem(String key) { - if (this.items.remove(key) != null) { - this.w3cHeader = null; + /** + * Removes a baggage item. + * + * @param key The item key to remove. + */ + public void removeItem(String key) { + if (this.items.remove(key) != null) { + this.w3cHeader = null; + } } - } - /** - * Gets the W3C Baggage header representation. - * - * @return The header value, {@code null} if not in sync with the current baggage items. - */ - public @Nullable String getW3cHeader() { - return this.w3cHeader; - } + /** + * Gets the W3C Baggage header representation. + * + * @return The header value, {@code null} if not in sync with the current baggage items. + */ + public @Nullable String getW3cHeader() { + return this.w3cHeader; + } - /** - * Updates the W3C Baggage header representation. - * - * @param w3cHeader The header value. - */ - public void setW3cHeader(String w3cHeader) { - this.w3cHeader = w3cHeader; - } + /** + * Updates the W3C Baggage header representation. + * + * @param w3cHeader The header value. + */ + public void setW3cHeader(String w3cHeader) { + this.w3cHeader = w3cHeader; + } - /** - * Gets a view of the baggage items. - * - * @return The read-only view of the baggage items. - */ - public Map asMap() { - return unmodifiableMap(this.items); - } + /** + * Gets a view of the baggage items. + * + * @return The read-only view of the baggage items. + */ + public Map asMap() { + return unmodifiableMap(this.items); + } - @Override - public Context storeInto(@Nonnull Context context) { - return context.with(CONTEXT_KEY, this); - } + @Override + public Context storeInto(@Nonnull Context context) { + return context.with(CONTEXT_KEY, this); + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/BlackHoleSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/BlackHoleSpan.java index f5e477d2054..10555c88677 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/BlackHoleSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/BlackHoleSpan.java @@ -4,32 +4,31 @@ /** An {@link AgentSpan} implementation that stops context propagation. */ public final class BlackHoleSpan extends NoopSpan { - private final DDTraceId traceId; - - public BlackHoleSpan(final DDTraceId traceId) { - this.traceId = traceId; - } - - @Override - public boolean isSameTrace(final AgentSpan otherSpan) { - return otherSpan != null - && ((traceId != null && traceId.equals(otherSpan.getTraceId())) - || otherSpan.getTraceId() == null); - } - - @Override - public DDTraceId getTraceId() { - return traceId; - } - - @Override - public AgentSpanContext spanContext() { - return Context.INSTANCE; - } - - public static final class Context extends NoopSpanContext { - public static final Context INSTANCE = new Context(); - - private Context() {} - } + private final DDTraceId traceId; + + public BlackHoleSpan(final DDTraceId traceId) { + this.traceId = traceId; + } + + @Override + public boolean isSameTrace(final AgentSpan otherSpan) { + return otherSpan != null + && ((traceId != null && traceId.equals(otherSpan.getTraceId())) || otherSpan.getTraceId() == null); + } + + @Override + public DDTraceId getTraceId() { + return traceId; + } + + @Override + public AgentSpanContext spanContext() { + return Context.INSTANCE; + } + + public static final class Context extends NoopSpanContext { + public static final Context INSTANCE = new Context(); + + private Context() {} + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ClientIpAddressData.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ClientIpAddressData.java index d22bcde4618..834af4a7e56 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ClientIpAddressData.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ClientIpAddressData.java @@ -5,19 +5,19 @@ * (such as AI Guard) can apply it lazily to the local root span without re-running the resolver. */ public final class ClientIpAddressData { - private final String peerIp; - private final String inferredClientIp; + private final String peerIp; + private final String inferredClientIp; - public ClientIpAddressData(final String peerIp, final String inferredClientIp) { - this.peerIp = peerIp; - this.inferredClientIp = inferredClientIp; - } + public ClientIpAddressData(final String peerIp, final String inferredClientIp) { + this.peerIp = peerIp; + this.inferredClientIp = inferredClientIp; + } - public String getPeerIp() { - return peerIp; - } + public String getPeerIp() { + return peerIp; + } - public String getInferredClientIp() { - return inferredClientIp; - } + public String getInferredClientIp() { + return inferredClientIp; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ErrorPriorities.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ErrorPriorities.java index d1a5525efdf..6f86abe1fba 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ErrorPriorities.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ErrorPriorities.java @@ -1,9 +1,9 @@ package datadog.trace.bootstrap.instrumentation.api; public class ErrorPriorities { - public static final byte UNSET = Byte.MIN_VALUE; - public static final byte HTTP_SERVER_DECORATOR = -1; + public static final byte UNSET = Byte.MIN_VALUE; + public static final byte HTTP_SERVER_DECORATOR = -1; - public static final byte DEFAULT = 0; - public static final byte MANUAL_INSTRUMENTATION = 1; + public static final byte DEFAULT = 0; + public static final byte MANUAL_INSTRUMENTATION = 1; } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpan.java index d589141b8d2..4cb1bfddd7d 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpan.java @@ -14,158 +14,158 @@ * propagated information into span context, until they migrate to the new context API. */ class ExtractedSpan extends ImmutableSpan { - private final AgentSpanContext spanContext; - - ExtractedSpan(AgentSpanContext spanContext) { - this.spanContext = spanContext; - } - - @Override - public DDTraceId getTraceId() { - return this.spanContext.getTraceId(); - } - - @Override - public long getSpanId() { - return this.spanContext.getSpanId(); - } - - @Override - public AgentSpan getRootSpan() { - return this; - } - - @Override - public AgentSpan getLocalRootSpan() { - return this; - } - - @Override - public boolean isError() { - return false; - } - - @Override - public short getHttpStatusCode() { - return 0; - } - - @Override - public CharSequence getSpanName() { - return ""; - } - - @Override - public boolean hasResourceName() { - return false; - } - - @Override - public byte getResourceNamePriority() { - return Byte.MAX_VALUE; - } - - @Override - public long getStartTime() { - return 0; - } - - @Override - public long getDurationNano() { - return 0; - } - - @Override - public CharSequence getOperationName() { - return null; - } - - @Override - public String getServiceName() { - return ""; - } - - @Override - public CharSequence getResourceName() { - return null; - } - - @Override - public Integer getSamplingPriority() { - return this.spanContext.getSamplingPriority(); - } - - @Override - public String getSpanType() { - return null; - } - - @Override - public boolean isOutbound() { - return false; - } - - @Override - public Object getTag(final String tag) { - if (this.spanContext instanceof TagContext) { - return ((TagContext) this.spanContext).getTags().getObject(tag); - } - return null; - } - - @Override - public TagMap getTags() { - if (this.spanContext instanceof TagContext) { - return ((TagContext) this.spanContext).getTags(); - } else { - return TagMap.EMPTY; - } - } - - @Override - public String getBaggageItem(final String key) { - Iterable> baggage = this.spanContext.baggageItems(); - for (Map.Entry stringStringEntry : baggage) { - if (stringStringEntry.getKey().equals(key)) { - return stringStringEntry.getValue(); - } - } - return null; - } - - @Override - public AgentSpanContext spanContext() { - return this.spanContext; - } - - @Override - public TraceConfig traceConfig() { - if (this.spanContext instanceof TagContext) { - TraceConfig traceConfig = ((TagContext) this.spanContext).getTraceConfig(); - if (traceConfig != null) { - return traceConfig; - } - } - return AgentTracer.traceConfig(); - } - - @Override - public boolean isSameTrace(AgentSpan otherSpan) { - return null != otherSpan && getTraceId().equals(otherSpan.getTraceId()); - } - - @Override - public RequestContext getRequestContext() { - return RequestContext.Noop.INSTANCE; - } - - @Override - public RequestBlockingAction getRequestBlockingAction() { - return null; - } - - @Override - public String toString() { - return "ExtractedSpan{spanContext=" + this.spanContext + '}'; - } + private final AgentSpanContext spanContext; + + ExtractedSpan(AgentSpanContext spanContext) { + this.spanContext = spanContext; + } + + @Override + public DDTraceId getTraceId() { + return this.spanContext.getTraceId(); + } + + @Override + public long getSpanId() { + return this.spanContext.getSpanId(); + } + + @Override + public AgentSpan getRootSpan() { + return this; + } + + @Override + public AgentSpan getLocalRootSpan() { + return this; + } + + @Override + public boolean isError() { + return false; + } + + @Override + public short getHttpStatusCode() { + return 0; + } + + @Override + public CharSequence getSpanName() { + return ""; + } + + @Override + public boolean hasResourceName() { + return false; + } + + @Override + public byte getResourceNamePriority() { + return Byte.MAX_VALUE; + } + + @Override + public long getStartTime() { + return 0; + } + + @Override + public long getDurationNano() { + return 0; + } + + @Override + public CharSequence getOperationName() { + return null; + } + + @Override + public String getServiceName() { + return ""; + } + + @Override + public CharSequence getResourceName() { + return null; + } + + @Override + public Integer getSamplingPriority() { + return this.spanContext.getSamplingPriority(); + } + + @Override + public String getSpanType() { + return null; + } + + @Override + public boolean isOutbound() { + return false; + } + + @Override + public Object getTag(final String tag) { + if (this.spanContext instanceof TagContext) { + return ((TagContext) this.spanContext).getTags().getObject(tag); + } + return null; + } + + @Override + public TagMap getTags() { + if (this.spanContext instanceof TagContext) { + return ((TagContext) this.spanContext).getTags(); + } else { + return TagMap.EMPTY; + } + } + + @Override + public String getBaggageItem(final String key) { + Iterable> baggage = this.spanContext.baggageItems(); + for (Map.Entry stringStringEntry : baggage) { + if (stringStringEntry.getKey().equals(key)) { + return stringStringEntry.getValue(); + } + } + return null; + } + + @Override + public AgentSpanContext spanContext() { + return this.spanContext; + } + + @Override + public TraceConfig traceConfig() { + if (this.spanContext instanceof TagContext) { + TraceConfig traceConfig = ((TagContext) this.spanContext).getTraceConfig(); + if (traceConfig != null) { + return traceConfig; + } + } + return AgentTracer.traceConfig(); + } + + @Override + public boolean isSameTrace(AgentSpan otherSpan) { + return null != otherSpan && getTraceId().equals(otherSpan.getTraceId()); + } + + @Override + public RequestContext getRequestContext() { + return RequestContext.Noop.INSTANCE; + } + + @Override + public RequestBlockingAction getRequestBlockingAction() { + return null; + } + + @Override + public String toString() { + return "ExtractedSpan{spanContext=" + this.spanContext + '}'; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java index c71f8b59023..afda8357efd 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ImmutableSpan.java @@ -12,200 +12,200 @@ * alternative to provide read-only span implementations. */ public abstract class ImmutableSpan implements AgentSpan { - @Override - public AgentSpan setTag(String key, boolean value) { - return this; - } - - @Override - public AgentSpan setTag(String key, int value) { - return this; - } - - @Override - public AgentSpan setTag(String key, long value) { - return this; - } - - @Override - public AgentSpan setTag(String key, float value) { - return this; - } - - @Override - public AgentSpan setTag(String key, double value) { - return this; - } - - @Override - public AgentSpan setTag(String key, String value) { - return this; - } - - @Override - public AgentSpan setTag(TagMap.EntryReader entry) { - return this; - } - - @Override - public AgentSpan setTag(String key, CharSequence value) { - return this; - } - - @Override - public AgentSpan setTag(String key, Object value) { - return this; - } - - @Override - public AgentSpan setAllTags(Map map) { - return this; - } - - @Override - public AgentSpan setTag(String key, Number value) { - return this; - } - - @Override - public AgentSpan setMetric(CharSequence key, int value) { - return this; - } - - @Override - public AgentSpan setMetric(CharSequence key, long value) { - return this; - } - - @Override - public AgentSpan setMetric(CharSequence key, float value) { - return this; - } - - @Override - public AgentSpan setMetric(CharSequence key, double value) { - return this; - } - - @Override - public AgentSpan setMetric(TagMap.EntryReader entry) { - return this; - } - - @Override - public AgentSpan setSpanType(CharSequence type) { - return this; - } - - @Override - public AgentSpan setError(boolean error) { - return this; - } - - @Override - public AgentSpan setError(boolean error, byte priority) { - return this; - } - - @Override - public AgentSpan setMeasured(boolean measured) { - return this; - } - - @Override - public AgentSpan setErrorMessage(String errorMessage) { - return this; - } - - @Override - public AgentSpan addThrowable(Throwable throwable) { - return this; - } - - @Override - public AgentSpan addThrowable(Throwable throwable, byte errorPriority) { - return this; - } - - @Override - public AgentSpan setBaggageItem(String key, String value) { - return null; - } - - @Override - public AgentSpan setHttpStatusCode(int statusCode) { - return this; - } - - @Override - public void finish() {} - - @Override - public void finish(long finishMicros) {} - - @Override - public void finishWithDuration(long durationNanos) {} - - @Override - public void beginEndToEnd() {} - - @Override - public void finishWithEndToEnd() {} - - @Override - public boolean phasedFinish() { - return false; - } - - @Override - public void publish() {} - - @Override - public void setSpanName(CharSequence spanName) {} - - @Override - public MutableSpan setOperationName(CharSequence serviceName) { - return this; - } - - @Override - public MutableSpan setServiceName(String serviceName) { - return this; - } - - @Override - public AgentSpan setResourceName(CharSequence resourceName) { - return this; - } - - @Override - public MutableSpan setSamplingPriority(int newPriority) { - return null; - } - - @Override - public AgentSpan setResourceName(CharSequence resourceName, byte priority) { - return null; - } - - @Override - public Integer forceSamplingDecision() { - return null; - } - - @Override - public AgentSpan setSamplingPriority(int newPriority, int samplingMechanism) { - return this; - } - - @Override - public void addLink(AgentSpanLink link) {} - - @Override - public AgentSpan setMetaStruct(String field, Object value) { - return this; - } - - @Override - public void setRequestBlockingAction(RequestBlockingAction rba) {} + @Override + public AgentSpan setTag(String key, boolean value) { + return this; + } + + @Override + public AgentSpan setTag(String key, int value) { + return this; + } + + @Override + public AgentSpan setTag(String key, long value) { + return this; + } + + @Override + public AgentSpan setTag(String key, float value) { + return this; + } + + @Override + public AgentSpan setTag(String key, double value) { + return this; + } + + @Override + public AgentSpan setTag(String key, String value) { + return this; + } + + @Override + public AgentSpan setTag(TagMap.EntryReader entry) { + return this; + } + + @Override + public AgentSpan setTag(String key, CharSequence value) { + return this; + } + + @Override + public AgentSpan setTag(String key, Object value) { + return this; + } + + @Override + public AgentSpan setAllTags(Map map) { + return this; + } + + @Override + public AgentSpan setTag(String key, Number value) { + return this; + } + + @Override + public AgentSpan setMetric(CharSequence key, int value) { + return this; + } + + @Override + public AgentSpan setMetric(CharSequence key, long value) { + return this; + } + + @Override + public AgentSpan setMetric(CharSequence key, float value) { + return this; + } + + @Override + public AgentSpan setMetric(CharSequence key, double value) { + return this; + } + + @Override + public AgentSpan setMetric(TagMap.EntryReader entry) { + return this; + } + + @Override + public AgentSpan setSpanType(CharSequence type) { + return this; + } + + @Override + public AgentSpan setError(boolean error) { + return this; + } + + @Override + public AgentSpan setError(boolean error, byte priority) { + return this; + } + + @Override + public AgentSpan setMeasured(boolean measured) { + return this; + } + + @Override + public AgentSpan setErrorMessage(String errorMessage) { + return this; + } + + @Override + public AgentSpan addThrowable(Throwable throwable) { + return this; + } + + @Override + public AgentSpan addThrowable(Throwable throwable, byte errorPriority) { + return this; + } + + @Override + public AgentSpan setBaggageItem(String key, String value) { + return null; + } + + @Override + public AgentSpan setHttpStatusCode(int statusCode) { + return this; + } + + @Override + public void finish() {} + + @Override + public void finish(long finishMicros) {} + + @Override + public void finishWithDuration(long durationNanos) {} + + @Override + public void beginEndToEnd() {} + + @Override + public void finishWithEndToEnd() {} + + @Override + public boolean phasedFinish() { + return false; + } + + @Override + public void publish() {} + + @Override + public void setSpanName(CharSequence spanName) {} + + @Override + public MutableSpan setOperationName(CharSequence serviceName) { + return this; + } + + @Override + public MutableSpan setServiceName(String serviceName) { + return this; + } + + @Override + public AgentSpan setResourceName(CharSequence resourceName) { + return this; + } + + @Override + public MutableSpan setSamplingPriority(int newPriority) { + return null; + } + + @Override + public AgentSpan setResourceName(CharSequence resourceName, byte priority) { + return null; + } + + @Override + public Integer forceSamplingDecision() { + return null; + } + + @Override + public AgentSpan setSamplingPriority(int newPriority, int samplingMechanism) { + return this; + } + + @Override + public void addLink(AgentSpanLink link) {} + + @Override + public AgentSpan setMetaStruct(String field, Object value) { + return this; + } + + @Override + public void setRequestBlockingAction(RequestBlockingAction rba) {} } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java index 0c1054e7776..20931989588 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java @@ -2,140 +2,130 @@ public class InstrumentationTags { - // this exists to make it easy to intern UTF-8 encoding - // of tag/metric keys used in instrumentations. It should - // stay reasonably small. TODO when/if this gets large - // start looking at generating constants based on the - // enabled instrumentations. + // this exists to make it easy to intern UTF-8 encoding + // of tag/metric keys used in instrumentations. It should + // stay reasonably small. TODO when/if this gets large + // start looking at generating constants based on the + // enabled instrumentations. - public static final String PARTITION = "partition"; - public static final String OFFSET = "offset"; - public static final String CONSUMER_GROUP = "kafka.group"; - public static final String PROCESSOR_NAME = "processor.name"; - public static final String RECORD_QUEUE_TIME_MS = "record.queue_time_ms"; - public static final String RECORD_END_TO_END_DURATION_MS = "record.e2e_duration_ms"; - public static final String KAFKA_RECORDS_COUNT = "kafka.records_count"; - public static final String TOMBSTONE = "tombstone"; - public static final String AWS_AGENT = "aws.agent"; - public static final String AWS_SERVICE = "aws.service"; - public static final String TOP_LEVEL_AWS_SERVICE = "aws_service"; - public static final String AWS_OPERATION = "aws.operation"; - public static final String AWS_ENDPOINT = "aws.endpoint"; - public static final String AWS_BUCKET_NAME = "aws.bucket.name"; - public static final String BUCKET_NAME = "bucketname"; - public static final String AWS_OBJECT_KEY = "aws.object.key"; - public static final String AWS_QUEUE_URL = "aws.queue.url"; + public static final String PARTITION = "partition"; + public static final String OFFSET = "offset"; + public static final String CONSUMER_GROUP = "kafka.group"; + public static final String PROCESSOR_NAME = "processor.name"; + public static final String RECORD_QUEUE_TIME_MS = "record.queue_time_ms"; + public static final String RECORD_END_TO_END_DURATION_MS = "record.e2e_duration_ms"; + public static final String KAFKA_RECORDS_COUNT = "kafka.records_count"; + public static final String TOMBSTONE = "tombstone"; + public static final String AWS_AGENT = "aws.agent"; + public static final String AWS_SERVICE = "aws.service"; + public static final String TOP_LEVEL_AWS_SERVICE = "aws_service"; + public static final String AWS_OPERATION = "aws.operation"; + public static final String AWS_ENDPOINT = "aws.endpoint"; + public static final String AWS_BUCKET_NAME = "aws.bucket.name"; + public static final String BUCKET_NAME = "bucketname"; + public static final String AWS_OBJECT_KEY = "aws.object.key"; + public static final String AWS_QUEUE_URL = "aws.queue.url"; - public static final String AWS_QUEUE_NAME = "aws.queue.name"; - public static final String QUEUE_NAME = "queuename"; - public static final String AWS_TOPIC_NAME = "aws.topic.name"; - public static final String TOPIC_NAME = "topicname"; - public static final String AWS_STREAM_NAME = "aws.stream.name"; - public static final String STREAM_NAME = "streamname"; - public static final String AWS_TABLE_NAME = "aws.table.name"; - public static final String TABLE_NAME = "tablename"; - public static final String AWS_REQUEST_ID = "aws.requestId"; - public static final String AWS_STORAGE_CLASS = "aws.storage.class"; + public static final String AWS_QUEUE_NAME = "aws.queue.name"; + public static final String QUEUE_NAME = "queuename"; + public static final String AWS_TOPIC_NAME = "aws.topic.name"; + public static final String TOPIC_NAME = "topicname"; + public static final String AWS_STREAM_NAME = "aws.stream.name"; + public static final String STREAM_NAME = "streamname"; + public static final String AWS_TABLE_NAME = "aws.table.name"; + public static final String TABLE_NAME = "tablename"; + public static final String AWS_REQUEST_ID = "aws.requestId"; + public static final String AWS_STORAGE_CLASS = "aws.storage.class"; - // These are temporary keys used for span pointer hash calculation - public static final String S3_ETAG = "s3.eTag"; - public static final String DYNAMO_PRIMARY_KEY_1 = "dynamodb.primary_key_1"; - public static final String DYNAMO_PRIMARY_KEY_1_VALUE = "dynamodb.primary_key_1_value"; - public static final String DYNAMO_PRIMARY_KEY_2 = "dynamodb.primary_key_2"; - public static final String DYNAMO_PRIMARY_KEY_2_VALUE = "dynamodb.primary_key_2_value"; + // These are temporary keys used for span pointer hash calculation + public static final String S3_ETAG = "s3.eTag"; + public static final String DYNAMO_PRIMARY_KEY_1 = "dynamodb.primary_key_1"; + public static final String DYNAMO_PRIMARY_KEY_1_VALUE = "dynamodb.primary_key_1_value"; + public static final String DYNAMO_PRIMARY_KEY_2 = "dynamodb.primary_key_2"; + public static final String DYNAMO_PRIMARY_KEY_2_VALUE = "dynamodb.primary_key_2_value"; - public static final String BUCKET = "bucket"; - public static final String CASSANDRA_CONTACT_POINTS = "db.cassandra.contact.points"; - public static final String COUCHBASE_OPERATION_ID = "couchbase.operation_id"; - public static final String COUCHBASE_SEED_NODES = "db.couchbase.seed.nodes"; - public static final String ELASTICSEARCH_REQUEST_INDICES = "elasticsearch.request.indices"; - public static final String ELASTICSEARCH_REQUEST_SEARCH_TYPES = - "elasticsearch.request.search.types"; - public static final String ELASTICSEARCH_REQUEST_WRITE_TYPE = "elasticsearch.request.write.type"; - public static final String ELASTICSEARCH_REQUEST_WRITE_ROUTING = - "elasticsearch.request.write.routing"; - public static final String ELASTICSEARCH_REQUEST_WRITE_VERSION = - "elasticsearch.request.write.version"; - public static final String ELASTICSEARCH_TYPE = "elasticsearch.type"; - public static final String ELASTICSEARCH_ID = "elasticsearch.id"; - public static final String ELASTICSEARCH_VERSION = "elasticsearch.version"; - public static final String ELASTICSEARCH_SHARD_BROADCAST_TOTAL = - "elasticsearch.shard.broadcast.total"; - public static final String ELASTICSEARCH_SHARD_BROADCAST_SUCCESSFUL = - "elasticsearch.shard.broadcast.successful"; - public static final String ELASTICSEARCH_SHARD_BROADCAST_FAILED = - "elasticsearch.shard.broadcast.failed"; - public static final String ELASTICSEARCH_SHARD_BULK_ID = "elasticsearch.shard.bulk.id"; - public static final String ELASTICSEARCH_SHARD_BULK_INDEX = "elasticsearch.shard.bulk.index"; - public static final String ELASTICSEARCH_NODE_FAILURES = "elasticsearch.node.failures"; - public static final String ELASTICSEARCH_NODE_CLUSTER_NAME = "elasticsearch.node.cluster.name"; - public static final String ELASTICSEARCH_SHARD_REPLICATION_TOTAL = - "elasticsearch.shard.replication.total"; - public static final String ELASTICSEARCH_SHARD_REPLICATION_FAILED = - "elasticsearch.shard.replication.failed"; - public static final String ELASTICSEARCH_RESPONSE_STATUS = "elasticsearch.response.status"; - public static final String STATUS_CODE = "status.code"; - public static final String STATUS_DESCRIPTION = "status.description"; - public static final String GRPC_STATUS_CODE = "rpc.grpc.status_code"; - public static final String MESSAGE_TYPE = "message.type"; - public static final String MESSAGE_SIZE = "message.size"; - public static final String HYSTRIX_COMMAND = "hystrix.command"; - public static final String HYSTRIX_CIRCUIT_OPEN = "hystrix.circuit-open"; - public static final String HYSTRIX_GROUP = "hystrix.group"; - public static final String SERVLET_CONTEXT = "servlet.context"; - public static final String SERVLET_PATH = "servlet.path"; - public static final String SERVLET_DISPATCH = "servlet.dispatch"; - public static final String TIMEOUT = "timeout"; - public static final String JMS_PRODUCE = "jms.produce"; - public static final String JSP_COMPILER = "jsp.compiler"; - public static final String JSP_CLASSFQCN = "jsp.classFQCN"; - public static final String JSP_FORWARD_ORIGIN = "jsp.forwardOrigin"; - public static final String DB_REDIS_DBINDEX = "db.redis.dbIndex"; - public static final String DB_COMMAND_CANCELLED = "db.command.cancelled"; - public static final String DB_COMMAND_RESULTS_COUNT = "db.command.results.count"; - public static final String AMQP_DELIVERY_MODE = "amqp.delivery_mode"; - public static final String AMQP_COMMAND = "amqp.command"; - public static final String AMQP_EXCHANGE = "amqp.exchange"; - public static final String AMQP_ROUTING_KEY = "amqp.routing_key"; - public static final String AMQP_QUEUE = "amqp.queue"; - public static final String EVENT = "event"; - public static final String MESSAGE = "message"; - public static final String HANDLER_TYPE = "handler.type"; - public static final String KAFKA_BOOTSTRAP_SERVERS = "messaging.kafka.bootstrap.servers"; - public static final String KAFKA_CLUSTER_ID = "messaging.kafka.cluster.id"; - public static final String MESSAGING_DESTINATION_NAME = "messaging.destination.name"; - public static final String QUARTZ_JOB_NAME = "quartz.job.name"; - public static final String QUARTZ_JOB_GROUP = "quartz.job.group"; - public static final String QUARTZ_TRIGGER_NAME = "quartz.trigger.name"; - public static final String QUARTZ_TRIGGER_GROUP = "quartz.trigger.group"; - public static final String REQUEST_PREDICATE = "request.predicate"; - public static final String VIEW_NAME = "view.name"; - public static final String VIEW_TYPE = "view.type"; - public static final String TWILIO_TYPE = "twilio.type"; - public static final String TWILIO_ACCOUNT = "twilio.account"; - public static final String TWILIO_SID = "twilio.sid"; - public static final String TWILIO_STATUS = "twilio.status"; - public static final String TWILIO_PARENT_SID = "twilio.parentSid"; - public static final String DBM_TRACE_INJECTED = "_dd.dbm_trace_injected"; - public static final String INSTRUMENTATION_TIME_MS = "_dd.instrumentation.time_ms"; - public static final UTF8BytesString DD_MEASURED = UTF8BytesString.create("_dd.measured"); - public static final UTF8BytesString DD_TOP_LEVEL = UTF8BytesString.create("_dd.top_level"); - public static final UTF8BytesString DD_PARTIAL_VERSION = - UTF8BytesString.create("_dd.partial_version"); - public static final UTF8BytesString DD_WAS_LONG_RUNNING = - UTF8BytesString.create("_dd.was_long_running"); + public static final String BUCKET = "bucket"; + public static final String CASSANDRA_CONTACT_POINTS = "db.cassandra.contact.points"; + public static final String COUCHBASE_OPERATION_ID = "couchbase.operation_id"; + public static final String COUCHBASE_SEED_NODES = "db.couchbase.seed.nodes"; + public static final String ELASTICSEARCH_REQUEST_INDICES = "elasticsearch.request.indices"; + public static final String ELASTICSEARCH_REQUEST_SEARCH_TYPES = "elasticsearch.request.search.types"; + public static final String ELASTICSEARCH_REQUEST_WRITE_TYPE = "elasticsearch.request.write.type"; + public static final String ELASTICSEARCH_REQUEST_WRITE_ROUTING = "elasticsearch.request.write.routing"; + public static final String ELASTICSEARCH_REQUEST_WRITE_VERSION = "elasticsearch.request.write.version"; + public static final String ELASTICSEARCH_TYPE = "elasticsearch.type"; + public static final String ELASTICSEARCH_ID = "elasticsearch.id"; + public static final String ELASTICSEARCH_VERSION = "elasticsearch.version"; + public static final String ELASTICSEARCH_SHARD_BROADCAST_TOTAL = "elasticsearch.shard.broadcast.total"; + public static final String ELASTICSEARCH_SHARD_BROADCAST_SUCCESSFUL = "elasticsearch.shard.broadcast.successful"; + public static final String ELASTICSEARCH_SHARD_BROADCAST_FAILED = "elasticsearch.shard.broadcast.failed"; + public static final String ELASTICSEARCH_SHARD_BULK_ID = "elasticsearch.shard.bulk.id"; + public static final String ELASTICSEARCH_SHARD_BULK_INDEX = "elasticsearch.shard.bulk.index"; + public static final String ELASTICSEARCH_NODE_FAILURES = "elasticsearch.node.failures"; + public static final String ELASTICSEARCH_NODE_CLUSTER_NAME = "elasticsearch.node.cluster.name"; + public static final String ELASTICSEARCH_SHARD_REPLICATION_TOTAL = "elasticsearch.shard.replication.total"; + public static final String ELASTICSEARCH_SHARD_REPLICATION_FAILED = "elasticsearch.shard.replication.failed"; + public static final String ELASTICSEARCH_RESPONSE_STATUS = "elasticsearch.response.status"; + public static final String STATUS_CODE = "status.code"; + public static final String STATUS_DESCRIPTION = "status.description"; + public static final String GRPC_STATUS_CODE = "rpc.grpc.status_code"; + public static final String MESSAGE_TYPE = "message.type"; + public static final String MESSAGE_SIZE = "message.size"; + public static final String HYSTRIX_COMMAND = "hystrix.command"; + public static final String HYSTRIX_CIRCUIT_OPEN = "hystrix.circuit-open"; + public static final String HYSTRIX_GROUP = "hystrix.group"; + public static final String SERVLET_CONTEXT = "servlet.context"; + public static final String SERVLET_PATH = "servlet.path"; + public static final String SERVLET_DISPATCH = "servlet.dispatch"; + public static final String TIMEOUT = "timeout"; + public static final String JMS_PRODUCE = "jms.produce"; + public static final String JSP_COMPILER = "jsp.compiler"; + public static final String JSP_CLASSFQCN = "jsp.classFQCN"; + public static final String JSP_FORWARD_ORIGIN = "jsp.forwardOrigin"; + public static final String DB_REDIS_DBINDEX = "db.redis.dbIndex"; + public static final String DB_COMMAND_CANCELLED = "db.command.cancelled"; + public static final String DB_COMMAND_RESULTS_COUNT = "db.command.results.count"; + public static final String AMQP_DELIVERY_MODE = "amqp.delivery_mode"; + public static final String AMQP_COMMAND = "amqp.command"; + public static final String AMQP_EXCHANGE = "amqp.exchange"; + public static final String AMQP_ROUTING_KEY = "amqp.routing_key"; + public static final String AMQP_QUEUE = "amqp.queue"; + public static final String EVENT = "event"; + public static final String MESSAGE = "message"; + public static final String HANDLER_TYPE = "handler.type"; + public static final String KAFKA_BOOTSTRAP_SERVERS = "messaging.kafka.bootstrap.servers"; + public static final String KAFKA_CLUSTER_ID = "messaging.kafka.cluster.id"; + public static final String MESSAGING_DESTINATION_NAME = "messaging.destination.name"; + public static final String QUARTZ_JOB_NAME = "quartz.job.name"; + public static final String QUARTZ_JOB_GROUP = "quartz.job.group"; + public static final String QUARTZ_TRIGGER_NAME = "quartz.trigger.name"; + public static final String QUARTZ_TRIGGER_GROUP = "quartz.trigger.group"; + public static final String REQUEST_PREDICATE = "request.predicate"; + public static final String VIEW_NAME = "view.name"; + public static final String VIEW_TYPE = "view.type"; + public static final String TWILIO_TYPE = "twilio.type"; + public static final String TWILIO_ACCOUNT = "twilio.account"; + public static final String TWILIO_SID = "twilio.sid"; + public static final String TWILIO_STATUS = "twilio.status"; + public static final String TWILIO_PARENT_SID = "twilio.parentSid"; + public static final String DBM_TRACE_INJECTED = "_dd.dbm_trace_injected"; + public static final String INSTRUMENTATION_TIME_MS = "_dd.instrumentation.time_ms"; + public static final UTF8BytesString DD_MEASURED = UTF8BytesString.create("_dd.measured"); + public static final UTF8BytesString DD_TOP_LEVEL = UTF8BytesString.create("_dd.top_level"); + public static final UTF8BytesString DD_PARTIAL_VERSION = UTF8BytesString.create("_dd.partial_version"); + public static final UTF8BytesString DD_WAS_LONG_RUNNING = UTF8BytesString.create("_dd.was_long_running"); - public static final String TIBCO_NODE = "tibco.node"; - public static final String TIBCO_VERSION = "tibco.version"; + public static final String TIBCO_NODE = "tibco.node"; + public static final String TIBCO_VERSION = "tibco.version"; - public static final String MULE_CORRELATION_ID = "mule.correlation_id"; - public static final String MULE_LOCATION = "mule.location"; + public static final String MULE_CORRELATION_ID = "mule.correlation_id"; + public static final String MULE_LOCATION = "mule.location"; - public static final String WEBSOCKET_SESSION_ID = "websocket.session.id"; - public static final String WEBSOCKET_MESSAGE_TYPE = "websocket.message.type"; - public static final String WEBSOCKET_MESSAGE_LENGTH = "websocket.message.length"; - public static final String WEBSOCKET_MESSAGE_FRAMES = "websocket.message.frames"; - public static final String WEBSOCKET_MESSAGE_RECEIVE_TIME = "websocket.message.receive_time"; - public static final String WEBSOCKET_CLOSE_CODE = "websocket.close.code"; - public static final String WEBSOCKET_CLOSE_REASON = "websocket.close.reason"; + public static final String WEBSOCKET_SESSION_ID = "websocket.session.id"; + public static final String WEBSOCKET_MESSAGE_TYPE = "websocket.message.type"; + public static final String WEBSOCKET_MESSAGE_LENGTH = "websocket.message.length"; + public static final String WEBSOCKET_MESSAGE_FRAMES = "websocket.message.frames"; + public static final String WEBSOCKET_MESSAGE_RECEIVE_TIME = "websocket.message.receive_time"; + public static final String WEBSOCKET_CLOSE_CODE = "websocket.close.code"; + public static final String WEBSOCKET_CLOSE_REASON = "websocket.close.reason"; } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalContextKeys.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalContextKeys.java index 296b45fec23..873c1067d38 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalContextKeys.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalContextKeys.java @@ -3,7 +3,7 @@ import datadog.context.ContextKey; final class InternalContextKeys { - static final ContextKey SPAN_KEY = ContextKey.named("dd-span-key"); + static final ContextKey SPAN_KEY = ContextKey.named("dd-span-key"); - private InternalContextKeys() {} + private InternalContextKeys() {} } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalSpanTypes.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalSpanTypes.java index 6682a98f8b5..e106df64a2b 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalSpanTypes.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InternalSpanTypes.java @@ -3,55 +3,46 @@ import datadog.trace.api.DDSpanTypes; public class InternalSpanTypes { - public static final CharSequence HTTP_CLIENT = UTF8BytesString.create(DDSpanTypes.HTTP_CLIENT); - public static final CharSequence HTTP_SERVER = UTF8BytesString.create(DDSpanTypes.HTTP_SERVER); - public static final CharSequence RPC = UTF8BytesString.create(DDSpanTypes.RPC); - public static final CharSequence CACHE = UTF8BytesString.create(DDSpanTypes.CACHE); - public static final CharSequence SOAP = UTF8BytesString.create(DDSpanTypes.SOAP); - - public static final CharSequence SQL = UTF8BytesString.create(DDSpanTypes.SQL); - public static final CharSequence MONGO = UTF8BytesString.create(DDSpanTypes.MONGO); - public static final CharSequence CASSANDRA = UTF8BytesString.create(DDSpanTypes.CASSANDRA); - public static final CharSequence COUCHBASE = - UTF8BytesString.create(DDSpanTypes.COUCHBASE); // Using generic for now. - public static final CharSequence REDIS = UTF8BytesString.create(DDSpanTypes.REDIS); - public static final CharSequence MEMCACHED = UTF8BytesString.create(DDSpanTypes.MEMCACHED); - public static final CharSequence ELASTICSEARCH = - UTF8BytesString.create(DDSpanTypes.ELASTICSEARCH); - public static final CharSequence OPENSEARCH = UTF8BytesString.create(DDSpanTypes.OPENSEARCH); - public static final CharSequence HIBERNATE = UTF8BytesString.create(DDSpanTypes.HIBERNATE); - public static final CharSequence AEROSPIKE = UTF8BytesString.create(DDSpanTypes.AEROSPIKE); - public static final CharSequence DATANUCLEUS = UTF8BytesString.create(DDSpanTypes.DATANUCLEUS); - - // these are all the same thing but don't want to be taken by surprise by changes in DDSpanTypes - public static final CharSequence MESSAGE_CLIENT = - UTF8BytesString.create(DDSpanTypes.MESSAGE_CLIENT); - public static final CharSequence MESSAGE_CONSUMER = - UTF8BytesString.create(DDSpanTypes.MESSAGE_CONSUMER); - public static final CharSequence MESSAGE_PRODUCER = - UTF8BytesString.create(DDSpanTypes.MESSAGE_PRODUCER); - public static final CharSequence MESSAGE_BROKER = - UTF8BytesString.create(DDSpanTypes.MESSAGE_BROKER); - - public static final CharSequence GRAPHQL = UTF8BytesString.create(DDSpanTypes.GRAPHQL); - - public static final UTF8BytesString TEST = UTF8BytesString.create(DDSpanTypes.TEST); - public static final UTF8BytesString TEST_SUITE_END = - UTF8BytesString.create(DDSpanTypes.TEST_SUITE_END); - public static final UTF8BytesString TEST_MODULE_END = - UTF8BytesString.create(DDSpanTypes.TEST_MODULE_END); - public static final UTF8BytesString TEST_SESSION_END = - UTF8BytesString.create(DDSpanTypes.TEST_SESSION_END); - public static final UTF8BytesString VULNERABILITY = - UTF8BytesString.create(DDSpanTypes.VULNERABILITY); - public static final UTF8BytesString PROTOBUF = UTF8BytesString.create(DDSpanTypes.PROTOBUF); - - public static final UTF8BytesString LLMOBS = UTF8BytesString.create(DDSpanTypes.LLMOBS); - - public static final UTF8BytesString TIBCO_BW = UTF8BytesString.create("tibco_bw"); - public static final UTF8BytesString MULE = UTF8BytesString.create(DDSpanTypes.MULE); - public static final CharSequence VALKEY = UTF8BytesString.create(DDSpanTypes.VALKEY); - public static final UTF8BytesString WEBSOCKET = UTF8BytesString.create(DDSpanTypes.WEBSOCKET); - - public static final CharSequence SERVERLESS = UTF8BytesString.create(DDSpanTypes.SERVERLESS); + public static final CharSequence HTTP_CLIENT = UTF8BytesString.create(DDSpanTypes.HTTP_CLIENT); + public static final CharSequence HTTP_SERVER = UTF8BytesString.create(DDSpanTypes.HTTP_SERVER); + public static final CharSequence RPC = UTF8BytesString.create(DDSpanTypes.RPC); + public static final CharSequence CACHE = UTF8BytesString.create(DDSpanTypes.CACHE); + public static final CharSequence SOAP = UTF8BytesString.create(DDSpanTypes.SOAP); + + public static final CharSequence SQL = UTF8BytesString.create(DDSpanTypes.SQL); + public static final CharSequence MONGO = UTF8BytesString.create(DDSpanTypes.MONGO); + public static final CharSequence CASSANDRA = UTF8BytesString.create(DDSpanTypes.CASSANDRA); + public static final CharSequence COUCHBASE = + UTF8BytesString.create(DDSpanTypes.COUCHBASE); // Using generic for now. + public static final CharSequence REDIS = UTF8BytesString.create(DDSpanTypes.REDIS); + public static final CharSequence MEMCACHED = UTF8BytesString.create(DDSpanTypes.MEMCACHED); + public static final CharSequence ELASTICSEARCH = UTF8BytesString.create(DDSpanTypes.ELASTICSEARCH); + public static final CharSequence OPENSEARCH = UTF8BytesString.create(DDSpanTypes.OPENSEARCH); + public static final CharSequence HIBERNATE = UTF8BytesString.create(DDSpanTypes.HIBERNATE); + public static final CharSequence AEROSPIKE = UTF8BytesString.create(DDSpanTypes.AEROSPIKE); + public static final CharSequence DATANUCLEUS = UTF8BytesString.create(DDSpanTypes.DATANUCLEUS); + + // these are all the same thing but don't want to be taken by surprise by changes in DDSpanTypes + public static final CharSequence MESSAGE_CLIENT = UTF8BytesString.create(DDSpanTypes.MESSAGE_CLIENT); + public static final CharSequence MESSAGE_CONSUMER = UTF8BytesString.create(DDSpanTypes.MESSAGE_CONSUMER); + public static final CharSequence MESSAGE_PRODUCER = UTF8BytesString.create(DDSpanTypes.MESSAGE_PRODUCER); + public static final CharSequence MESSAGE_BROKER = UTF8BytesString.create(DDSpanTypes.MESSAGE_BROKER); + + public static final CharSequence GRAPHQL = UTF8BytesString.create(DDSpanTypes.GRAPHQL); + + public static final UTF8BytesString TEST = UTF8BytesString.create(DDSpanTypes.TEST); + public static final UTF8BytesString TEST_SUITE_END = UTF8BytesString.create(DDSpanTypes.TEST_SUITE_END); + public static final UTF8BytesString TEST_MODULE_END = UTF8BytesString.create(DDSpanTypes.TEST_MODULE_END); + public static final UTF8BytesString TEST_SESSION_END = UTF8BytesString.create(DDSpanTypes.TEST_SESSION_END); + public static final UTF8BytesString VULNERABILITY = UTF8BytesString.create(DDSpanTypes.VULNERABILITY); + public static final UTF8BytesString PROTOBUF = UTF8BytesString.create(DDSpanTypes.PROTOBUF); + + public static final UTF8BytesString LLMOBS = UTF8BytesString.create(DDSpanTypes.LLMOBS); + + public static final UTF8BytesString TIBCO_BW = UTF8BytesString.create("tibco_bw"); + public static final UTF8BytesString MULE = UTF8BytesString.create(DDSpanTypes.MULE); + public static final CharSequence VALKEY = UTF8BytesString.create(DDSpanTypes.VALKEY); + public static final UTF8BytesString WEBSOCKET = UTF8BytesString.create(DDSpanTypes.WEBSOCKET); + + public static final CharSequence SERVERLESS = UTF8BytesString.create(DDSpanTypes.SERVERLESS); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopContinuation.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopContinuation.java index 5f8c4dad657..456cd5ed0bb 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopContinuation.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopContinuation.java @@ -5,25 +5,25 @@ import datadog.context.ContextScope; public final class NoopContinuation implements ContextContinuation { - public static final NoopContinuation INSTANCE = new NoopContinuation(); + public static final NoopContinuation INSTANCE = new NoopContinuation(); - private NoopContinuation() {} + private NoopContinuation() {} - @Override - public NoopContinuation hold() { - return this; - } + @Override + public NoopContinuation hold() { + return this; + } - @Override - public ContextScope resume() { - return NoopScope.INSTANCE; - } + @Override + public ContextScope resume() { + return NoopScope.INSTANCE; + } - @Override - public Context context() { - return Context.root(); - } + @Override + public Context context() { + return Context.root(); + } - @Override - public void release() {} + @Override + public void release() {} } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopScope.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopScope.java index 5347b5de1c7..f3839c90bec 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopScope.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopScope.java @@ -4,15 +4,15 @@ import datadog.context.ContextScope; public final class NoopScope implements ContextScope { - public static final NoopScope INSTANCE = new NoopScope(); + public static final NoopScope INSTANCE = new NoopScope(); - private NoopScope() {} + private NoopScope() {} - @Override - public Context context() { - return NoopSpan.INSTANCE; - } + @Override + public Context context() { + return NoopSpan.INSTANCE; + } - @Override - public void close() {} + @Override + public void close() {} } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpan.java index a5f1ce0d788..badf3897b2a 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpan.java @@ -10,132 +10,132 @@ import datadog.trace.api.sampling.PrioritySampling; class NoopSpan extends ImmutableSpan implements AgentSpan, SelfScopedContext { - static final NoopSpan INSTANCE = new NoopSpan(); - - NoopSpan() {} - - @Override - public DDTraceId getTraceId() { - return DDTraceId.ZERO; - } - - @Override - public long getSpanId() { - return DDSpanId.ZERO; - } - - @Override - public RequestBlockingAction getRequestBlockingAction() { - return null; - } - - @Override - public boolean isError() { - return false; - } - - @Override - public Object getTag(final String key) { - return null; - } - - @Override - public long getStartTime() { - return 0; - } - - @Override - public long getDurationNano() { - return 0; - } - - @Override - public String getOperationName() { - return null; - } - - @Override - public String getServiceName() { - return null; - } - - @Override - public CharSequence getResourceName() { - return null; - } - - @Override - public RequestContext getRequestContext() { - return RequestContext.Noop.INSTANCE; - } - - @Override - public Integer getSamplingPriority() { - return (int) PrioritySampling.UNSET; - } - - @Override - public String getSpanType() { - return null; - } - - @Override - public TagMap getTags() { - return TagMap.EMPTY; - } - - @Override - public AgentSpan getRootSpan() { - return this; - } - - @Override - public short getHttpStatusCode() { - return 0; - } - - @Override - public AgentSpan getLocalRootSpan() { - return this; - } - - @Override - public boolean isSameTrace(final AgentSpan otherSpan) { - return otherSpan == INSTANCE; - } - - @Override - public AgentSpanContext spanContext() { - return NoopSpanContext.INSTANCE; - } - - @Override - public String getBaggageItem(final String key) { - return null; - } - - @Override - public String getSpanName() { - return ""; - } - - @Override - public boolean hasResourceName() { - return false; - } - - @Override - public byte getResourceNamePriority() { - return Byte.MAX_VALUE; - } - - @Override - public TraceConfig traceConfig() { - return AgentTracer.NoopTraceConfig.INSTANCE; - } - - @Override - public boolean isOutbound() { - return false; - } + static final NoopSpan INSTANCE = new NoopSpan(); + + NoopSpan() {} + + @Override + public DDTraceId getTraceId() { + return DDTraceId.ZERO; + } + + @Override + public long getSpanId() { + return DDSpanId.ZERO; + } + + @Override + public RequestBlockingAction getRequestBlockingAction() { + return null; + } + + @Override + public boolean isError() { + return false; + } + + @Override + public Object getTag(final String key) { + return null; + } + + @Override + public long getStartTime() { + return 0; + } + + @Override + public long getDurationNano() { + return 0; + } + + @Override + public String getOperationName() { + return null; + } + + @Override + public String getServiceName() { + return null; + } + + @Override + public CharSequence getResourceName() { + return null; + } + + @Override + public RequestContext getRequestContext() { + return RequestContext.Noop.INSTANCE; + } + + @Override + public Integer getSamplingPriority() { + return (int) PrioritySampling.UNSET; + } + + @Override + public String getSpanType() { + return null; + } + + @Override + public TagMap getTags() { + return TagMap.EMPTY; + } + + @Override + public AgentSpan getRootSpan() { + return this; + } + + @Override + public short getHttpStatusCode() { + return 0; + } + + @Override + public AgentSpan getLocalRootSpan() { + return this; + } + + @Override + public boolean isSameTrace(final AgentSpan otherSpan) { + return otherSpan == INSTANCE; + } + + @Override + public AgentSpanContext spanContext() { + return NoopSpanContext.INSTANCE; + } + + @Override + public String getBaggageItem(final String key) { + return null; + } + + @Override + public String getSpanName() { + return ""; + } + + @Override + public boolean hasResourceName() { + return false; + } + + @Override + public byte getResourceNamePriority() { + return Byte.MAX_VALUE; + } + + @Override + public TraceConfig traceConfig() { + return AgentTracer.NoopTraceConfig.INSTANCE; + } + + @Override + public boolean isOutbound() { + return false; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpanContext.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpanContext.java index 43458f54928..30f1337c8e7 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpanContext.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NoopSpanContext.java @@ -11,122 +11,122 @@ import java.util.Map; class NoopSpanContext implements AgentSpanContext.Extracted { - static final NoopSpanContext INSTANCE = new NoopSpanContext(); - - NoopSpanContext() {} - - @Override - public DDTraceId getTraceId() { - return DDTraceId.ZERO; - } - - @Override - public long getSpanId() { - return DDSpanId.ZERO; - } - - @Override - public AgentTraceCollector getTraceCollector() { - return AgentTracer.NoopAgentTraceCollector.INSTANCE; - } - - @Override - public int getSamplingPriority() { - return PrioritySampling.UNSET; - } - - @Override - public Iterable> baggageItems() { - return emptyList(); - } - - @Override - public PathwayContext getPathwayContext() { - return NoopPathwayContext.INSTANCE; - } - - @Override - public boolean isRemote() { - return false; - } - - @Override - public List getTerminatedSpanLinks() { - return emptyList(); - } - - @Override - public String getForwarded() { - return null; - } - - @Override - public String getFastlyClientIp() { - return null; - } - - @Override - public String getCfConnectingIp() { - return null; - } - - @Override - public String getCfConnectingIpv6() { - return null; - } - - @Override - public String getXForwardedProto() { - return null; - } - - @Override - public String getXForwardedHost() { - return null; - } - - @Override - public String getXForwardedPort() { - return null; - } - - @Override - public String getForwardedFor() { - return null; - } - - @Override - public String getXForwardedFor() { - return null; - } - - @Override - public String getXClusterClientIp() { - return null; - } - - @Override - public String getXRealIp() { - return null; - } - - @Override - public String getXClientIp() { - return null; - } - - @Override - public String getUserAgent() { - return null; - } - - @Override - public String getTrueClientIp() { - return null; - } - - @Override - public String getCustomIpHeader() { - return null; - } + static final NoopSpanContext INSTANCE = new NoopSpanContext(); + + NoopSpanContext() {} + + @Override + public DDTraceId getTraceId() { + return DDTraceId.ZERO; + } + + @Override + public long getSpanId() { + return DDSpanId.ZERO; + } + + @Override + public AgentTraceCollector getTraceCollector() { + return AgentTracer.NoopAgentTraceCollector.INSTANCE; + } + + @Override + public int getSamplingPriority() { + return PrioritySampling.UNSET; + } + + @Override + public Iterable> baggageItems() { + return emptyList(); + } + + @Override + public PathwayContext getPathwayContext() { + return NoopPathwayContext.INSTANCE; + } + + @Override + public boolean isRemote() { + return false; + } + + @Override + public List getTerminatedSpanLinks() { + return emptyList(); + } + + @Override + public String getForwarded() { + return null; + } + + @Override + public String getFastlyClientIp() { + return null; + } + + @Override + public String getCfConnectingIp() { + return null; + } + + @Override + public String getCfConnectingIpv6() { + return null; + } + + @Override + public String getXForwardedProto() { + return null; + } + + @Override + public String getXForwardedHost() { + return null; + } + + @Override + public String getXForwardedPort() { + return null; + } + + @Override + public String getForwardedFor() { + return null; + } + + @Override + public String getXForwardedFor() { + return null; + } + + @Override + public String getXClusterClientIp() { + return null; + } + + @Override + public String getXRealIp() { + return null; + } + + @Override + public String getXClientIp() { + return null; + } + + @Override + public String getUserAgent() { + return null; + } + + @Override + public String getTrueClientIp() { + return null; + } + + @Override + public String getCustomIpHeader() { + return null; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NotSampledSpanContext.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NotSampledSpanContext.java index 743f9c3bf66..a8a567fb108 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NotSampledSpanContext.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/NotSampledSpanContext.java @@ -7,44 +7,44 @@ /** An {@link AgentSpanContext} that hides the sampling priority. */ public final class NotSampledSpanContext implements AgentSpanContext { - private final AgentSpanContext delegate; - - public NotSampledSpanContext(AgentSpanContext delegate) { - this.delegate = delegate; - } - - @Override - public DDTraceId getTraceId() { - return delegate.getTraceId(); - } - - @Override - public long getSpanId() { - return delegate.getSpanId(); - } - - @Override - public AgentTraceCollector getTraceCollector() { - return delegate.getTraceCollector(); - } - - @Override - public int getSamplingPriority() { - return PrioritySampling.UNSET; - } - - @Override - public Iterable> baggageItems() { - return delegate.baggageItems(); - } - - @Override - public PathwayContext getPathwayContext() { - return delegate.getPathwayContext(); - } - - @Override - public boolean isRemote() { - return delegate.isRemote(); - } + private final AgentSpanContext delegate; + + public NotSampledSpanContext(AgentSpanContext delegate) { + this.delegate = delegate; + } + + @Override + public DDTraceId getTraceId() { + return delegate.getTraceId(); + } + + @Override + public long getSpanId() { + return delegate.getSpanId(); + } + + @Override + public AgentTraceCollector getTraceCollector() { + return delegate.getTraceCollector(); + } + + @Override + public int getSamplingPriority() { + return PrioritySampling.UNSET; + } + + @Override + public Iterable> baggageItems() { + return delegate.baggageItems(); + } + + @Override + public PathwayContext getPathwayContext() { + return delegate.getPathwayContext(); + } + + @Override + public boolean isRemote() { + return delegate.isRemote(); + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilerContext.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilerContext.java index 646ca92bf40..e95e4839081 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilerContext.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilerContext.java @@ -2,32 +2,32 @@ public interface ProfilerContext { - long getSpanId(); + long getSpanId(); - /** - * @return the span id of the local root span, or the span itself - */ - long getRootSpanId(); + /** + * @return the span id of the local root span, or the span itself + */ + long getRootSpanId(); - /** - * @return upper 64 bits of the 128-bit trace id, or 0 if not available - */ - default long getTraceIdHigh() { - return 0L; - } + /** + * @return upper 64 bits of the 128-bit trace id, or 0 if not available + */ + default long getTraceIdHigh() { + return 0L; + } - /** - * @return lower 64 bits of the 128-bit trace id, or 0 if not available - */ - default long getTraceIdLow() { - return 0L; - } + /** + * @return lower 64 bits of the 128-bit trace id, or 0 if not available + */ + default long getTraceIdLow() { + return 0L; + } - int getEncodedOperationName(); + int getEncodedOperationName(); - CharSequence getOperationName(); + CharSequence getOperationName(); - int getEncodedResourceName(); + int getEncodedResourceName(); - CharSequence getResourceName(); + CharSequence getResourceName(); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java index f479a956973..3a135b843bd 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java @@ -11,97 +11,96 @@ import datadog.trace.api.profiling.Timing; public interface ProfilingContextIntegration extends Profiling, EndpointCheckpointer, Timer { - /** - * invoked when the profiler is started, implementations must not initialise JFR before this is - * called. - */ - default void onStart() {} - - /** Invoked when a trace first propagates to a thread */ - default void onAttach() {} - - /** Invoked when a thread exits */ - default void onDetach() {} - - /** - * Binds {@code context} to profiler state associated with the current carrier thread. - * - *

The default implementation is a no-op. Integrations that return {@code true} from {@link - * #isThreadContextBindingRequired()} must override this method and treat {@link Context#root()} - * as clearing the current binding. - * - * @param context context to bind - */ - default void setContext(Context context) {} - - /** Whether profiler context must be rebound when a virtual thread changes carrier threads. */ - default boolean isThreadContextBindingRequired() { - return false; - } - - default Stateful newScopeState(ProfilerContext profilerContext) { - return Stateful.DEFAULT; - } - - default int encode(CharSequence constant) { - return 0; - } - - default int encodeOperationName(CharSequence constant) { - return 0; - } - - default int encodeResourceName(CharSequence constant) { - return 0; - } - - String name(); - - /** - * Registers a one-shot callback to run once this integration is actually able to label context; - * runs inline if already available, or never if a deferred construction fails. - */ - default void whenAvailable(Runnable callback) { - callback.run(); - } - - final class NoOp implements ProfilingContextIntegration { - - public static final ProfilingContextIntegration INSTANCE = - new ProfilingContextIntegration.NoOp(); - - @Override - public ProfilingContextAttribute createContextAttribute(String attribute) { - return ProfilingContextAttribute.NoOp.INSTANCE; + /** + * invoked when the profiler is started, implementations must not initialise JFR before this is + * called. + */ + default void onStart() {} + + /** Invoked when a trace first propagates to a thread */ + default void onAttach() {} + + /** Invoked when a thread exits */ + default void onDetach() {} + + /** + * Binds {@code context} to profiler state associated with the current carrier thread. + * + *

The default implementation is a no-op. Integrations that return {@code true} from {@link + * #isThreadContextBindingRequired()} must override this method and treat {@link Context#root()} + * as clearing the current binding. + * + * @param context context to bind + */ + default void setContext(Context context) {} + + /** Whether profiler context must be rebound when a virtual thread changes carrier threads. */ + default boolean isThreadContextBindingRequired() { + return false; } - @Override - public ProfilingScope newScope() { - return ProfilingScope.NO_OP; + default Stateful newScopeState(ProfilerContext profilerContext) { + return Stateful.DEFAULT; } - @Override - public void onAttach() {} + default int encode(CharSequence constant) { + return 0; + } - @Override - public void onDetach() {} + default int encodeOperationName(CharSequence constant) { + return 0; + } - @Override - public String name() { - return "none"; + default int encodeResourceName(CharSequence constant) { + return 0; } - @Override - public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} + String name(); - @Override - public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { - return EndpointTracker.NO_OP; + /** + * Registers a one-shot callback to run once this integration is actually able to label context; + * runs inline if already available, or never if a deferred construction fails. + */ + default void whenAvailable(Runnable callback) { + callback.run(); } - @Override - public Timing start(TimerType type) { - return Timing.NoOp.INSTANCE; + final class NoOp implements ProfilingContextIntegration { + + public static final ProfilingContextIntegration INSTANCE = new ProfilingContextIntegration.NoOp(); + + @Override + public ProfilingContextAttribute createContextAttribute(String attribute) { + return ProfilingContextAttribute.NoOp.INSTANCE; + } + + @Override + public ProfilingScope newScope() { + return ProfilingScope.NO_OP; + } + + @Override + public void onAttach() {} + + @Override + public void onDetach() {} + + @Override + public String name() { + return "none"; + } + + @Override + public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} + + @Override + public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { + return EndpointTracker.NO_OP; + } + + @Override + public Timing start(TimerType type) { + return Timing.NoOp.INSTANCE; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ResourceNamePriorities.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ResourceNamePriorities.java index dddeabaf0e4..21d7ca658c0 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ResourceNamePriorities.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ResourceNamePriorities.java @@ -1,13 +1,13 @@ package datadog.trace.bootstrap.instrumentation.api; public class ResourceNamePriorities { - public static final byte DEFAULT = 0; - public static final byte HTTP_PATH_NORMALIZER = 1; - public static final byte HTTP_404 = 2; - public static final byte HTTP_FRAMEWORK_ROUTE = 3; - public static final byte RPC_COMMAND_NAME = 3; - public static final byte HTTP_SERVER_CONFIG_PATTERN_MATCH = 4; - public static final byte HTTP_CLIENT_CONFIG_PATTERN_MATCH = 4; - public static final byte TAG_INTERCEPTOR = 5; - public static final byte MANUAL_INSTRUMENTATION = 6; + public static final byte DEFAULT = 0; + public static final byte HTTP_PATH_NORMALIZER = 1; + public static final byte HTTP_404 = 2; + public static final byte HTTP_FRAMEWORK_ROUTE = 3; + public static final byte RPC_COMMAND_NAME = 3; + public static final byte HTTP_SERVER_CONFIG_PATTERN_MATCH = 4; + public static final byte HTTP_CLIENT_CONFIG_PATTERN_MATCH = 4; + public static final byte TAG_INTERCEPTOR = 5; + public static final byte MANUAL_INSTRUMENTATION = 6; } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SamplerConstants.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SamplerConstants.java index eae61cdc310..3cc0859b19a 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SamplerConstants.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SamplerConstants.java @@ -2,8 +2,8 @@ public class SamplerConstants { - public static final String KEEP = "keep"; - public static final String DROP = "drop"; + public static final String KEEP = "keep"; + public static final String DROP = "drop"; - private SamplerConstants() {} + private SamplerConstants() {} } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ServiceNameSources.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ServiceNameSources.java index e7896972944..189f9811573 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ServiceNameSources.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ServiceNameSources.java @@ -1,22 +1,19 @@ package datadog.trace.bootstrap.instrumentation.api; public final class ServiceNameSources { - public static final CharSequence DB_CLIENT_SPLIT_BY_HOST = - UTF8BytesString.create("opt.db_client_split_by_host"); - public static final CharSequence DB_CLIENT_SPLIT_BY_INSTANCE = - UTF8BytesString.create("opt.db_client_split_by_instance"); - public static final CharSequence HTTP_CLIENT_SPLIT_BY_DOMAIN = - UTF8BytesString.create("opt.http_client_split_by_domain"); - public static final CharSequence JEE_SPLIT_BY_DEPLOYMENT = - UTF8BytesString.create("opt.jee_split_by_deployment"); - public static final CharSequence MESSAGE_BROKER_SPLIT_BY_DESTINATION = - UTF8BytesString.create("opt.message_broker_split_by_destination"); - public static final CharSequence SPLIT_BY_SERVLET_CONTEXT = - UTF8BytesString.create("opt.split_by_servlet_context"); - public static final CharSequence SPLIT_BY_TAGS = UTF8BytesString.create("opt.split_by_tags"); - public static final CharSequence MANUAL = UTF8BytesString.create("m"); + public static final CharSequence DB_CLIENT_SPLIT_BY_HOST = UTF8BytesString.create("opt.db_client_split_by_host"); + public static final CharSequence DB_CLIENT_SPLIT_BY_INSTANCE = + UTF8BytesString.create("opt.db_client_split_by_instance"); + public static final CharSequence HTTP_CLIENT_SPLIT_BY_DOMAIN = + UTF8BytesString.create("opt.http_client_split_by_domain"); + public static final CharSequence JEE_SPLIT_BY_DEPLOYMENT = UTF8BytesString.create("opt.jee_split_by_deployment"); + public static final CharSequence MESSAGE_BROKER_SPLIT_BY_DESTINATION = + UTF8BytesString.create("opt.message_broker_split_by_destination"); + public static final CharSequence SPLIT_BY_SERVLET_CONTEXT = UTF8BytesString.create("opt.split_by_servlet_context"); + public static final CharSequence SPLIT_BY_TAGS = UTF8BytesString.create("opt.split_by_tags"); + public static final CharSequence MANUAL = UTF8BytesString.create("m"); - private ServiceNameSources() { - // utility class - no instantiation - } + private ServiceNameSources() { + // utility class - no instantiation + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanAttributes.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanAttributes.java index 169e01da374..0e3d54b5962 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanAttributes.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanAttributes.java @@ -9,133 +9,133 @@ import java.util.Objects; public class SpanAttributes { - /** Represent an empty attributes. */ - public static final SpanAttributes EMPTY = new SpanAttributes(Collections.emptyMap()); - - private final Map attributes; - - protected SpanAttributes(Map attributes) { - this.attributes = attributes; - } - - /** - * Gets a builder to create attributes. - * - * @return A builder to create attributes. - */ - public static Builder builder() { - return new Builder(); - } - - /** - * Create attributes from its map representation. - * - * @param map A map representing the attributes. - * @return The related attributes. - */ - public static SpanAttributes fromMap(Map map) { - return new SpanAttributes(new HashMap<>(map)); - } - - /** - * Gets the attributes as an immutable map. - * - * @return The attributes as an immutable map. - */ - public Map asMap() { - return this.attributes; - } - - /** - * Checks whether the attributes are empty. - * - * @return {@code true} if the attributes are empty, {@code false} otherwise. - */ - public boolean isEmpty() { - return this.attributes.isEmpty(); - } - - @Override - public String toString() { - return "SpanAttributes{" + this.attributes + '}'; - } - - public static class Builder { + /** Represent an empty attributes. */ + public static final SpanAttributes EMPTY = new SpanAttributes(Collections.emptyMap()); + private final Map attributes; - protected Builder() { - this.attributes = new HashMap<>(); + protected SpanAttributes(Map attributes) { + this.attributes = attributes; } - public Builder put(String key, String value) { - requireNonNull(key, "key must not be null"); - if (value != null) { - this.attributes.put(key, value); - } - return this; + /** + * Gets a builder to create attributes. + * + * @return A builder to create attributes. + */ + public static Builder builder() { + return new Builder(); } - public Builder put(String key, boolean value) { - requireNonNull(key, "key must not be null"); - this.attributes.put(key, Boolean.toString(value)); - return this; + /** + * Create attributes from its map representation. + * + * @param map A map representing the attributes. + * @return The related attributes. + */ + public static SpanAttributes fromMap(Map map) { + return new SpanAttributes(new HashMap<>(map)); } - public Builder put(String key, long value) { - requireNonNull(key, "key must not be null"); - this.attributes.put(key, Long.toString(value)); - return this; + /** + * Gets the attributes as an immutable map. + * + * @return The attributes as an immutable map. + */ + public Map asMap() { + return this.attributes; } - public Builder put(String key, double value) { - requireNonNull(key, "key must not be null"); - this.attributes.put(key, Double.toString(value)); - return this; + /** + * Checks whether the attributes are empty. + * + * @return {@code true} if the attributes are empty, {@code false} otherwise. + */ + public boolean isEmpty() { + return this.attributes.isEmpty(); } - public Builder putStringArray(String key, List array) { - return putArray(key, array); + @Override + public String toString() { + return "SpanAttributes{" + this.attributes + '}'; } - public Builder putBooleanArray(String key, List array) { - return putArray(key, array); - } + public static class Builder { + private final Map attributes; - public Builder putLongArray(String key, List array) { - return putArray(key, array); - } + protected Builder() { + this.attributes = new HashMap<>(); + } - public Builder putDoubleArray(String key, List array) { - return putArray(key, array); - } + public Builder put(String key, String value) { + requireNonNull(key, "key must not be null"); + if (value != null) { + this.attributes.put(key, value); + } + return this; + } + + public Builder put(String key, boolean value) { + requireNonNull(key, "key must not be null"); + this.attributes.put(key, Boolean.toString(value)); + return this; + } + + public Builder put(String key, long value) { + requireNonNull(key, "key must not be null"); + this.attributes.put(key, Long.toString(value)); + return this; + } + + public Builder put(String key, double value) { + requireNonNull(key, "key must not be null"); + this.attributes.put(key, Double.toString(value)); + return this; + } - protected Builder putArray(String key, List array) { - requireNonNull(key, "key must not be null"); - if (array != null) { - for (int index = 0; index < array.size(); index++) { - Object value = array.get(index); - if (value != null) { - this.attributes.put(key + "." + index, value.toString()); - } + public Builder putStringArray(String key, List array) { + return putArray(key, array); } - } - return this; + + public Builder putBooleanArray(String key, List array) { + return putArray(key, array); + } + + public Builder putLongArray(String key, List array) { + return putArray(key, array); + } + + public Builder putDoubleArray(String key, List array) { + return putArray(key, array); + } + + protected Builder putArray(String key, List array) { + requireNonNull(key, "key must not be null"); + if (array != null) { + for (int index = 0; index < array.size(); index++) { + Object value = array.get(index); + if (value != null) { + this.attributes.put(key + "." + index, value.toString()); + } + } + } + return this; + } + + public SpanAttributes build() { + return new SpanAttributes(this.attributes); + } + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof SpanAttributes)) return false; + SpanAttributes that = (SpanAttributes) o; + return Objects.equals(attributes, that.attributes); } - public SpanAttributes build() { - return new SpanAttributes(this.attributes); + @Override + public int hashCode() { + return Objects.hashCode(attributes); } - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof SpanAttributes)) return false; - SpanAttributes that = (SpanAttributes) o; - return Objects.equals(attributes, that.attributes); - } - - @Override - public int hashCode() { - return Objects.hashCode(attributes); - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanLink.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanLink.java index 11351d2d594..0a276faaa30 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanLink.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanLink.java @@ -6,94 +6,88 @@ /** This class is a base implementation of {@link AgentSpanLink}. */ public class SpanLink implements AgentSpanLink { - private final DDTraceId traceId; - private final long spanId; - private final byte traceFlags; - private final String traceState; - private final SpanAttributes attributes; + private final DDTraceId traceId; + private final long spanId; + private final byte traceFlags; + private final String traceState; + private final SpanAttributes attributes; - protected SpanLink( - DDTraceId traceId, - long spanId, - byte traceFlags, - String traceState, - SpanAttributes attributes) { - this.traceId = traceId == null ? DDTraceId.ZERO : traceId; - this.spanId = spanId; - this.traceFlags = traceFlags; - this.traceState = traceState == null ? "" : traceState; - this.attributes = attributes == null ? EMPTY : attributes; - } + protected SpanLink(DDTraceId traceId, long spanId, byte traceFlags, String traceState, SpanAttributes attributes) { + this.traceId = traceId == null ? DDTraceId.ZERO : traceId; + this.spanId = spanId; + this.traceFlags = traceFlags; + this.traceState = traceState == null ? "" : traceState; + this.attributes = attributes == null ? EMPTY : attributes; + } - /** - * Creates a span link from a span context. Gathers the trace and span identifiers from the given - * instance. - * - * @param context The context of the span to get the link to. - * @return A span link to the given context. - */ - public static SpanLink from(AgentSpanContext context) { - return from(context, DEFAULT_FLAGS, "", EMPTY); - } + /** + * Creates a span link from a span context. Gathers the trace and span identifiers from the given + * instance. + * + * @param context The context of the span to get the link to. + * @return A span link to the given context. + */ + public static SpanLink from(AgentSpanContext context) { + return from(context, DEFAULT_FLAGS, "", EMPTY); + } - /** - * Creates a span link from a span context with W3C trace state and custom attributes. Gathers the - * trace and span identifiers from the given instance. - * - * @param context The context of the span to get the link to. - * @param traceFlags The W3C formatted trace flags. - * @param traceState The W3C formatted trace state. - * @param attributes The link attributes. - * @return A span link to the given context. - */ - public static SpanLink from( - AgentSpanContext context, byte traceFlags, String traceState, SpanAttributes attributes) { - if (context.getSamplingPriority() > 0) { - traceFlags = (byte) (traceFlags | SAMPLED_FLAG); + /** + * Creates a span link from a span context with W3C trace state and custom attributes. Gathers the + * trace and span identifiers from the given instance. + * + * @param context The context of the span to get the link to. + * @param traceFlags The W3C formatted trace flags. + * @param traceState The W3C formatted trace state. + * @param attributes The link attributes. + * @return A span link to the given context. + */ + public static SpanLink from( + AgentSpanContext context, byte traceFlags, String traceState, SpanAttributes attributes) { + if (context.getSamplingPriority() > 0) { + traceFlags = (byte) (traceFlags | SAMPLED_FLAG); + } + return new SpanLink(context.getTraceId(), context.getSpanId(), traceFlags, traceState, attributes); } - return new SpanLink( - context.getTraceId(), context.getSpanId(), traceFlags, traceState, attributes); - } - @Override - public DDTraceId traceId() { - return this.traceId; - } + @Override + public DDTraceId traceId() { + return this.traceId; + } - @Override - public long spanId() { - return this.spanId; - } + @Override + public long spanId() { + return this.spanId; + } - @Override - public byte traceFlags() { - return this.traceFlags; - } + @Override + public byte traceFlags() { + return this.traceFlags; + } - @Override - public String traceState() { - return this.traceState; - } + @Override + public String traceState() { + return this.traceState; + } - @Override - public SpanAttributes attributes() { - return this.attributes; - } + @Override + public SpanAttributes attributes() { + return this.attributes; + } - @Override - public String toString() { - return "SpanLink{" - + "traceId=" - + this.traceId - + ", spanId=" - + this.spanId - + ", traceFlags=" - + this.traceFlags - + ", traceState='" - + this.traceState - + '\'' - + ", attributes=" - + this.attributes - + '}'; - } + @Override + public String toString() { + return "SpanLink{" + + "traceId=" + + this.traceId + + ", spanId=" + + this.spanId + + ", traceFlags=" + + this.traceFlags + + ", traceState='" + + this.traceState + + '\'' + + ", attributes=" + + this.attributes + + '}'; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPostProcessor.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPostProcessor.java index 137ddedba1d..bfc174bc56a 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPostProcessor.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPostProcessor.java @@ -13,25 +13,25 @@ */ public interface SpanPostProcessor { - /** - * Post-processes a span, if needed. - * - *

Implementations should use {@code timeoutCheck}, and if true, they should halt processing as - * much as possible. This method is guaranteed to be called even if post-processing of previous - * spans have timed out. - */ - void process(@Nonnull AgentSpan span, @Nonnull BooleanSupplier timeoutCheck); + /** + * Post-processes a span, if needed. + * + *

Implementations should use {@code timeoutCheck}, and if true, they should halt processing as + * much as possible. This method is guaranteed to be called even if post-processing of previous + * spans have timed out. + */ + void process(@Nonnull AgentSpan span, @Nonnull BooleanSupplier timeoutCheck); - class Holder { - public static final SpanPostProcessor NOOP = new NoOpSpanPostProcessor(); + class Holder { + public static final SpanPostProcessor NOOP = new NoOpSpanPostProcessor(); - // XXX: At the moment, a single post-processor can be registered, and only AppSec defines one. - // If other products add their own, we'll need to refactor this to support multiple processors. - public static volatile SpanPostProcessor INSTANCE = NOOP; - } + // XXX: At the moment, a single post-processor can be registered, and only AppSec defines one. + // If other products add their own, we'll need to refactor this to support multiple processors. + public static volatile SpanPostProcessor INSTANCE = NOOP; + } - class NoOpSpanPostProcessor implements SpanPostProcessor { - @Override - public void process(@Nonnull AgentSpan span, @Nonnull BooleanSupplier timeoutCheck) {} - } + class NoOpSpanPostProcessor implements SpanPostProcessor { + @Override + public void process(@Nonnull AgentSpan span, @Nonnull BooleanSupplier timeoutCheck) {} + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java index 24ccd2b39d8..0a45765caaf 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -17,163 +17,163 @@ * deliberately out — grown when the work that needs each arrives, not pre-slotted. */ public final class SpanPrototype { - /** The empty prototype — for spans created without a decorator-provided prototype. */ - public static final SpanPrototype NONE = builder().build(); - - public static Builder builder() { - return new Builder(); - } - - private final String instrumentationName; - private final CharSequence operationName; - private final CharSequence spanType; - private final CharSequence integrationName; - private final TagMap tags; // frozen - - private SpanPrototype(final Builder builder) { - this.instrumentationName = builder.instrumentationName; - this.operationName = builder.operationName; - this.spanType = builder.spanType; - this.integrationName = builder.integrationName; - this.tags = builder.tags.immutableCopy(); - } - - public String instrumentationName() { - return instrumentationName; - } - - public CharSequence operationName() { - return operationName; - } - - public CharSequence spanType() { - return spanType; - } - - /** - * The integration name to record on the span context ({@code setIntegrationName}), which the - * IntegrationAdder serializer step turns into {@code _dd.integration}. Null unless set via {@link - * Builder#initComponentAndIntegration}. Mirrors {@code BaseDecorator.afterStart}'s side effect. - */ - public CharSequence integrationName() { - return integrationName; - } - - /** The frozen constant tags — the internal seed applied at span construction. */ - public TagMap tags() { - return tags; - } - - public static final class Builder { - private String instrumentationName; - private CharSequence operationName; - private CharSequence spanType; - private CharSequence integrationName; - // Internal accumulator — never exposed; authors compose via the typed methods below. - private final TagMap tags = TagMap.create(); - - private Builder() {} + /** The empty prototype — for spans created without a decorator-provided prototype. */ + public static final SpanPrototype NONE = builder().build(); - /** - * Inherit a base prototype's identity and constant tags (e.g. a SpanType base). Subsequent - * identity / {@code init*} calls on this builder override the inherited values. - */ - public Builder extends_(final SpanPrototype base) { - if (base != null) { - if (base.instrumentationName != null) { - this.instrumentationName = base.instrumentationName; - } - if (base.operationName != null) { - this.operationName = base.operationName; - } - if (base.spanType != null) { - this.spanType = base.spanType; - } - if (base.integrationName != null) { - this.integrationName = base.integrationName; - } - this.tags.putAll(base.tags); - } - return this; - } - - public Builder initInstrumentationNames(final String[] instrumentationNames) { - return (instrumentationNames == null || instrumentationNames.length == 0) - ? this - : initInstrumentationName(instrumentationNames[0]); + public static Builder builder() { + return new Builder(); } - public Builder initInstrumentationName(final String instrumentationName) { - this.instrumentationName = instrumentationName; - return this; + private final String instrumentationName; + private final CharSequence operationName; + private final CharSequence spanType; + private final CharSequence integrationName; + private final TagMap tags; // frozen + + private SpanPrototype(final Builder builder) { + this.instrumentationName = builder.instrumentationName; + this.operationName = builder.operationName; + this.spanType = builder.spanType; + this.integrationName = builder.integrationName; + this.tags = builder.tags.immutableCopy(); } - public Builder initOperationName(final CharSequence operationName) { - this.operationName = operationName; - return this; + public String instrumentationName() { + return instrumentationName; } - public Builder initSpanType(final CharSequence spanType) { - this.spanType = spanType; - return this; + public CharSequence operationName() { + return operationName; } - /** Sets {@code span.kind}. */ - public Builder initKind(final CharSequence kind) { - return initTag(Tags.SPAN_KIND, kind); + public CharSequence spanType() { + return spanType; } /** - * Sets the {@code component} tag only. Does NOT touch the integration name — so overriding a - * component inherited via {@link #initComponentAndIntegration} with this leaves the inherited - * integration name in place (a desync). Use {@link #initComponentAndIntegration} to override - * both together. + * The integration name to record on the span context ({@code setIntegrationName}), which the + * IntegrationAdder serializer step turns into {@code _dd.integration}. Null unless set via {@link + * Builder#initComponentAndIntegration}. Mirrors {@code BaseDecorator.afterStart}'s side effect. */ - public Builder initComponentOnly(final CharSequence component) { - return initTag(Tags.COMPONENT, component); + public CharSequence integrationName() { + return integrationName; } - /** - * Sets the {@code component} tag AND records it as the integration name — the {@code - * BaseDecorator.afterStart} pairing (component tag + {@code setIntegrationName(component)}, - * which the IntegrationAdder serializer turns into {@code _dd.integration}). Null/empty is a - * no-op for both. - */ - public Builder initComponentAndIntegration(final CharSequence component) { - if (!TagMap.Entry.isEmptyValue(component)) { - this.tags.set(Tags.COMPONENT, component); - this.integrationName = component; - } - return this; + /** The frozen constant tags — the internal seed applied at span construction. */ + public TagMap tags() { + return tags; } - public Builder initTag(final String key, final CharSequence value) { - if (!TagMap.Entry.isEmptyValue(value)) { - this.tags.set(key, value); - } - return this; - } + public static final class Builder { + private String instrumentationName; + private CharSequence operationName; + private CharSequence spanType; + private CharSequence integrationName; + // Internal accumulator — never exposed; authors compose via the typed methods below. + private final TagMap tags = TagMap.create(); + + private Builder() {} + + /** + * Inherit a base prototype's identity and constant tags (e.g. a SpanType base). Subsequent + * identity / {@code init*} calls on this builder override the inherited values. + */ + public Builder extends_(final SpanPrototype base) { + if (base != null) { + if (base.instrumentationName != null) { + this.instrumentationName = base.instrumentationName; + } + if (base.operationName != null) { + this.operationName = base.operationName; + } + if (base.spanType != null) { + this.spanType = base.spanType; + } + if (base.integrationName != null) { + this.integrationName = base.integrationName; + } + this.tags.putAll(base.tags); + } + return this; + } - public Builder initTag(final String key, final Object value) { - if (!TagMap.Entry.isEmptyValue(value)) { - this.tags.set(key, value); - } - return this; - } + public Builder initInstrumentationNames(final String[] instrumentationNames) { + return (instrumentationNames == null || instrumentationNames.length == 0) + ? this + : initInstrumentationName(instrumentationNames[0]); + } - /** - * Advanced/internal: reuse an already-built entry — a decorator's cached constant or a metric - * entry — rather than re-creating it. Authors should prefer the typed {@code init*} methods. - */ - public Builder initTag(final TagMap.EntryReader entry) { - if (entry != null) { - this.tags.set(entry); - } - return this; - } + public Builder initInstrumentationName(final String instrumentationName) { + this.instrumentationName = instrumentationName; + return this; + } - public SpanPrototype build() { - return new SpanPrototype(this); + public Builder initOperationName(final CharSequence operationName) { + this.operationName = operationName; + return this; + } + + public Builder initSpanType(final CharSequence spanType) { + this.spanType = spanType; + return this; + } + + /** Sets {@code span.kind}. */ + public Builder initKind(final CharSequence kind) { + return initTag(Tags.SPAN_KIND, kind); + } + + /** + * Sets the {@code component} tag only. Does NOT touch the integration name — so overriding a + * component inherited via {@link #initComponentAndIntegration} with this leaves the inherited + * integration name in place (a desync). Use {@link #initComponentAndIntegration} to override + * both together. + */ + public Builder initComponentOnly(final CharSequence component) { + return initTag(Tags.COMPONENT, component); + } + + /** + * Sets the {@code component} tag AND records it as the integration name — the {@code + * BaseDecorator.afterStart} pairing (component tag + {@code setIntegrationName(component)}, + * which the IntegrationAdder serializer turns into {@code _dd.integration}). Null/empty is a + * no-op for both. + */ + public Builder initComponentAndIntegration(final CharSequence component) { + if (!TagMap.Entry.isEmptyValue(component)) { + this.tags.set(Tags.COMPONENT, component); + this.integrationName = component; + } + return this; + } + + public Builder initTag(final String key, final CharSequence value) { + if (!TagMap.Entry.isEmptyValue(value)) { + this.tags.set(key, value); + } + return this; + } + + public Builder initTag(final String key, final Object value) { + if (!TagMap.Entry.isEmptyValue(value)) { + this.tags.set(key, value); + } + return this; + } + + /** + * Advanced/internal: reuse an already-built entry — a decorator's cached constant or a metric + * entry — rather than re-creating it. Authors should prefer the typed {@code init*} methods. + */ + public Builder initTag(final TagMap.EntryReader entry) { + if (entry != null) { + this.tags.set(entry); + } + return this; + } + + public SpanPrototype build() { + return new SpanPrototype(this); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanWrapper.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanWrapper.java index 8c0b8241c13..95ce45708d8 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanWrapper.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanWrapper.java @@ -6,5 +6,5 @@ * specific logic runs when spans are auto-finished. */ public interface SpanWrapper { - default void onSpanFinished() {} + default void onSpanFinished() {} } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContext.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContext.java index 078ffeb1625..c34da34a578 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContext.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TagContext.java @@ -21,273 +21,273 @@ */ public class TagContext implements AgentSpanContext.Extracted { - private static final HttpHeaders EMPTY_HTTP_HEADERS = new HttpHeaders(); - - private final CharSequence origin; - private TagMap tags; - private List terminatedSpanLinks; - private Object requestContextDataAppSec; - private Object requestContextDataIast; - private Object ciVisibilityContextData; - private PathwayContext pathwayContext; - private final HttpHeaders httpHeaders; - private final Map baggage; - private Baggage w3cBaggage; - private final int samplingPriority; - private final TraceConfig traceConfig; - private final TracePropagationStyle propagationStyle; - private final DDTraceId traceId; - - public TagContext() { - this(null, null); - } - - public TagContext(final CharSequence origin, final TagMap tags) { - this(origin, tags, null, null, PrioritySampling.UNSET, null, NONE, DDTraceId.ZERO); - } - - public TagContext( - final CharSequence origin, - final TagMap tags, - final HttpHeaders httpHeaders, - final Map baggage, - final int samplingPriority, - final TraceConfig traceConfig, - final TracePropagationStyle propagationStyle, - final DDTraceId traceId) { - this.origin = origin; - this.tags = tags; - this.terminatedSpanLinks = null; - this.httpHeaders = httpHeaders == null ? EMPTY_HTTP_HEADERS : httpHeaders; - this.baggage = baggage == null ? Collections.emptyMap() : baggage; - this.samplingPriority = samplingPriority; - this.traceConfig = traceConfig; - this.propagationStyle = propagationStyle; - this.traceId = traceId; - } - - public TraceConfig getTraceConfig() { - return traceConfig; - } - - public TracePropagationStyle getPropagationStyle() { - return this.propagationStyle; - } - - public final CharSequence getOrigin() { - return origin; - } - - @Override - public List getTerminatedSpanLinks() { - return this.terminatedSpanLinks == null ? emptyList() : this.terminatedSpanLinks; - } - - public void addTerminatedSpanLink(AgentSpanLink link) { - if (this.terminatedSpanLinks == null) { - this.terminatedSpanLinks = new ArrayList<>(); - } - this.terminatedSpanLinks.add(link); - } - - @Override - public String getForwarded() { - return httpHeaders.forwarded; - } - - @Override - public String getFastlyClientIp() { - return httpHeaders.fastlyClientIp; - } - - @Override - public String getCfConnectingIp() { - return httpHeaders.cfConnectingIp; - } - - @Override - public String getCfConnectingIpv6() { - return httpHeaders.cfConnectingIpv6; - } - - @Override - public String getXForwardedProto() { - return httpHeaders.xForwardedProto; - } - - @Override - public String getXForwardedHost() { - return httpHeaders.xForwardedHost; - } - - @Override - public String getXForwardedPort() { - return httpHeaders.xForwardedPort; - } - - @Override - public String getForwardedFor() { - return httpHeaders.forwardedFor; - } - - @Override - public String getXForwardedFor() { - return httpHeaders.xForwardedFor; - } - - @Override - public String getXClusterClientIp() { - return httpHeaders.xClusterClientIp; - } - - @Override - public String getXRealIp() { - return httpHeaders.xRealIp; - } - - @Override - public String getXClientIp() { - return httpHeaders.xClientIp; - } - - @Override - public String getUserAgent() { - return httpHeaders.userAgent; - } - - @Override - public String getTrueClientIp() { - return httpHeaders.trueClientIp; - } - - @Override - public String getCustomIpHeader() { - return httpHeaders.customIpHeader; - } - - public final TagMap getTags() { - return (this.tags == null) ? TagMap.EMPTY : this.tags; - } - - public void putTag(final String key, final String value) { - if (this.tags == null) { - this.tags = TagMap.create(4); - } - this.tags.set(key, value); - } - - @Override - public final int getSamplingPriority() { - return samplingPriority; - } - - public final Map getBaggage() { - return baggage; - } - - @Override - public Iterable> baggageItems() { - return baggage.entrySet(); - } - - public final Baggage getW3CBaggage() { - return w3cBaggage; - } - - public void setW3CBaggage(Baggage w3cBaggage) { - this.w3cBaggage = w3cBaggage; - } - - @Override - public DDTraceId getTraceId() { - return traceId; - } - - @Override - public long getSpanId() { - return DDSpanId.ZERO; - } - - @Override - public final AgentTraceCollector getTraceCollector() { - return AgentTracer.NoopAgentTraceCollector.INSTANCE; - } - - public final Object getRequestContextDataAppSec() { - return requestContextDataAppSec; - } - - public final TagContext withRequestContextDataAppSec(Object requestContextData) { - this.requestContextDataAppSec = requestContextData; - return this; - } - - public final Object getRequestContextDataIast() { - return requestContextDataIast; - } - - public final TagContext withRequestContextDataIast(Object requestContextData) { - this.requestContextDataIast = requestContextData; - return this; - } - - public Object getCiVisibilityContextData() { - return ciVisibilityContextData; - } - - public TagContext withCiVisibilityContextData(Object ciVisibilityContextData) { - this.ciVisibilityContextData = ciVisibilityContextData; - return this; - } - - @Override - public PathwayContext getPathwayContext() { - return this.pathwayContext; - } - - @Override - public boolean isRemote() { - return true; - } - - public TagContext withPathwayContext(PathwayContext pathwayContext) { - this.pathwayContext = pathwayContext; - return this; - } - - @Override - public String toString() { - StringBuilder builder = new StringBuilder("TagContext{"); - if (origin != null) { - builder.append("origin=").append(origin).append(", "); - } - if (tags != null) { - builder.append("tags=").append(tags).append(", "); - } - if (baggage != null) { - builder.append("baggage=").append(baggage).append(", "); - } - if (samplingPriority != PrioritySampling.UNSET) { - builder.append("samplingPriority=").append(samplingPriority).append(", "); - } - return builder.append('}').toString(); - } - - public static class HttpHeaders { - public String fastlyClientIp; - public String cfConnectingIp; - public String cfConnectingIpv6; - public String xForwardedProto; - public String xForwardedHost; - public String xForwardedPort; - public String xForwardedFor; - public String forwarded; - public String forwardedFor; - public String xClusterClientIp; - public String xRealIp; - public String xClientIp; - public String userAgent; - public String trueClientIp; - public String customIpHeader; - } + private static final HttpHeaders EMPTY_HTTP_HEADERS = new HttpHeaders(); + + private final CharSequence origin; + private TagMap tags; + private List terminatedSpanLinks; + private Object requestContextDataAppSec; + private Object requestContextDataIast; + private Object ciVisibilityContextData; + private PathwayContext pathwayContext; + private final HttpHeaders httpHeaders; + private final Map baggage; + private Baggage w3cBaggage; + private final int samplingPriority; + private final TraceConfig traceConfig; + private final TracePropagationStyle propagationStyle; + private final DDTraceId traceId; + + public TagContext() { + this(null, null); + } + + public TagContext(final CharSequence origin, final TagMap tags) { + this(origin, tags, null, null, PrioritySampling.UNSET, null, NONE, DDTraceId.ZERO); + } + + public TagContext( + final CharSequence origin, + final TagMap tags, + final HttpHeaders httpHeaders, + final Map baggage, + final int samplingPriority, + final TraceConfig traceConfig, + final TracePropagationStyle propagationStyle, + final DDTraceId traceId) { + this.origin = origin; + this.tags = tags; + this.terminatedSpanLinks = null; + this.httpHeaders = httpHeaders == null ? EMPTY_HTTP_HEADERS : httpHeaders; + this.baggage = baggage == null ? Collections.emptyMap() : baggage; + this.samplingPriority = samplingPriority; + this.traceConfig = traceConfig; + this.propagationStyle = propagationStyle; + this.traceId = traceId; + } + + public TraceConfig getTraceConfig() { + return traceConfig; + } + + public TracePropagationStyle getPropagationStyle() { + return this.propagationStyle; + } + + public final CharSequence getOrigin() { + return origin; + } + + @Override + public List getTerminatedSpanLinks() { + return this.terminatedSpanLinks == null ? emptyList() : this.terminatedSpanLinks; + } + + public void addTerminatedSpanLink(AgentSpanLink link) { + if (this.terminatedSpanLinks == null) { + this.terminatedSpanLinks = new ArrayList<>(); + } + this.terminatedSpanLinks.add(link); + } + + @Override + public String getForwarded() { + return httpHeaders.forwarded; + } + + @Override + public String getFastlyClientIp() { + return httpHeaders.fastlyClientIp; + } + + @Override + public String getCfConnectingIp() { + return httpHeaders.cfConnectingIp; + } + + @Override + public String getCfConnectingIpv6() { + return httpHeaders.cfConnectingIpv6; + } + + @Override + public String getXForwardedProto() { + return httpHeaders.xForwardedProto; + } + + @Override + public String getXForwardedHost() { + return httpHeaders.xForwardedHost; + } + + @Override + public String getXForwardedPort() { + return httpHeaders.xForwardedPort; + } + + @Override + public String getForwardedFor() { + return httpHeaders.forwardedFor; + } + + @Override + public String getXForwardedFor() { + return httpHeaders.xForwardedFor; + } + + @Override + public String getXClusterClientIp() { + return httpHeaders.xClusterClientIp; + } + + @Override + public String getXRealIp() { + return httpHeaders.xRealIp; + } + + @Override + public String getXClientIp() { + return httpHeaders.xClientIp; + } + + @Override + public String getUserAgent() { + return httpHeaders.userAgent; + } + + @Override + public String getTrueClientIp() { + return httpHeaders.trueClientIp; + } + + @Override + public String getCustomIpHeader() { + return httpHeaders.customIpHeader; + } + + public final TagMap getTags() { + return (this.tags == null) ? TagMap.EMPTY : this.tags; + } + + public void putTag(final String key, final String value) { + if (this.tags == null) { + this.tags = TagMap.create(4); + } + this.tags.set(key, value); + } + + @Override + public final int getSamplingPriority() { + return samplingPriority; + } + + public final Map getBaggage() { + return baggage; + } + + @Override + public Iterable> baggageItems() { + return baggage.entrySet(); + } + + public final Baggage getW3CBaggage() { + return w3cBaggage; + } + + public void setW3CBaggage(Baggage w3cBaggage) { + this.w3cBaggage = w3cBaggage; + } + + @Override + public DDTraceId getTraceId() { + return traceId; + } + + @Override + public long getSpanId() { + return DDSpanId.ZERO; + } + + @Override + public final AgentTraceCollector getTraceCollector() { + return AgentTracer.NoopAgentTraceCollector.INSTANCE; + } + + public final Object getRequestContextDataAppSec() { + return requestContextDataAppSec; + } + + public final TagContext withRequestContextDataAppSec(Object requestContextData) { + this.requestContextDataAppSec = requestContextData; + return this; + } + + public final Object getRequestContextDataIast() { + return requestContextDataIast; + } + + public final TagContext withRequestContextDataIast(Object requestContextData) { + this.requestContextDataIast = requestContextData; + return this; + } + + public Object getCiVisibilityContextData() { + return ciVisibilityContextData; + } + + public TagContext withCiVisibilityContextData(Object ciVisibilityContextData) { + this.ciVisibilityContextData = ciVisibilityContextData; + return this; + } + + @Override + public PathwayContext getPathwayContext() { + return this.pathwayContext; + } + + @Override + public boolean isRemote() { + return true; + } + + public TagContext withPathwayContext(PathwayContext pathwayContext) { + this.pathwayContext = pathwayContext; + return this; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("TagContext{"); + if (origin != null) { + builder.append("origin=").append(origin).append(", "); + } + if (tags != null) { + builder.append("tags=").append(tags).append(", "); + } + if (baggage != null) { + builder.append("baggage=").append(baggage).append(", "); + } + if (samplingPriority != PrioritySampling.UNSET) { + builder.append("samplingPriority=").append(samplingPriority).append(", "); + } + return builder.append('}').toString(); + } + + public static class HttpHeaders { + public String fastlyClientIp; + public String cfConnectingIp; + public String cfConnectingIpv6; + public String xForwardedProto; + public String xForwardedHost; + public String xForwardedPort; + public String xForwardedFor; + public String forwarded; + public String forwardedFor; + public String xClusterClientIp; + public String xRealIp; + public String xClientIp; + public String userAgent; + public String trueClientIp; + public String customIpHeader; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java index 9e96c17a486..cbefdd70a6a 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java @@ -3,200 +3,196 @@ // standard tag names (and span kind values) from OpenTracing (see io.opentracing.tag.Tags) public class Tags { - public static final String SPAN_KIND_SERVER = "server"; - public static final String SPAN_KIND_CLIENT = "client"; - public static final String SPAN_KIND_PRODUCER = "producer"; - public static final String SPAN_KIND_CONSUMER = "consumer"; - public static final String SPAN_KIND_BROKER = "broker"; - public static final String SPAN_KIND_TEST = "test"; - public static final String SPAN_KIND_TEST_SUITE = "test_suite_end"; - public static final String SPAN_KIND_TEST_MODULE = "test_module_end"; - public static final String SPAN_KIND_TEST_SESSION = "test_session_end"; - public static final String SPAN_KIND_INTERNAL = "internal"; - - public static final String HTTP_URL = "http.url"; - public static final String HTTP_HOSTNAME = "http.hostname"; - public static final String HTTP_ROUTE = "http.route"; - public static final String HTTP_STATUS = "http.status_code"; - public static final String HTTP_METHOD = "http.method"; - public static final String HTTP_ENDPOINT = "http.endpoint"; - public static final String HTTP_FORWARDED = "http.forwarded"; - public static final String HTTP_FORWARDED_PROTO = "http.forwarded.proto"; - public static final String HTTP_FORWARDED_HOST = "http.forwarded.host"; - public static final String HTTP_FORWARDED_IP = "http.forwarded.ip"; - public static final String HTTP_FORWARDED_PORT = "http.forwarded.port"; - public static final String HTTP_USER_AGENT = "http.useragent"; - public static final String HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN = - "http.request.headers.x-datadog-endpoint-scan"; - public static final String HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST = - "http.request.headers.x-datadog-security-test"; - public static final String HTTP_CLIENT_IP = "http.client_ip"; - public static final String NETWORK_CLIENT_IP = "network.client.ip"; - public static final String HTTP_REQUEST_CONTENT_LENGTH = "http.request_content_length"; - public static final String HTTP_RESPONSE_CONTENT_LENGTH = "http.response_content_length"; - public static final String PEER_HOST_IPV4 = "peer.ipv4"; - public static final String PEER_HOST_IPV6 = "peer.ipv6"; - public static final String PEER_SERVICE = "peer.service"; - public static final String PEER_HOSTNAME = "peer.hostname"; - public static final String PEER_PORT = "peer.port"; - public static final String RPC_SERVICE = "rpc.service"; - public static final String SAMPLING_PRIORITY = "sampling.priority"; - public static final String SPAN_KIND = "span.kind"; - public static final String COMPONENT = "component"; - public static final String ERROR = "error"; - public static final String DB_TYPE = "db.type"; - public static final String DB_INSTANCE = "db.instance"; - public static final String DB_USER = "db.user"; - public static final String DB_OPERATION = "db.operation"; - public static final String DB_STATEMENT = "db.statement"; - public static final String DB_WAREHOUSE = "db.warehouse"; - public static final String DB_HOST = "db.host"; - public static final String DB_SCHEMA = "db.schema"; - public static final String MESSAGE_BUS_DESTINATION = "message_bus.destination"; - public static final String DB_POOL_NAME = "db.pool.name"; - public static final String BASE_HASH = "_dd.propagated_hash"; - - public static final String TEST_SESSION_NAME = "test_session.name"; - public static final String TEST_MODULE = "test.module"; - public static final String TEST_SUITE = "test.suite"; - public static final String TEST_NAME = "test.name"; - public static final String TEST_STATUS = "test.status"; - public static final String TEST_FINAL_STATUS = "test.final_status"; - public static final String TEST_FRAMEWORK = "test.framework"; - public static final String TEST_FRAMEWORK_VERSION = "test.framework_version"; - public static final String TEST_SKIP_REASON = "test.skip_reason"; - public static final String TEST_SKIPPED_BY_ITR = "test.skipped_by_itr"; - public static final String TEST_TYPE = "test.type"; - public static final String TEST_PARAMETERS = "test.parameters"; - public static final String TEST_CODEOWNERS = "test.codeowners"; - public static final String TEST_SOURCE_FILE = "test.source.file"; - public static final String TEST_SOURCE_CLASS = "test.source.class"; - public static final String TEST_SOURCE_METHOD = "test.source.method"; - public static final String TEST_SOURCE_START = "test.source.start"; - public static final String TEST_SOURCE_END = "test.source.end"; - public static final String TEST_TRAITS = "test.traits"; - public static final String TEST_COMMAND = "test.command"; - public static final String TEST_TOOLCHAIN = "test.toolchain"; - public static final String TEST_EXECUTION = "test.execution"; - public static final String TEST_GRADLE_NESTED_BUILD = "test.gradle.nested_build"; - public static final String TEST_IS_RUM_ACTIVE = "test.is_rum_active"; - public static final String TEST_BROWSER_DRIVER = "test.browser.driver"; - public static final String TEST_BROWSER_DRIVER_VERSION = "test.browser.driver_version"; - public static final String TEST_BROWSER_NAME = "test.browser.name"; - public static final String TEST_BROWSER_VERSION = "test.browser.version"; - public static final String TEST_CALLBACK = "test.callback"; - public static final String TEST_ANDROID_API_LEVEL = "test.android.api_level"; - public static final String TEST_ANDROID_RELEASE = "test.android.release"; - public static final String TEST_ANDROID_CODENAME = "test.android.codename"; - public static final String TEST_ANDROID_ROBOLECTRIC_VERSION = "test.android.robolectric.version"; - - public static final String TEST_IS_ANDROID = "test.is_android"; - - public static final String TEST_SESSION_ID = "test_session_id"; - public static final String TEST_MODULE_ID = "test_module_id"; - public static final String TEST_SUITE_ID = "test_suite_id"; - public static final String ITR_CORRELATION_ID = "itr_correlation_id"; - public static final String TEST_CODE_COVERAGE_ENABLED = "test.code_coverage.enabled"; - public static final String TEST_CODE_COVERAGE_LINES_PERCENTAGE = "test.code_coverage.lines_pct"; - public static final String TEST_CODE_COVERAGE_BACKFILLED = "test.code_coverage.backfilled"; - public static final String TEST_ITR_TESTS_SKIPPING_ENABLED = "test.itr.tests_skipping.enabled"; - public static final String TEST_ITR_TESTS_SKIPPING_TYPE = "test.itr.tests_skipping.type"; - public static final String TEST_ITR_TESTS_SKIPPING_COUNT = "test.itr.tests_skipping.count"; - public static final String TEST_ITR_UNSKIPPABLE = "test.itr.unskippable"; - public static final String TEST_ITR_FORCED_RUN = "test.itr.forced_run"; - public static final String TEST_EARLY_FLAKE_ENABLED = "test.early_flake.enabled"; - public static final String TEST_EARLY_FLAKE_ABORT_REASON = "test.early_flake.abort_reason"; - public static final String TEST_IS_NEW = "test.is_new"; - public static final String TEST_IS_RETRY = "test.is_retry"; - public static final String TEST_RETRY_REASON = "test.retry_reason"; - public static final String TEST_JUNIT_IS_DYNAMIC = "test.junit5.is_dynamic"; - public static final String TEST_IS_MODIFIED = "test.is_modified"; - public static final String TEST_HAS_FAILED_ALL_RETRIES = "test.has_failed_all_retries"; - public static final String TEST_FAILURE_SUPPRESSED = "test.failure_suppressed"; - public static final String TEST_TEST_MANAGEMENT_ENABLED = "test.test_management.enabled"; - public static final String TEST_TEST_MANAGEMENT_IS_QUARANTINED = - "test.test_management.is_quarantined"; - public static final String TEST_TEST_MANAGEMENT_IS_TEST_DISABLED = - "test.test_management.is_test_disabled"; - public static final String TEST_TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX = - "test.test_management.is_attempt_to_fix"; - public static final String TEST_TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED = - "test.test_management.attempt_to_fix_passed"; - - public static final String ERROR_DEBUG_INFO_CAPTURED = "error.debug_info_captured"; - - public static final String CI_PROVIDER_NAME = "ci.provider.name"; - public static final String CI_PIPELINE_ID = "ci.pipeline.id"; - public static final String CI_PIPELINE_NAME = "ci.pipeline.name"; - public static final String CI_PIPELINE_DISPLAY_NAME = "ci.pipeline.display_name"; - public static final String CI_PIPELINE_NUMBER = "ci.pipeline.number"; - public static final String CI_PIPELINE_URL = "ci.pipeline.url"; - public static final String CI_STAGE_NAME = "ci.stage.name"; - public static final String CI_JOB_ID = "ci.job.id"; - public static final String CI_JOB_NAME = "ci.job.name"; - public static final String CI_JOB_URL = "ci.job.url"; - public static final String CI_WORKSPACE_PATH = "ci.workspace_path"; - public static final String CI_NODE_NAME = "ci.node.name"; - public static final String CI_NODE_LABELS = "ci.node.labels"; - - public static final String GIT_REPOSITORY_URL = "git.repository_url"; - public static final String GIT_COMMIT_SHA = "git.commit.sha"; - public static final String GIT_COMMIT_AUTHOR_NAME = "git.commit.author.name"; - public static final String GIT_COMMIT_AUTHOR_EMAIL = "git.commit.author.email"; - public static final String GIT_COMMIT_AUTHOR_DATE = "git.commit.author.date"; - public static final String GIT_COMMIT_COMMITTER_NAME = "git.commit.committer.name"; - public static final String GIT_COMMIT_COMMITTER_EMAIL = "git.commit.committer.email"; - public static final String GIT_COMMIT_COMMITTER_DATE = "git.commit.committer.date"; - public static final String GIT_COMMIT_MESSAGE = "git.commit.message"; - public static final String GIT_BRANCH = "git.branch"; - public static final String GIT_TAG = "git.tag"; - public static final String GIT_PULL_REQUEST_BASE_BRANCH = "git.pull_request.base_branch"; - public static final String GIT_PULL_REQUEST_BASE_BRANCH_SHA = "git.pull_request.base_branch_sha"; - public static final String GIT_PULL_REQUEST_BASE_BRANCH_HEAD_SHA = - "git.pull_request.base_branch_head_sha"; - public static final String GIT_COMMIT_HEAD_SHA = "git.commit.head.sha"; - public static final String GIT_COMMIT_HEAD_AUTHOR_NAME = "git.commit.head.author.name"; - public static final String GIT_COMMIT_HEAD_AUTHOR_EMAIL = "git.commit.head.author.email"; - public static final String GIT_COMMIT_HEAD_AUTHOR_DATE = "git.commit.head.author.date"; - public static final String GIT_COMMIT_HEAD_COMMITTER_NAME = "git.commit.head.committer.name"; - public static final String GIT_COMMIT_HEAD_COMMITTER_EMAIL = "git.commit.head.committer.email"; - public static final String GIT_COMMIT_HEAD_COMMITTER_DATE = "git.commit.head.committer.date"; - public static final String GIT_COMMIT_HEAD_MESSAGE = "git.commit.head.message"; - public static final String PULL_REQUEST_NUMBER = "pr.number"; - - public static final String RUNTIME_NAME = "runtime.name"; - public static final String RUNTIME_VENDOR = "runtime.vendor"; - public static final String RUNTIME_VERSION = "runtime.version"; - public static final String OS_ARCHITECTURE = "os.architecture"; - public static final String OS_PLATFORM = "os.platform"; - public static final String OS_VERSION = "os.version"; - - public static final String DD_SERVICE = "dd.service"; - public static final String DD_VERSION = "dd.version"; - public static final String DD_ENV = "dd.env"; - - public static final String ENV = "env"; - public static final String VERSION = "version"; - - /** ASM force tracer to keep the trace */ - public static final String ASM_KEEP = "asm.keep"; - - /** AI Guard force tracer to keep the trace */ - public static final String AI_GUARD_KEEP = "ai_guard.keep"; - - /** Marks a span as originating from an AI Guard evaluation */ - public static final String AI_GUARD_EVENT = "ai_guard.event"; - - public static final String PROPAGATED_TRACE_SOURCE = "_dd.p.ts"; - public static final String PROPAGATED_DEBUG = "_dd.p.debug"; - - public static final String LLMOBS_LLM_SPAN_KIND = "llm"; - public static final String LLMOBS_WORKFLOW_SPAN_KIND = "workflow"; - public static final String LLMOBS_TASK_SPAN_KIND = "task"; - public static final String LLMOBS_AGENT_SPAN_KIND = "agent"; - public static final String LLMOBS_TOOL_SPAN_KIND = "tool"; - public static final String LLMOBS_EMBEDDING_SPAN_KIND = "embedding"; - public static final String LLMOBS_RETRIEVAL_SPAN_KIND = "retrieval"; - public static final String DSM_TRANSACTION_ID = "dsm.transaction.id"; - public static final String DSM_TRANSACTION_CHECKPOINT = "dsm.transaction.checkpoint"; + public static final String SPAN_KIND_SERVER = "server"; + public static final String SPAN_KIND_CLIENT = "client"; + public static final String SPAN_KIND_PRODUCER = "producer"; + public static final String SPAN_KIND_CONSUMER = "consumer"; + public static final String SPAN_KIND_BROKER = "broker"; + public static final String SPAN_KIND_TEST = "test"; + public static final String SPAN_KIND_TEST_SUITE = "test_suite_end"; + public static final String SPAN_KIND_TEST_MODULE = "test_module_end"; + public static final String SPAN_KIND_TEST_SESSION = "test_session_end"; + public static final String SPAN_KIND_INTERNAL = "internal"; + + public static final String HTTP_URL = "http.url"; + public static final String HTTP_HOSTNAME = "http.hostname"; + public static final String HTTP_ROUTE = "http.route"; + public static final String HTTP_STATUS = "http.status_code"; + public static final String HTTP_METHOD = "http.method"; + public static final String HTTP_ENDPOINT = "http.endpoint"; + public static final String HTTP_FORWARDED = "http.forwarded"; + public static final String HTTP_FORWARDED_PROTO = "http.forwarded.proto"; + public static final String HTTP_FORWARDED_HOST = "http.forwarded.host"; + public static final String HTTP_FORWARDED_IP = "http.forwarded.ip"; + public static final String HTTP_FORWARDED_PORT = "http.forwarded.port"; + public static final String HTTP_USER_AGENT = "http.useragent"; + public static final String HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN = + "http.request.headers.x-datadog-endpoint-scan"; + public static final String HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST = + "http.request.headers.x-datadog-security-test"; + public static final String HTTP_CLIENT_IP = "http.client_ip"; + public static final String NETWORK_CLIENT_IP = "network.client.ip"; + public static final String HTTP_REQUEST_CONTENT_LENGTH = "http.request_content_length"; + public static final String HTTP_RESPONSE_CONTENT_LENGTH = "http.response_content_length"; + public static final String PEER_HOST_IPV4 = "peer.ipv4"; + public static final String PEER_HOST_IPV6 = "peer.ipv6"; + public static final String PEER_SERVICE = "peer.service"; + public static final String PEER_HOSTNAME = "peer.hostname"; + public static final String PEER_PORT = "peer.port"; + public static final String RPC_SERVICE = "rpc.service"; + public static final String SAMPLING_PRIORITY = "sampling.priority"; + public static final String SPAN_KIND = "span.kind"; + public static final String COMPONENT = "component"; + public static final String ERROR = "error"; + public static final String DB_TYPE = "db.type"; + public static final String DB_INSTANCE = "db.instance"; + public static final String DB_USER = "db.user"; + public static final String DB_OPERATION = "db.operation"; + public static final String DB_STATEMENT = "db.statement"; + public static final String DB_WAREHOUSE = "db.warehouse"; + public static final String DB_HOST = "db.host"; + public static final String DB_SCHEMA = "db.schema"; + public static final String MESSAGE_BUS_DESTINATION = "message_bus.destination"; + public static final String DB_POOL_NAME = "db.pool.name"; + public static final String BASE_HASH = "_dd.propagated_hash"; + + public static final String TEST_SESSION_NAME = "test_session.name"; + public static final String TEST_MODULE = "test.module"; + public static final String TEST_SUITE = "test.suite"; + public static final String TEST_NAME = "test.name"; + public static final String TEST_STATUS = "test.status"; + public static final String TEST_FINAL_STATUS = "test.final_status"; + public static final String TEST_FRAMEWORK = "test.framework"; + public static final String TEST_FRAMEWORK_VERSION = "test.framework_version"; + public static final String TEST_SKIP_REASON = "test.skip_reason"; + public static final String TEST_SKIPPED_BY_ITR = "test.skipped_by_itr"; + public static final String TEST_TYPE = "test.type"; + public static final String TEST_PARAMETERS = "test.parameters"; + public static final String TEST_CODEOWNERS = "test.codeowners"; + public static final String TEST_SOURCE_FILE = "test.source.file"; + public static final String TEST_SOURCE_CLASS = "test.source.class"; + public static final String TEST_SOURCE_METHOD = "test.source.method"; + public static final String TEST_SOURCE_START = "test.source.start"; + public static final String TEST_SOURCE_END = "test.source.end"; + public static final String TEST_TRAITS = "test.traits"; + public static final String TEST_COMMAND = "test.command"; + public static final String TEST_TOOLCHAIN = "test.toolchain"; + public static final String TEST_EXECUTION = "test.execution"; + public static final String TEST_GRADLE_NESTED_BUILD = "test.gradle.nested_build"; + public static final String TEST_IS_RUM_ACTIVE = "test.is_rum_active"; + public static final String TEST_BROWSER_DRIVER = "test.browser.driver"; + public static final String TEST_BROWSER_DRIVER_VERSION = "test.browser.driver_version"; + public static final String TEST_BROWSER_NAME = "test.browser.name"; + public static final String TEST_BROWSER_VERSION = "test.browser.version"; + public static final String TEST_CALLBACK = "test.callback"; + public static final String TEST_ANDROID_API_LEVEL = "test.android.api_level"; + public static final String TEST_ANDROID_RELEASE = "test.android.release"; + public static final String TEST_ANDROID_CODENAME = "test.android.codename"; + public static final String TEST_ANDROID_ROBOLECTRIC_VERSION = "test.android.robolectric.version"; + + public static final String TEST_IS_ANDROID = "test.is_android"; + + public static final String TEST_SESSION_ID = "test_session_id"; + public static final String TEST_MODULE_ID = "test_module_id"; + public static final String TEST_SUITE_ID = "test_suite_id"; + public static final String ITR_CORRELATION_ID = "itr_correlation_id"; + public static final String TEST_CODE_COVERAGE_ENABLED = "test.code_coverage.enabled"; + public static final String TEST_CODE_COVERAGE_LINES_PERCENTAGE = "test.code_coverage.lines_pct"; + public static final String TEST_CODE_COVERAGE_BACKFILLED = "test.code_coverage.backfilled"; + public static final String TEST_ITR_TESTS_SKIPPING_ENABLED = "test.itr.tests_skipping.enabled"; + public static final String TEST_ITR_TESTS_SKIPPING_TYPE = "test.itr.tests_skipping.type"; + public static final String TEST_ITR_TESTS_SKIPPING_COUNT = "test.itr.tests_skipping.count"; + public static final String TEST_ITR_UNSKIPPABLE = "test.itr.unskippable"; + public static final String TEST_ITR_FORCED_RUN = "test.itr.forced_run"; + public static final String TEST_EARLY_FLAKE_ENABLED = "test.early_flake.enabled"; + public static final String TEST_EARLY_FLAKE_ABORT_REASON = "test.early_flake.abort_reason"; + public static final String TEST_IS_NEW = "test.is_new"; + public static final String TEST_IS_RETRY = "test.is_retry"; + public static final String TEST_RETRY_REASON = "test.retry_reason"; + public static final String TEST_JUNIT_IS_DYNAMIC = "test.junit5.is_dynamic"; + public static final String TEST_IS_MODIFIED = "test.is_modified"; + public static final String TEST_HAS_FAILED_ALL_RETRIES = "test.has_failed_all_retries"; + public static final String TEST_FAILURE_SUPPRESSED = "test.failure_suppressed"; + public static final String TEST_TEST_MANAGEMENT_ENABLED = "test.test_management.enabled"; + public static final String TEST_TEST_MANAGEMENT_IS_QUARANTINED = "test.test_management.is_quarantined"; + public static final String TEST_TEST_MANAGEMENT_IS_TEST_DISABLED = "test.test_management.is_test_disabled"; + public static final String TEST_TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX = "test.test_management.is_attempt_to_fix"; + public static final String TEST_TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED = + "test.test_management.attempt_to_fix_passed"; + + public static final String ERROR_DEBUG_INFO_CAPTURED = "error.debug_info_captured"; + + public static final String CI_PROVIDER_NAME = "ci.provider.name"; + public static final String CI_PIPELINE_ID = "ci.pipeline.id"; + public static final String CI_PIPELINE_NAME = "ci.pipeline.name"; + public static final String CI_PIPELINE_DISPLAY_NAME = "ci.pipeline.display_name"; + public static final String CI_PIPELINE_NUMBER = "ci.pipeline.number"; + public static final String CI_PIPELINE_URL = "ci.pipeline.url"; + public static final String CI_STAGE_NAME = "ci.stage.name"; + public static final String CI_JOB_ID = "ci.job.id"; + public static final String CI_JOB_NAME = "ci.job.name"; + public static final String CI_JOB_URL = "ci.job.url"; + public static final String CI_WORKSPACE_PATH = "ci.workspace_path"; + public static final String CI_NODE_NAME = "ci.node.name"; + public static final String CI_NODE_LABELS = "ci.node.labels"; + + public static final String GIT_REPOSITORY_URL = "git.repository_url"; + public static final String GIT_COMMIT_SHA = "git.commit.sha"; + public static final String GIT_COMMIT_AUTHOR_NAME = "git.commit.author.name"; + public static final String GIT_COMMIT_AUTHOR_EMAIL = "git.commit.author.email"; + public static final String GIT_COMMIT_AUTHOR_DATE = "git.commit.author.date"; + public static final String GIT_COMMIT_COMMITTER_NAME = "git.commit.committer.name"; + public static final String GIT_COMMIT_COMMITTER_EMAIL = "git.commit.committer.email"; + public static final String GIT_COMMIT_COMMITTER_DATE = "git.commit.committer.date"; + public static final String GIT_COMMIT_MESSAGE = "git.commit.message"; + public static final String GIT_BRANCH = "git.branch"; + public static final String GIT_TAG = "git.tag"; + public static final String GIT_PULL_REQUEST_BASE_BRANCH = "git.pull_request.base_branch"; + public static final String GIT_PULL_REQUEST_BASE_BRANCH_SHA = "git.pull_request.base_branch_sha"; + public static final String GIT_PULL_REQUEST_BASE_BRANCH_HEAD_SHA = "git.pull_request.base_branch_head_sha"; + public static final String GIT_COMMIT_HEAD_SHA = "git.commit.head.sha"; + public static final String GIT_COMMIT_HEAD_AUTHOR_NAME = "git.commit.head.author.name"; + public static final String GIT_COMMIT_HEAD_AUTHOR_EMAIL = "git.commit.head.author.email"; + public static final String GIT_COMMIT_HEAD_AUTHOR_DATE = "git.commit.head.author.date"; + public static final String GIT_COMMIT_HEAD_COMMITTER_NAME = "git.commit.head.committer.name"; + public static final String GIT_COMMIT_HEAD_COMMITTER_EMAIL = "git.commit.head.committer.email"; + public static final String GIT_COMMIT_HEAD_COMMITTER_DATE = "git.commit.head.committer.date"; + public static final String GIT_COMMIT_HEAD_MESSAGE = "git.commit.head.message"; + public static final String PULL_REQUEST_NUMBER = "pr.number"; + + public static final String RUNTIME_NAME = "runtime.name"; + public static final String RUNTIME_VENDOR = "runtime.vendor"; + public static final String RUNTIME_VERSION = "runtime.version"; + public static final String OS_ARCHITECTURE = "os.architecture"; + public static final String OS_PLATFORM = "os.platform"; + public static final String OS_VERSION = "os.version"; + + public static final String DD_SERVICE = "dd.service"; + public static final String DD_VERSION = "dd.version"; + public static final String DD_ENV = "dd.env"; + + public static final String ENV = "env"; + public static final String VERSION = "version"; + + /** ASM force tracer to keep the trace */ + public static final String ASM_KEEP = "asm.keep"; + + /** AI Guard force tracer to keep the trace */ + public static final String AI_GUARD_KEEP = "ai_guard.keep"; + + /** Marks a span as originating from an AI Guard evaluation */ + public static final String AI_GUARD_EVENT = "ai_guard.event"; + + public static final String PROPAGATED_TRACE_SOURCE = "_dd.p.ts"; + public static final String PROPAGATED_DEBUG = "_dd.p.debug"; + + public static final String LLMOBS_LLM_SPAN_KIND = "llm"; + public static final String LLMOBS_WORKFLOW_SPAN_KIND = "workflow"; + public static final String LLMOBS_TASK_SPAN_KIND = "task"; + public static final String LLMOBS_AGENT_SPAN_KIND = "agent"; + public static final String LLMOBS_TOOL_SPAN_KIND = "tool"; + public static final String LLMOBS_EMBEDDING_SPAN_KIND = "embedding"; + public static final String LLMOBS_RETRIEVAL_SPAN_KIND = "retrieval"; + public static final String DSM_TRANSACTION_ID = "dsm.transaction.id"; + public static final String DSM_TRANSACTION_CHECKPOINT = "dsm.transaction.checkpoint"; } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TaskWrapper.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TaskWrapper.java index 3315ab3e8b2..6d66b06553f 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TaskWrapper.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/TaskWrapper.java @@ -1,15 +1,15 @@ package datadog.trace.bootstrap.instrumentation.api; public interface TaskWrapper { - static Class getUnwrappedType(Object task) { - int depth = 0; - Object inspected = task; - while (depth < 5 && inspected instanceof TaskWrapper) { - inspected = ((TaskWrapper) inspected).$$DD$$__unwrap(); - depth++; + static Class getUnwrappedType(Object task) { + int depth = 0; + Object inspected = task; + while (depth < 5 && inspected instanceof TaskWrapper) { + inspected = ((TaskWrapper) inspected).$$DD$$__unwrap(); + depth++; + } + return inspected == null ? null : inspected.getClass(); } - return inspected == null ? null : inspected.getClass(); - } - Object $$DD$$__unwrap(); + Object $$DD$$__unwrap(); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapter.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapter.java index e2511b6e4be..48510e45d87 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapter.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapter.java @@ -1,43 +1,43 @@ package datadog.trace.bootstrap.instrumentation.api; public interface URIDataAdapter { - /** The scheme of this URI. Can never be encoded. */ - String scheme(); + /** The scheme of this URI. Can never be encoded. */ + String scheme(); - /** The host of this URI. Can never be encoded. */ - String host(); + /** The host of this URI. Can never be encoded. */ + String host(); - /** The port number of this URI. */ - int port(); + /** The port number of this URI. */ + int port(); - /** The decoded path of this URI. */ - String path(); + /** The decoded path of this URI. */ + String path(); - /** The decoded fragment of this URI. */ - String fragment(); + /** The decoded fragment of this URI. */ + String fragment(); - /** The decoded query string of this URI. */ - String query(); + /** The decoded query string of this URI. */ + String query(); - /** Does this adapter support raw encoded access? */ - boolean supportsRaw(); + /** Does this adapter support raw encoded access? */ + boolean supportsRaw(); - /** The raw path of this URI. */ - String rawPath(); + /** The raw path of this URI. */ + String rawPath(); - /** Does the raw query string have '+' encoded spaces? */ - boolean hasPlusEncodedSpaces(); + /** Does the raw query string have '+' encoded spaces? */ + boolean hasPlusEncodedSpaces(); - /** The raw query string of this URI. */ - String rawQuery(); + /** The raw query string of this URI. */ + String rawQuery(); - /** The raw path(?query) of this URI. */ - String raw(); + /** The raw path(?query) of this URI. */ + String raw(); - /** - * The URI is a valid one or not (looks malformed) - * - * @return {@code true} if the URI is valid, {@code false} if it looks malformed - */ - boolean isValid(); + /** + * The URI is a valid one or not (looks malformed) + * + * @return {@code true} if the URI is valid, {@code false} if it looks malformed + */ + boolean isValid(); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapterBase.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapterBase.java index 0587e0c7644..c30dee1638d 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapterBase.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDataAdapterBase.java @@ -6,45 +6,45 @@ @SuppressFBWarnings(value = "DM_STRING_CTOR", justification = "Unique instance needs constructor") public abstract class URIDataAdapterBase implements URIDataAdapter { - /** Unique instance that signifies an uninitialized field, to allow for == comparison */ - protected static final String UNINITIALIZED = new String("uninitialized"); + /** Unique instance that signifies an uninitialized field, to allow for == comparison */ + protected static final String UNINITIALIZED = new String("uninitialized"); - private String raw = supportsRaw() ? UNINITIALIZED : null; + private String raw = supportsRaw() ? UNINITIALIZED : null; - @Override - public boolean hasPlusEncodedSpaces() { - return false; - } + @Override + public boolean hasPlusEncodedSpaces() { + return false; + } - @Override - public String raw() { - String raw = this.raw; - if (raw == UNINITIALIZED) { - String p = rawPath(); - String q = rawQuery(); - StringBuilder builder = new StringBuilder(); - if (null != p && !p.isEmpty()) { - builder.append(p); - } - if (null != q && !q.isEmpty()) { - builder.append('?'); - builder.append(q); - } - this.raw = raw = builder.toString(); + @Override + public String raw() { + String raw = this.raw; + if (raw == UNINITIALIZED) { + String p = rawPath(); + String q = rawQuery(); + StringBuilder builder = new StringBuilder(); + if (null != p && !p.isEmpty()) { + builder.append(p); + } + if (null != q && !q.isEmpty()) { + builder.append('?'); + builder.append(q); + } + this.raw = raw = builder.toString(); + } + return raw; } - return raw; - } - @Override - public boolean isValid() { - return true; - } + @Override + public boolean isValid() { + return true; + } - public static URIDataAdapter fromURI(String uri, Function mapper) { - final URI parsed = URIUtils.safeParse(uri); - if (parsed != null) { - return mapper.apply(parsed); + public static URIDataAdapter fromURI(String uri, Function mapper) { + final URI parsed = URIUtils.safeParse(uri); + if (parsed != null) { + return mapper.apply(parsed); + } + return new UnparseableURIDataAdapter(uri); } - return new UnparseableURIDataAdapter(uri); - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDefaultDataAdapter.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDefaultDataAdapter.java index 6392aad2d4e..84cdc9ef313 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDefaultDataAdapter.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIDefaultDataAdapter.java @@ -4,54 +4,54 @@ public class URIDefaultDataAdapter extends URIDataAdapterBase { - private final URI uri; - - public URIDefaultDataAdapter(URI uri) { - this.uri = uri; - } - - @Override - public String scheme() { - return uri.getScheme(); - } - - @Override - public String host() { - return uri.getHost(); - } - - @Override - public int port() { - return uri.getPort(); - } - - @Override - public String path() { - return uri.getPath(); - } - - @Override - public String fragment() { - return uri.getFragment(); - } - - @Override - public String query() { - return uri.getQuery(); - } - - @Override - public boolean supportsRaw() { - return true; - } - - @Override - public String rawPath() { - return uri.getRawPath(); - } - - @Override - public String rawQuery() { - return uri.getRawQuery(); - } + private final URI uri; + + public URIDefaultDataAdapter(URI uri) { + this.uri = uri; + } + + @Override + public String scheme() { + return uri.getScheme(); + } + + @Override + public String host() { + return uri.getHost(); + } + + @Override + public int port() { + return uri.getPort(); + } + + @Override + public String path() { + return uri.getPath(); + } + + @Override + public String fragment() { + return uri.getFragment(); + } + + @Override + public String query() { + return uri.getQuery(); + } + + @Override + public boolean supportsRaw() { + return true; + } + + @Override + public String rawPath() { + return uri.getRawPath(); + } + + @Override + public String rawQuery() { + return uri.getRawQuery(); + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIRawDataAdapter.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIRawDataAdapter.java index d02af4ead12..b3bf10218bd 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIRawDataAdapter.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIRawDataAdapter.java @@ -1,55 +1,55 @@ package datadog.trace.bootstrap.instrumentation.api; public abstract class URIRawDataAdapter extends URIDataAdapterBase { - private String decodedPath = UNINITIALIZED; - private String decodedQuery = UNINITIALIZED; - private String rawPath = UNINITIALIZED; - private String rawQuery = UNINITIALIZED; - - protected abstract String innerRawPath(); - - protected abstract String innerRawQuery(); + private String decodedPath = UNINITIALIZED; + private String decodedQuery = UNINITIALIZED; + private String rawPath = UNINITIALIZED; + private String rawQuery = UNINITIALIZED; + + protected abstract String innerRawPath(); + + protected abstract String innerRawQuery(); + + @Override + public final String path() { + String path = decodedPath; + if (path != UNINITIALIZED) { + return path; + } + decodedPath = path = URIUtils.decode(rawPath()); + return path; + } - @Override - public final String path() { - String path = decodedPath; - if (path != UNINITIALIZED) { - return path; + @Override + public final String query() { + String query = decodedQuery; + if (query != UNINITIALIZED) { + return query; + } + decodedQuery = query = URIUtils.decode(rawQuery(), hasPlusEncodedSpaces()); + return query; } - decodedPath = path = URIUtils.decode(rawPath()); - return path; - } - - @Override - public final String query() { - String query = decodedQuery; - if (query != UNINITIALIZED) { - return query; + + @Override + public final boolean supportsRaw() { + return true; } - decodedQuery = query = URIUtils.decode(rawQuery(), hasPlusEncodedSpaces()); - return query; - } - - @Override - public final boolean supportsRaw() { - return true; - } - - @Override - public final String rawPath() { - String path = rawPath; - if (path == UNINITIALIZED) { - rawPath = path = innerRawPath(); + + @Override + public final String rawPath() { + String path = rawPath; + if (path == UNINITIALIZED) { + rawPath = path = innerRawPath(); + } + return path; } - return path; - } - - @Override - public final String rawQuery() { - String query = rawQuery; - if (query == UNINITIALIZED) { - rawQuery = query = innerRawQuery(); + + @Override + public final String rawQuery() { + String query = rawQuery; + if (query == UNINITIALIZED) { + rawQuery = query = innerRawQuery(); + } + return query; } - return query; - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIUtils.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIUtils.java index 5f1fd20504e..5a685fd902c 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIUtils.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/URIUtils.java @@ -13,292 +13,289 @@ import org.slf4j.LoggerFactory; public class URIUtils { - private URIUtils() {} + private URIUtils() {} - // This is the � character, which is also the default replacement for the UTF_8 charset - private static final byte[] REPLACEMENT = {(byte) 0xEF, (byte) 0xBF, (byte) 0xBD}; + // This is the � character, which is also the default replacement for the UTF_8 charset + private static final byte[] REPLACEMENT = {(byte) 0xEF, (byte) 0xBF, (byte) 0xBD}; - private static final Logger LOGGER = LoggerFactory.getLogger(URIUtils.class); + private static final Logger LOGGER = LoggerFactory.getLogger(URIUtils.class); - /** - * Decodes a %-encoded UTF-8 {@code String} into a regular {@code String}. - * - *

All illegal % sequences and illegal UTF-8 sequences are replaced with one or more � - * characters. - */ - public static String decode(String encoded) { - return decode(encoded, false); - } + /** + * Decodes a %-encoded UTF-8 {@code String} into a regular {@code String}. + * + *

All illegal % sequences and illegal UTF-8 sequences are replaced with one or more � + * characters. + */ + public static String decode(String encoded) { + return decode(encoded, false); + } - /** - * Decodes a %-encoded UTF-8 {@code String} into a regular {@code String}. Can also be made to - * decode '+' to ' ' to support old query strings. - * - *

All illegal % sequences and illegal UTF-8 sequences are replaced with one or more � - * characters. - */ - public static String decode(String encoded, boolean plusToSpace) { - if (encoded == null) return null; - int len = encoded.length(); - if (len == 0) return encoded; - if (encoded.indexOf('%') < 0 && (!plusToSpace || encoded.indexOf('+') < 0)) return encoded; + /** + * Decodes a %-encoded UTF-8 {@code String} into a regular {@code String}. Can also be made to + * decode '+' to ' ' to support old query strings. + * + *

All illegal % sequences and illegal UTF-8 sequences are replaced with one or more � + * characters. + */ + public static String decode(String encoded, boolean plusToSpace) { + if (encoded == null) return null; + int len = encoded.length(); + if (len == 0) return encoded; + if (encoded.indexOf('%') < 0 && (!plusToSpace || encoded.indexOf('+') < 0)) return encoded; - ByteBuffer bb = - ByteBuffer.allocate(len + 2); // The extra 2 is if we have a % last and need to replace it - for (int i = 0; i < len; i++) { - int c = encoded.charAt(i); - if (c == '%') { - if (i + 2 < len) { - int h = Character.digit(encoded.charAt(i + 1), 16); - int l = Character.digit(encoded.charAt(i + 2), 16); - if ((h | l) < 0) { - bb.put(REPLACEMENT[0]); - bb.put(REPLACEMENT[1]); - bb.put(REPLACEMENT[2]); - } else { - bb.put((byte) ((h << 4) + l)); - } - i += 2; + ByteBuffer bb = ByteBuffer.allocate(len + 2); // The extra 2 is if we have a % last and need to replace it + for (int i = 0; i < len; i++) { + int c = encoded.charAt(i); + if (c == '%') { + if (i + 2 < len) { + int h = Character.digit(encoded.charAt(i + 1), 16); + int l = Character.digit(encoded.charAt(i + 2), 16); + if ((h | l) < 0) { + bb.put(REPLACEMENT[0]); + bb.put(REPLACEMENT[1]); + bb.put(REPLACEMENT[2]); + } else { + bb.put((byte) ((h << 4) + l)); + } + i += 2; + } else { + bb.put(REPLACEMENT[0]); + bb.put(REPLACEMENT[1]); + bb.put(REPLACEMENT[2]); + i = len; + } + } else { + if (plusToSpace && c == '+') { + c = ' '; + } + bb.put((byte) c); + } + } + bb.flip(); + return new String(bb.array(), 0, bb.limit(), StandardCharsets.UTF_8); + } + + /** + * Build a URL based on the scheme, host, port and path. + * + *

Will remove the port if it is <= 0 or if its the default http/https port. + */ + public static String buildURL(String scheme, String host, int port, String path) { + int length = 0; + length += null == scheme ? 0 : scheme.length() + 3; + if (null != host) { + length += host.length(); + if (port > 0 && port != 80 && port != 443) { + length += 6; + } + } + if (null == path || path.isEmpty()) { + ++length; } else { - bb.put(REPLACEMENT[0]); - bb.put(REPLACEMENT[1]); - bb.put(REPLACEMENT[2]); - i = len; + if (path.charAt(0) != '/') { + ++length; + } + length += path.length(); } - } else { - if (plusToSpace && c == '+') { - c = ' '; + final StringBuilder urlNoParams = new StringBuilder(length); + if (scheme != null) { + urlNoParams.append(scheme); + urlNoParams.append("://"); } - bb.put((byte) c); - } - } - bb.flip(); - return new String(bb.array(), 0, bb.limit(), StandardCharsets.UTF_8); - } - /** - * Build a URL based on the scheme, host, port and path. - * - *

Will remove the port if it is <= 0 or if its the default http/https port. - */ - public static String buildURL(String scheme, String host, int port, String path) { - int length = 0; - length += null == scheme ? 0 : scheme.length() + 3; - if (null != host) { - length += host.length(); - if (port > 0 && port != 80 && port != 443) { - length += 6; - } - } - if (null == path || path.isEmpty()) { - ++length; - } else { - if (path.charAt(0) != '/') { - ++length; - } - length += path.length(); - } - final StringBuilder urlNoParams = new StringBuilder(length); - if (scheme != null) { - urlNoParams.append(scheme); - urlNoParams.append("://"); - } + if (host != null) { + urlNoParams.append(host); + if (port > 0 && !(port == 80 && "http".equals(scheme) || port == 443 && "https".equals(scheme))) { + urlNoParams.append(':'); + urlNoParams.append(port); + } + } - if (host != null) { - urlNoParams.append(host); - if (port > 0 - && !(port == 80 && "http".equals(scheme) || port == 443 && "https".equals(scheme))) { - urlNoParams.append(':'); - urlNoParams.append(port); - } + if (null == path || path.isEmpty()) { + urlNoParams.append('/'); + } else { + if (path.charAt(0) != '/' && urlNoParams.length() > 0) { + urlNoParams.append('/'); + } + urlNoParams.append(path); + } + return urlNoParams.toString(); } - if (null == path || path.isEmpty()) { - urlNoParams.append('/'); - } else { - if (path.charAt(0) != '/' && urlNoParams.length() > 0) { - urlNoParams.append('/'); - } - urlNoParams.append(path); + public static URI safeParse(final String unparsed) { + if (unparsed == null) { + return null; + } + try { + return PropagationUtils.onUriCreate(unparsed, URI.create(unparsed)); + } catch (final IllegalArgumentException exception) { + LOGGER.debug(EXCLUDE_TELEMETRY, "Unable to parse request uri {}", unparsed, exception); + return null; + } } - return urlNoParams.toString(); - } - public static URI safeParse(final String unparsed) { - if (unparsed == null) { - return null; - } - try { - return PropagationUtils.onUriCreate(unparsed, URI.create(unparsed)); - } catch (final IllegalArgumentException exception) { - LOGGER.debug(EXCLUDE_TELEMETRY, "Unable to parse request uri {}", unparsed, exception); - return null; + /** + * Builds a lazily evaluated valid URL based on the scheme, host, port and path. + * + *

Will remove the port if it is <= 0 or if its the default http/https port. + * + * @param scheme The scheme + * @param host The host + * @param port The port + * @param path The path + * @return The {@code LazyUrl} + */ + public static LazyUrl lazyValidURL(String scheme, String host, int port, String path) { + return new ValidUrl(scheme, host, port, path); } - } - - /** - * Builds a lazily evaluated valid URL based on the scheme, host, port and path. - * - *

Will remove the port if it is <= 0 or if its the default http/https port. - * - * @param scheme The scheme - * @param host The host - * @param port The port - * @param path The path - * @return The {@code LazyUrl} - */ - public static LazyUrl lazyValidURL(String scheme, String host, int port, String path) { - return new ValidUrl(scheme, host, port, path); - } - /** - * Builds an invalid URL from a raw string representation. - * - * @param raw The raw {@code String} representation of the invalid URL - * @return The {@code LazyUrl} - */ - public static LazyUrl lazyInvalidUrl(String raw) { - return new InvalidUrl(raw); - } - - public static String urlFileName(String raw) { - try { - URL url = new URL(raw); - String path = url.getPath(); - int nameEnd = path.length() - 1; - while (nameEnd >= 0 && path.charAt(nameEnd) == '/') { - nameEnd--; - } - if (nameEnd < 0) { - return ""; - } - String name = path.substring(path.lastIndexOf('/', nameEnd) + 1, nameEnd + 1); - return name; - } catch (MalformedURLException e) { - return ""; + /** + * Builds an invalid URL from a raw string representation. + * + * @param raw The raw {@code String} representation of the invalid URL + * @return The {@code LazyUrl} + */ + public static LazyUrl lazyInvalidUrl(String raw) { + return new InvalidUrl(raw); } - } - /** - * A lazily evaluated URL that can also return its path. If the URL is invalid the path will be - * {@code null}. - */ - public abstract static class LazyUrl implements CharSequence, Supplier { - protected String lazy; - - protected LazyUrl(String lazy) { - this.lazy = lazy; + public static String urlFileName(String raw) { + try { + URL url = new URL(raw); + String path = url.getPath(); + int nameEnd = path.length() - 1; + while (nameEnd >= 0 && path.charAt(nameEnd) == '/') { + nameEnd--; + } + if (nameEnd < 0) { + return ""; + } + String name = path.substring(path.lastIndexOf('/', nameEnd) + 1, nameEnd + 1); + return name; + } catch (MalformedURLException e) { + return ""; + } } /** - * The path component of this URL. - * - * @return The path if valid or {@code null} if invalid + * A lazily evaluated URL that can also return its path. If the URL is invalid the path will be + * {@code null}. */ - public abstract String path(); + public abstract static class LazyUrl implements CharSequence, Supplier { + protected String lazy; - @Override - public String toString() { - String str = lazy; - if (str == null) { - str = lazy = get(); - } - return str; - } + protected LazyUrl(String lazy) { + this.lazy = lazy; + } - @Override - public int length() { - return toString().length(); - } + /** + * The path component of this URL. + * + * @return The path if valid or {@code null} if invalid + */ + public abstract String path(); - @Override - public char charAt(int index) { - return toString().charAt(index); - } + @Override + public String toString() { + String str = lazy; + if (str == null) { + str = lazy = get(); + } + return str; + } - @Override - public CharSequence subSequence(int start, int end) { - return toString().subSequence(start, end); - } + @Override + public int length() { + return toString().length(); + } - @Override - public int hashCode() { - return toString().hashCode(); - } - } + @Override + public char charAt(int index) { + return toString().charAt(index); + } - private static class ValidUrl extends LazyUrl { - private final String scheme; - private final String host; - private final int port; - private final String path; + @Override + public CharSequence subSequence(int start, int end) { + return toString().subSequence(start, end); + } - private ValidUrl(String scheme, String host, int port, String path) { - super(null); - this.scheme = scheme; - this.host = host; - this.port = port; - if (null == path || path.isEmpty()) { - this.path = ""; - } else { - this.path = path; - } + @Override + public int hashCode() { + return toString().hashCode(); + } } - @Override - public String path() { - return path; - } + private static class ValidUrl extends LazyUrl { + private final String scheme; + private final String host; + private final int port; + private final String path; - @Override - public String get() { - String res = lazy; - return res != null ? res : buildURL(scheme, host, port, path); - } - } + private ValidUrl(String scheme, String host, int port, String path) { + super(null); + this.scheme = scheme; + this.host = host; + this.port = port; + if (null == path || path.isEmpty()) { + this.path = ""; + } else { + this.path = path; + } + } - private static class InvalidUrl extends LazyUrl { - public InvalidUrl(String raw) { - super(String.valueOf(raw)); - } + @Override + public String path() { + return path; + } - @Override - public String path() { - return null; + @Override + public String get() { + String res = lazy; + return res != null ? res : buildURL(scheme, host, port, path); + } } - @Override - public String get() { - return lazy; - } - } + private static class InvalidUrl extends LazyUrl { + public InvalidUrl(String raw) { + super(String.valueOf(raw)); + } - /** - * Concatenate two URI parts to form the complete one. Mostly used for apache http client - * instrumentations - * - * @param schemeHostPort the first part (usually http://host:port) - * @param theRest the rest of the uri (e.g. /path?query#fragment - * @return the full URI or null if fails to parse - */ - public static URI safeConcat(final String schemeHostPort, final String theRest) { - if (schemeHostPort == null && theRest == null) { - return null; - } - final String part1 = schemeHostPort != null ? schemeHostPort : ""; - final String part2 = theRest != null ? theRest : ""; - if (part2.startsWith(part1)) { - return safeParse(part2); + @Override + public String path() { + return null; + } + + @Override + public String get() { + return lazy; + } } - final boolean addSlash = !(part2.startsWith("/") || part1.endsWith("/")); - final StringBuilder sb = - new StringBuilder(part1.length() + part2.length() + (addSlash ? 1 : 0)); - PropagationUtils.onStringBuilderAppend(part1, sb.append(part1)); - if (addSlash) { - // it happens for http async client 4 with relative URI - sb.append('/'); + + /** + * Concatenate two URI parts to form the complete one. Mostly used for apache http client + * instrumentations + * + * @param schemeHostPort the first part (usually http://host:port) + * @param theRest the rest of the uri (e.g. /path?query#fragment + * @return the full URI or null if fails to parse + */ + public static URI safeConcat(final String schemeHostPort, final String theRest) { + if (schemeHostPort == null && theRest == null) { + return null; + } + final String part1 = schemeHostPort != null ? schemeHostPort : ""; + final String part2 = theRest != null ? theRest : ""; + if (part2.startsWith(part1)) { + return safeParse(part2); + } + final boolean addSlash = !(part2.startsWith("/") || part1.endsWith("/")); + final StringBuilder sb = new StringBuilder(part1.length() + part2.length() + (addSlash ? 1 : 0)); + PropagationUtils.onStringBuilderAppend(part1, sb.append(part1)); + if (addSlash) { + // it happens for http async client 4 with relative URI + sb.append('/'); + } + PropagationUtils.onStringBuilderAppend(part2, sb.append(part2)); + return safeParse(PropagationUtils.onStringBuilderToString(sb, sb.toString())); } - PropagationUtils.onStringBuilderAppend(part2, sb.append(part2)); - return safeParse(PropagationUtils.onStringBuilderToString(sb, sb.toString())); - } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UTF8BytesString.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UTF8BytesString.java index 9ea332a9a82..373e667af58 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UTF8BytesString.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UTF8BytesString.java @@ -10,137 +10,137 @@ */ public final class UTF8BytesString implements CharSequence { - public static final UTF8BytesString EMPTY = UTF8BytesString.create(""); - - public static UTF8BytesString create(CharSequence sequence) { - if (null == sequence) { - return null; - } else if (sequence instanceof UTF8BytesString) { - return (UTF8BytesString) sequence; - } else { - return new UTF8BytesString(String.valueOf(sequence)); - } - } - - public static UTF8BytesString create(byte[] utf8Bytes) { - if (null == utf8Bytes) { - return null; - } else { - return new UTF8BytesString(utf8Bytes); - } - } - - public static UTF8BytesString create(String string, byte[] utf8Bytes) { - if (null == utf8Bytes) { - return null; - } else { - return new UTF8BytesString(string, utf8Bytes); - } - } - - /** - * Creates a new {@linkplain UTF8BytesString} instance using the provided {@linkplain ByteBuffer} - *
- * All available data from the current position are read and used as the backing array. - * - * @param utf8BytesBuffer the byte buffer containing UTF8 data - * @return a new {@linkplain UTF8BytesString} instance or {@literal null} - */ - public static UTF8BytesString create(ByteBuffer utf8BytesBuffer) { - if (null == utf8BytesBuffer) { - return null; - } else { - byte[] utf8Bytes = new byte[utf8BytesBuffer.remaining()]; - utf8BytesBuffer.get(utf8Bytes); - return new UTF8BytesString(utf8Bytes); - } - } - - private final String string; - private byte[] utf8Bytes; - // Lazy hashCode cache. {@link String} already caches its own {@code hashCode} internally, but - // the inter-class call through {@code string.hashCode()} still costs a virtual dispatch + the - // String's own cached-hash field read + branch -- caching here saves all of that on subsequent - // calls. Benign race in the same shape as {@link #utf8Bytes}: two threads computing the same - // hash in parallel both produce the same value, and {@code int} writes are atomic per JLS so a - // reader cannot observe a partial value. - private int cachedHashCode; - - private UTF8BytesString(String string) { - this.string = string; - } - - private UTF8BytesString(byte[] utf8Bytes) { - this(new String(utf8Bytes, UTF_8), utf8Bytes); - } - - private UTF8BytesString(String string, byte[] utf8Bytes) { - this.string = string; - this.utf8Bytes = utf8Bytes; - } - - /** Writes the UTF8 encoding of the wrapped {@code String}. */ - public void transferTo(ByteBuffer buffer) { - encodeIfNecessary(); - buffer.put(utf8Bytes); - } - - /** Writes the UTF8 encoding of the wrapped {@code String}. */ - public byte[] getUtf8Bytes() { - encodeIfNecessary(); - return utf8Bytes; - } - - public int encodedLength() { - encodeIfNecessary(); - return utf8Bytes.length; - } - - @Override - public String toString() { - return string; - } - - private void encodeIfNecessary() { - // benign and intentional race condition - if (null == utf8Bytes) { - utf8Bytes = string.getBytes(UTF_8); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null) return false; - String that = null; - if (o instanceof UTF8BytesString) { - that = ((UTF8BytesString) o).string; - } - return this.string.equals(that); - } - - @Override - public int hashCode() { - int h = this.cachedHashCode; - if (h == 0) { - h = this.string.hashCode(); - this.cachedHashCode = h; - } - return h; - } - - @Override - public int length() { - return this.string.length(); - } - - @Override - public char charAt(int index) { - return this.string.charAt(index); - } - - @Override - public CharSequence subSequence(int start, int end) { - return this.string.subSequence(start, end); - } + public static final UTF8BytesString EMPTY = UTF8BytesString.create(""); + + public static UTF8BytesString create(CharSequence sequence) { + if (null == sequence) { + return null; + } else if (sequence instanceof UTF8BytesString) { + return (UTF8BytesString) sequence; + } else { + return new UTF8BytesString(String.valueOf(sequence)); + } + } + + public static UTF8BytesString create(byte[] utf8Bytes) { + if (null == utf8Bytes) { + return null; + } else { + return new UTF8BytesString(utf8Bytes); + } + } + + public static UTF8BytesString create(String string, byte[] utf8Bytes) { + if (null == utf8Bytes) { + return null; + } else { + return new UTF8BytesString(string, utf8Bytes); + } + } + + /** + * Creates a new {@linkplain UTF8BytesString} instance using the provided {@linkplain ByteBuffer} + *
+ * All available data from the current position are read and used as the backing array. + * + * @param utf8BytesBuffer the byte buffer containing UTF8 data + * @return a new {@linkplain UTF8BytesString} instance or {@literal null} + */ + public static UTF8BytesString create(ByteBuffer utf8BytesBuffer) { + if (null == utf8BytesBuffer) { + return null; + } else { + byte[] utf8Bytes = new byte[utf8BytesBuffer.remaining()]; + utf8BytesBuffer.get(utf8Bytes); + return new UTF8BytesString(utf8Bytes); + } + } + + private final String string; + private byte[] utf8Bytes; + // Lazy hashCode cache. {@link String} already caches its own {@code hashCode} internally, but + // the inter-class call through {@code string.hashCode()} still costs a virtual dispatch + the + // String's own cached-hash field read + branch -- caching here saves all of that on subsequent + // calls. Benign race in the same shape as {@link #utf8Bytes}: two threads computing the same + // hash in parallel both produce the same value, and {@code int} writes are atomic per JLS so a + // reader cannot observe a partial value. + private int cachedHashCode; + + private UTF8BytesString(String string) { + this.string = string; + } + + private UTF8BytesString(byte[] utf8Bytes) { + this(new String(utf8Bytes, UTF_8), utf8Bytes); + } + + private UTF8BytesString(String string, byte[] utf8Bytes) { + this.string = string; + this.utf8Bytes = utf8Bytes; + } + + /** Writes the UTF8 encoding of the wrapped {@code String}. */ + public void transferTo(ByteBuffer buffer) { + encodeIfNecessary(); + buffer.put(utf8Bytes); + } + + /** Writes the UTF8 encoding of the wrapped {@code String}. */ + public byte[] getUtf8Bytes() { + encodeIfNecessary(); + return utf8Bytes; + } + + public int encodedLength() { + encodeIfNecessary(); + return utf8Bytes.length; + } + + @Override + public String toString() { + return string; + } + + private void encodeIfNecessary() { + // benign and intentional race condition + if (null == utf8Bytes) { + utf8Bytes = string.getBytes(UTF_8); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + String that = null; + if (o instanceof UTF8BytesString) { + that = ((UTF8BytesString) o).string; + } + return this.string.equals(that); + } + + @Override + public int hashCode() { + int h = this.cachedHashCode; + if (h == 0) { + h = this.string.hashCode(); + this.cachedHashCode = h; + } + return h; + } + + @Override + public int length() { + return this.string.length(); + } + + @Override + public char charAt(int index) { + return this.string.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return this.string.subSequence(start, end); + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UnparseableURIDataAdapter.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UnparseableURIDataAdapter.java index b85d24e8045..d588c3e733d 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UnparseableURIDataAdapter.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/UnparseableURIDataAdapter.java @@ -3,64 +3,64 @@ import javax.annotation.Nonnull; public class UnparseableURIDataAdapter extends URIDataAdapterBase { - private final String rawUri; + private final String rawUri; - public UnparseableURIDataAdapter(@Nonnull String rawUri) { - this.rawUri = rawUri; - } + public UnparseableURIDataAdapter(@Nonnull String rawUri) { + this.rawUri = rawUri; + } - @Override - public boolean isValid() { - return false; - } + @Override + public boolean isValid() { + return false; + } - @Override - public String scheme() { - return null; - } + @Override + public String scheme() { + return null; + } - @Override - public String host() { - return null; - } + @Override + public String host() { + return null; + } - @Override - public int port() { - return 0; - } + @Override + public int port() { + return 0; + } - @Override - public String path() { - return null; - } + @Override + public String path() { + return null; + } - @Override - public String fragment() { - return null; - } + @Override + public String fragment() { + return null; + } - @Override - public String query() { - return null; - } + @Override + public String query() { + return null; + } - @Override - public boolean supportsRaw() { - return true; - } + @Override + public boolean supportsRaw() { + return true; + } - @Override - public String rawPath() { - return null; - } + @Override + public String rawPath() { + return null; + } - @Override - public String rawQuery() { - return null; - } + @Override + public String rawQuery() { + return null; + } - @Override - public String raw() { - return rawUri; - } + @Override + public String raw() { + return rawUri; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WithAgentSpan.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WithAgentSpan.java index 13bd1d7d5d7..593998e1e8d 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WithAgentSpan.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WithAgentSpan.java @@ -1,5 +1,5 @@ package datadog.trace.bootstrap.instrumentation.api; public interface WithAgentSpan { - AgentSpan asAgentSpan(); + AgentSpan asAgentSpan(); } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WriterConstants.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WriterConstants.java index 724e9296455..9de94769bef 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WriterConstants.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/WriterConstants.java @@ -1,13 +1,13 @@ package datadog.trace.bootstrap.instrumentation.api; public final class WriterConstants { - public static final String DD_AGENT_WRITER_TYPE = "DDAgentWriter"; - public static final String DD_INTAKE_WRITER_TYPE = "DDIntakeWriter"; - public static final String LOGGING_WRITER_TYPE = "LoggingWriter"; - public static final String PRINTING_WRITER_TYPE = "PrintingWriter"; - public static final String TRACE_STRUCTURE_WRITER_TYPE = "TraceStructureWriter"; - public static final String MULTI_WRITER_TYPE = "MultiWriter"; - public static final String OTLP_WRITER_TYPE = "OtlpWriter"; + public static final String DD_AGENT_WRITER_TYPE = "DDAgentWriter"; + public static final String DD_INTAKE_WRITER_TYPE = "DDIntakeWriter"; + public static final String LOGGING_WRITER_TYPE = "LoggingWriter"; + public static final String PRINTING_WRITER_TYPE = "PrintingWriter"; + public static final String TRACE_STRUCTURE_WRITER_TYPE = "TraceStructureWriter"; + public static final String MULTI_WRITER_TYPE = "MultiWriter"; + public static final String OTLP_WRITER_TYPE = "OtlpWriter"; - private WriterConstants() {} + private WriterConstants() {} } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/java/lang/ProcessImplInstrumentationHelpers.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/java/lang/ProcessImplInstrumentationHelpers.java index 97b8eb15c29..c898ce5f723 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/java/lang/ProcessImplInstrumentationHelpers.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/java/lang/ProcessImplInstrumentationHelpers.java @@ -32,289 +32,280 @@ /** This class is included here because it needs to injected into the bootstrap clasloader. */ public class ProcessImplInstrumentationHelpers { - private static final Logger LOGGER = - LoggerFactory.getLogger(ProcessImplInstrumentationHelpers.class); + private static final Logger LOGGER = LoggerFactory.getLogger(ProcessImplInstrumentationHelpers.class); - private static final int LIMIT = 4096; - public static final boolean ONLINE; - private static final MethodHandle PROCESS_ON_EXIT; - private static final Executor EXECUTOR; + private static final int LIMIT = 4096; + public static final boolean ONLINE; + private static final MethodHandle PROCESS_ON_EXIT; + private static final Executor EXECUTOR; - private static final Pattern REDACTED_PARAM_PAT = - Pattern.compile( - "^(?i)-{0,2}(?:p(?:ass(?:w(?:or)?d)?)?|api_?key|secret|" - + "a(?:ccess|uth)_token|mysql_pwd|credentials|(?:stripe)?token)$"); - private static final Set REDACTED_BINARIES = Collections.singleton("md5"); + private static final Pattern REDACTED_PARAM_PAT = + Pattern.compile("^(?i)-{0,2}(?:p(?:ass(?:w(?:or)?d)?)?|api_?key|secret|" + + "a(?:ccess|uth)_token|mysql_pwd|credentials|(?:stripe)?token)$"); + private static final Set REDACTED_BINARIES = Collections.singleton("md5"); - // This check is used to avoid command injection exploit prevention if shell injection exploit - // prevention checked - private static final ThreadLocal checkShi = ThreadLocal.withInitial(() -> false); + // This check is used to avoid command injection exploit prevention if shell injection exploit + // prevention checked + private static final ThreadLocal checkShi = ThreadLocal.withInitial(() -> false); - static { - MethodHandle processOnExit = null; - Executor executor = null; - try { - // java 9 - processOnExit = - MethodHandles.publicLookup() - .findVirtual(Process.class, "onExit", methodType(CompletableFuture.class)); - } catch (Throwable e) { - try { - // java 8 - Class unixProcessCls = - ClassLoader.getSystemClassLoader().loadClass("java.lang.UNIXProcess"); - Field f = unixProcessCls.getDeclaredField("processReaperExecutor"); - f.setAccessible(true); - executor = (Executor) f.get(null); - } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException ex) { - } - } - PROCESS_ON_EXIT = processOnExit; - EXECUTOR = executor; - ONLINE = PROCESS_ON_EXIT != null || EXECUTOR != null; - } - - private ProcessImplInstrumentationHelpers() {} - - public static void setTags(final AgentSpan span, final String[] origCommand) { - String[] command; - if (!ActiveSubsystems.APPSEC_ACTIVE) { - command = new String[] {origCommand[0]}; - } else { - command = origCommand; - } - command = redact(command); - StringBuilder sb = new StringBuilder("["); - long remaining = LIMIT; - for (int i = 0; i < command.length; i++) { - String cur = command[i]; - remaining -= cur.length(); - if (remaining < 0) { - span.setTag("cmd.truncated", "true"); - break; - } - if (i != 0) { - sb.append(','); - } - sb.append('"'); - sb.append(cur.replace("\\", "\\\\").replace("\"", "\\\"")); - sb.append('"'); + static { + MethodHandle processOnExit = null; + Executor executor = null; + try { + // java 9 + processOnExit = MethodHandles.publicLookup() + .findVirtual(Process.class, "onExit", methodType(CompletableFuture.class)); + } catch (Throwable e) { + try { + // java 8 + Class unixProcessCls = ClassLoader.getSystemClassLoader().loadClass("java.lang.UNIXProcess"); + Field f = unixProcessCls.getDeclaredField("processReaperExecutor"); + f.setAccessible(true); + executor = (Executor) f.get(null); + } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException ex) { + } + } + PROCESS_ON_EXIT = processOnExit; + EXECUTOR = executor; + ONLINE = PROCESS_ON_EXIT != null || EXECUTOR != null; } - sb.append(']'); - span.setTag("cmd.exec", sb.toString()); - } - private static String[] redact(String[] command) { - if (command.length == 0) { - return command; - } + private ProcessImplInstrumentationHelpers() {} - String[] newCommand = null; - if (REDACTED_BINARIES.contains(determineResource(command))) { - newCommand = new String[command.length]; - newCommand[0] = command[0]; - for (int i = 1; i < command.length; i++) { - newCommand[i] = "?"; - } - return newCommand; + public static void setTags(final AgentSpan span, final String[] origCommand) { + String[] command; + if (!ActiveSubsystems.APPSEC_ACTIVE) { + command = new String[] {origCommand[0]}; + } else { + command = origCommand; + } + command = redact(command); + StringBuilder sb = new StringBuilder("["); + long remaining = LIMIT; + for (int i = 0; i < command.length; i++) { + String cur = command[i]; + remaining -= cur.length(); + if (remaining < 0) { + span.setTag("cmd.truncated", "true"); + break; + } + if (i != 0) { + sb.append(','); + } + sb.append('"'); + sb.append(cur.replace("\\", "\\\\").replace("\"", "\\\"")); + sb.append('"'); + } + sb.append(']'); + span.setTag("cmd.exec", sb.toString()); } - boolean redactNext = false; - for (int i = 1; i < command.length; i++) { - if (redactNext) { - if (newCommand == null) { - newCommand = new String[command.length]; - System.arraycopy(command, 0, newCommand, 0, command.length); + private static String[] redact(String[] command) { + if (command.length == 0) { + return command; } - newCommand[i] = "?"; - redactNext = false; - continue; - } - String s = command[i]; - if (s == null) { - continue; - } - int posEqual = s.indexOf('='); - if (posEqual == -1) { - if (REDACTED_PARAM_PAT.matcher(s).matches()) { - redactNext = true; - } - } else { - String param = s.substring(0, posEqual); - if (REDACTED_PARAM_PAT.matcher(param).matches()) { - if (newCommand == null) { + String[] newCommand = null; + if (REDACTED_BINARIES.contains(determineResource(command))) { newCommand = new String[command.length]; - System.arraycopy(command, 0, newCommand, 0, command.length); - } - newCommand[i] = param + "=?"; + newCommand[0] = command[0]; + for (int i = 1; i < command.length; i++) { + newCommand[i] = "?"; + } + return newCommand; } - } - } - - return newCommand != null ? newCommand : command; - } - public static void addProcessCompletionHook(Process p, AgentSpan span) { - if (PROCESS_ON_EXIT != null) { - CompletableFuture future; - try { - future = (CompletableFuture) PROCESS_ON_EXIT.invokeExact(p); - } catch (Throwable e) { - span.finish(); - if (e instanceof Error) { - throw (Error) e; - } else if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } else { - throw new UndeclaredThrowableException(e); - } - } + boolean redactNext = false; + for (int i = 1; i < command.length; i++) { + if (redactNext) { + if (newCommand == null) { + newCommand = new String[command.length]; + System.arraycopy(command, 0, newCommand, 0, command.length); + } + newCommand[i] = "?"; + redactNext = false; + continue; + } - final ContextContinuation continuation = Context.current().capture(); - future.whenComplete( - (process, thr) -> { - if (thr != null) { - span.addThrowable(thr); + String s = command[i]; + if (s == null) { + continue; + } + int posEqual = s.indexOf('='); + if (posEqual == -1) { + if (REDACTED_PARAM_PAT.matcher(s).matches()) { + redactNext = true; + } } else { - span.setTag("cmd.exit_code", Integer.toString(process.exitValue())); + String param = s.substring(0, posEqual); + if (REDACTED_PARAM_PAT.matcher(param).matches()) { + if (newCommand == null) { + newCommand = new String[command.length]; + System.arraycopy(command, 0, newCommand, 0, command.length); + } + newCommand[i] = param + "=?"; + } } - finishSpan(continuation, span); - }); - } else if (EXECUTOR != null) { - final ContextContinuation continuation = Context.current().capture(); - EXECUTOR.execute( - () -> { + } + + return newCommand != null ? newCommand : command; + } + + public static void addProcessCompletionHook(Process p, AgentSpan span) { + if (PROCESS_ON_EXIT != null) { + CompletableFuture future; try { - int exitCode = p.waitFor(); - span.setTag("cmd.exit_code", Integer.toString(exitCode)); - } catch (InterruptedException e) { - span.addThrowable(e); - } finally { - finishSpan(continuation, span); + future = (CompletableFuture) PROCESS_ON_EXIT.invokeExact(p); + } catch (Throwable e) { + span.finish(); + if (e instanceof Error) { + throw (Error) e; + } else if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new UndeclaredThrowableException(e); + } } - }); - } - } - public static CharSequence determineResource(String[] command) { - String first = command[0]; - int pos = first.lastIndexOf('/'); - if (pos == -1 || pos == first.length() - 1) { - return first; + final ContextContinuation continuation = Context.current().capture(); + future.whenComplete((process, thr) -> { + if (thr != null) { + span.addThrowable(thr); + } else { + span.setTag("cmd.exit_code", Integer.toString(process.exitValue())); + } + finishSpan(continuation, span); + }); + } else if (EXECUTOR != null) { + final ContextContinuation continuation = Context.current().capture(); + EXECUTOR.execute(() -> { + try { + int exitCode = p.waitFor(); + span.setTag("cmd.exit_code", Integer.toString(exitCode)); + } catch (InterruptedException e) { + span.addThrowable(e); + } finally { + finishSpan(continuation, span); + } + }); + } } - return first.substring(pos + 1); - } - /* - Check if there is a cmd injection attempt to block it - */ - public static void cmdiRaspCheck(@Nonnull final String[] cmdArray) { - if (!Config.get().isAppSecRaspEnabled()) { - return; - } - // if shell injection was checked, skip cmd injection check - if (checkShi.get()) { - return; + public static CharSequence determineResource(String[] command) { + String first = command[0]; + int pos = first.lastIndexOf('/'); + if (pos == -1 || pos == first.length() - 1) { + return first; + } + return first.substring(pos + 1); } - try { - final BiFunction> execCmdCallback = - AgentTracer.get() - .getCallbackProvider(RequestContextSlot.APPSEC) - .getCallback(EVENTS.execCmd()); - if (execCmdCallback == null) { - return; - } + /* + Check if there is a cmd injection attempt to block it + */ + public static void cmdiRaspCheck(@Nonnull final String[] cmdArray) { + if (!Config.get().isAppSecRaspEnabled()) { + return; + } + // if shell injection was checked, skip cmd injection check + if (checkShi.get()) { + return; + } + try { + final BiFunction> execCmdCallback = AgentTracer.get() + .getCallbackProvider(RequestContextSlot.APPSEC) + .getCallback(EVENTS.execCmd()); + + if (execCmdCallback == null) { + return; + } - final AgentSpan span = AgentTracer.get().activeSpan(); - if (span == null) { - return; - } + final AgentSpan span = AgentTracer.get().activeSpan(); + if (span == null) { + return; + } - final RequestContext ctx = span.getRequestContext(); - if (ctx == null) { - return; - } + final RequestContext ctx = span.getRequestContext(); + if (ctx == null) { + return; + } - Flow flow = execCmdCallback.apply(ctx, cmdArray); - Flow.Action action = flow.getAction(); - if (action instanceof Flow.Action.RequestBlockingAction) { - BlockResponseFunction brf = ctx.getBlockResponseFunction(); - if (brf != null) { - Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; - brf.tryCommitBlockingResponse(ctx.getTraceSegment(), rba); + Flow flow = execCmdCallback.apply(ctx, cmdArray); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + BlockResponseFunction brf = ctx.getBlockResponseFunction(); + if (brf != null) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + brf.tryCommitBlockingResponse(ctx.getTraceSegment(), rba); + } + throw new BlockingException("Blocked request (for CMDI attempt)"); + } + } catch (final BlockingException e) { + // re-throw blocking exceptions + throw e; + } catch (final Throwable e) { + // suppress anything else + LOGGER.debug("Exception during CMDI rasp callback", e); } - throw new BlockingException("Blocked request (for CMDI attempt)"); - } - } catch (final BlockingException e) { - // re-throw blocking exceptions - throw e; - } catch (final Throwable e) { - // suppress anything else - LOGGER.debug("Exception during CMDI rasp callback", e); } - } - - public static void resetCheckShi() { - checkShi.set(false); - } - /* - Check if there is a chell injection attempt to block it - */ - public static void shiRaspCheck(@Nonnull final String cmd) { - if (!Config.get().isAppSecRaspEnabled()) { - return; + public static void resetCheckShi() { + checkShi.set(false); } - checkShi.set(true); - try { - final BiFunction> shellCmdCallback = - AgentTracer.get() - .getCallbackProvider(RequestContextSlot.APPSEC) - .getCallback(EVENTS.shellCmd()); - if (shellCmdCallback == null) { - return; - } + /* + Check if there is a chell injection attempt to block it + */ + public static void shiRaspCheck(@Nonnull final String cmd) { + if (!Config.get().isAppSecRaspEnabled()) { + return; + } + checkShi.set(true); + try { + final BiFunction> shellCmdCallback = AgentTracer.get() + .getCallbackProvider(RequestContextSlot.APPSEC) + .getCallback(EVENTS.shellCmd()); - final AgentSpan span = AgentTracer.get().activeSpan(); - if (span == null) { - return; - } + if (shellCmdCallback == null) { + return; + } - final RequestContext ctx = span.getRequestContext(); - if (ctx == null) { - return; - } + final AgentSpan span = AgentTracer.get().activeSpan(); + if (span == null) { + return; + } - Flow flow = shellCmdCallback.apply(ctx, cmd); - Flow.Action action = flow.getAction(); - if (action instanceof Flow.Action.RequestBlockingAction) { - BlockResponseFunction brf = ctx.getBlockResponseFunction(); - if (brf != null) { - Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; - brf.tryCommitBlockingResponse(ctx.getTraceSegment(), rba); + final RequestContext ctx = span.getRequestContext(); + if (ctx == null) { + return; + } + + Flow flow = shellCmdCallback.apply(ctx, cmd); + Flow.Action action = flow.getAction(); + if (action instanceof Flow.Action.RequestBlockingAction) { + BlockResponseFunction brf = ctx.getBlockResponseFunction(); + if (brf != null) { + Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action; + brf.tryCommitBlockingResponse(ctx.getTraceSegment(), rba); + } + throw new BlockingException("Blocked request (for SHI attempt)"); + } + } catch (final BlockingException e) { + // re-throw blocking exceptions + throw e; + } catch (final Throwable e) { + // suppress anything else + LOGGER.debug("Exception during SHI rasp callback", e); } - throw new BlockingException("Blocked request (for SHI attempt)"); - } - } catch (final BlockingException e) { - // re-throw blocking exceptions - throw e; - } catch (final Throwable e) { - // suppress anything else - LOGGER.debug("Exception during SHI rasp callback", e); } - } - private static void finishSpan( - final ContextContinuation parentContinuation, final AgentSpan span) { - if (parentContinuation.context() == Context.root()) { - span.finish(); - return; - } - try (final ContextScope scope = parentContinuation.resume()) { - span.finish(); + private static void finishSpan(final ContextContinuation parentContinuation, final AgentSpan span) { + if (parentContinuation.context() == Context.root()) { + span.finish(); + return; + } + try (final ContextScope scope = parentContinuation.resume()) { + span.finish(); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/logging/GlobalLogLevelSwitcher.java b/internal-api/src/main/java/datadog/trace/logging/GlobalLogLevelSwitcher.java index 400772b7f2d..31e686513da 100644 --- a/internal-api/src/main/java/datadog/trace/logging/GlobalLogLevelSwitcher.java +++ b/internal-api/src/main/java/datadog/trace/logging/GlobalLogLevelSwitcher.java @@ -5,49 +5,50 @@ import org.slf4j.LoggerFactory; public class GlobalLogLevelSwitcher implements LogLevelSwitcher { - private static volatile LogLevelSwitcher INSTANCE = null; - - public static LogLevelSwitcher get() { - LogLevelSwitcher switcher = INSTANCE; - if (switcher == null) { - ILoggerFactory factory = LoggerFactory.getILoggerFactory(); - INSTANCE = switcher = new GlobalLogLevelSwitcher(factory); + private static volatile LogLevelSwitcher INSTANCE = null; + + public static LogLevelSwitcher get() { + LogLevelSwitcher switcher = INSTANCE; + if (switcher == null) { + ILoggerFactory factory = LoggerFactory.getILoggerFactory(); + INSTANCE = switcher = new GlobalLogLevelSwitcher(factory); + } + return switcher; } - return switcher; - } - - private final Logger log; - private final LogLevelSwitcher delegate; - - GlobalLogLevelSwitcher(ILoggerFactory factory) { - log = factory.getLogger(GlobalLogLevelSwitcher.class.getName()); - if (factory instanceof LogLevelSwitcher) { - delegate = (LogLevelSwitcher) factory; - } else { - log.error( - "Unable to find global log level switcher, found {}", factory.getClass().getSimpleName()); - delegate = null; + + private final Logger log; + private final LogLevelSwitcher delegate; + + GlobalLogLevelSwitcher(ILoggerFactory factory) { + log = factory.getLogger(GlobalLogLevelSwitcher.class.getName()); + if (factory instanceof LogLevelSwitcher) { + delegate = (LogLevelSwitcher) factory; + } else { + log.error( + "Unable to find global log level switcher, found {}", + factory.getClass().getSimpleName()); + delegate = null; + } } - } - @Override - public void switchLevel(LogLevel level) { - if (delegate != null) { - delegate.switchLevel(level); + @Override + public void switchLevel(LogLevel level) { + if (delegate != null) { + delegate.switchLevel(level); + } } - } - @Override - public void restore() { - if (delegate != null) { - delegate.restore(); + @Override + public void restore() { + if (delegate != null) { + delegate.restore(); + } } - } - @Override - public void reinitialize() { - if (delegate != null) { - delegate.reinitialize(); + @Override + public void reinitialize() { + if (delegate != null) { + delegate.reinitialize(); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/logging/LogLevel.java b/internal-api/src/main/java/datadog/trace/logging/LogLevel.java index 7fb8ab5981f..9c45c2fe440 100644 --- a/internal-api/src/main/java/datadog/trace/logging/LogLevel.java +++ b/internal-api/src/main/java/datadog/trace/logging/LogLevel.java @@ -2,38 +2,38 @@ /** Log level enum. */ public enum LogLevel { - TRACE, - DEBUG, - INFO, - WARN, - ERROR, - OFF; + TRACE, + DEBUG, + INFO, + WARN, + ERROR, + OFF; - /** - * Case insensitive conversion from {@link String} to {@link LogLevel}. - * - *

Fallback is {@link #INFO}. - * - * @param level the {@link LogLevel} as a {@link String} - * @return the corresponding {@link LogLevel} enum - */ - public static LogLevel fromString(String level) { - String upper = level.toUpperCase(); - try { - return Enum.valueOf(LogLevel.class, upper); - } catch (Throwable t) { - // INFO is the fallback - return LogLevel.INFO; + /** + * Case insensitive conversion from {@link String} to {@link LogLevel}. + * + *

Fallback is {@link #INFO}. + * + * @param level the {@link LogLevel} as a {@link String} + * @return the corresponding {@link LogLevel} enum + */ + public static LogLevel fromString(String level) { + String upper = level.toUpperCase(); + try { + return Enum.valueOf(LogLevel.class, upper); + } catch (Throwable t) { + // INFO is the fallback + return LogLevel.INFO; + } } - } - /** - * Check if a {@link LogLevel} is enabled for this {@link LogLevel}. - * - * @param level the {@link LogLevel} to check - * @return true if the {@code level} is enabled, false otherwise - */ - public boolean isEnabled(LogLevel level) { - return this.compareTo(level) >= 0; - } + /** + * Check if a {@link LogLevel} is enabled for this {@link LogLevel}. + * + * @param level the {@link LogLevel} to check + * @return true if the {@code level} is enabled, false otherwise + */ + public boolean isEnabled(LogLevel level) { + return this.compareTo(level) >= 0; + } } diff --git a/internal-api/src/main/java/datadog/trace/logging/LogLevelSwitcher.java b/internal-api/src/main/java/datadog/trace/logging/LogLevelSwitcher.java index 3e1adfe12fe..2d566302ac9 100644 --- a/internal-api/src/main/java/datadog/trace/logging/LogLevelSwitcher.java +++ b/internal-api/src/main/java/datadog/trace/logging/LogLevelSwitcher.java @@ -3,16 +3,16 @@ /** Enables temporary runtime switching of LogLevel. */ public interface LogLevelSwitcher { - /** - * Temporarily switch the current LogLevel to a new LogLevel - * - * @param level the LogLevel to switch to - */ - void switchLevel(LogLevel level); + /** + * Temporarily switch the current LogLevel to a new LogLevel + * + * @param level the LogLevel to switch to + */ + void switchLevel(LogLevel level); - /** Restore the LogLevel to the original setting. */ - void restore(); + /** Restore the LogLevel to the original setting. */ + void restore(); - /** Re-execute logging settings initialization */ - void reinitialize(); + /** Re-execute logging settings initialization */ + void reinitialize(); } diff --git a/internal-api/src/main/java/datadog/trace/logging/LoggingSettingsDescription.java b/internal-api/src/main/java/datadog/trace/logging/LoggingSettingsDescription.java index 90924168aee..f5e2ea25338 100644 --- a/internal-api/src/main/java/datadog/trace/logging/LoggingSettingsDescription.java +++ b/internal-api/src/main/java/datadog/trace/logging/LoggingSettingsDescription.java @@ -5,16 +5,15 @@ import java.util.Map; public final class LoggingSettingsDescription { - private LoggingSettingsDescription() {} + private LoggingSettingsDescription() {} - private static volatile Map description = Collections.emptyMap(); + private static volatile Map description = Collections.emptyMap(); - public static void setDescription(Map description) { - LoggingSettingsDescription.description = - Collections.unmodifiableMap(new HashMap<>(description)); - } + public static void setDescription(Map description) { + LoggingSettingsDescription.description = Collections.unmodifiableMap(new HashMap<>(description)); + } - public static Map getDescription() { - return description; - } + public static Map getDescription() { + return description; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/AgentProxySelector.java b/internal-api/src/main/java/datadog/trace/util/AgentProxySelector.java index de999b29d6a..15165b4ef77 100644 --- a/internal-api/src/main/java/datadog/trace/util/AgentProxySelector.java +++ b/internal-api/src/main/java/datadog/trace/util/AgentProxySelector.java @@ -11,25 +11,25 @@ import java.util.Set; public final class AgentProxySelector extends ProxySelector { - public static final ProxySelector INSTANCE = new AgentProxySelector(); + public static final ProxySelector INSTANCE = new AgentProxySelector(); - private static final List DIRECT = Collections.singletonList(Proxy.NO_PROXY); + private static final List DIRECT = Collections.singletonList(Proxy.NO_PROXY); - private final Set noProxyHosts = Config.get().getNoProxyHosts(); + private final Set noProxyHosts = Config.get().getNoProxyHosts(); - private final ProxySelector defaultProxySelector = ProxySelector.getDefault(); + private final ProxySelector defaultProxySelector = ProxySelector.getDefault(); - @Override - public List select(final URI uri) { - if (null != uri.getHost() && noProxyHosts.contains(uri.getHost())) { - return DIRECT; - } else { - return defaultProxySelector.select(uri); + @Override + public List select(final URI uri) { + if (null != uri.getHost() && noProxyHosts.contains(uri.getHost())) { + return DIRECT; + } else { + return defaultProxySelector.select(uri); + } } - } - @Override - public void connectFailed(final URI uri, final SocketAddress sa, final IOException ioe) { - defaultProxySelector.connectFailed(uri, sa, ioe); - } + @Override + public void connectFailed(final URI uri, final SocketAddress sa, final IOException ioe) { + defaultProxySelector.connectFailed(uri, sa, ioe); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/AgentTaskScheduler.java b/internal-api/src/main/java/datadog/trace/util/AgentTaskScheduler.java index 81ecb2a76d3..9f19ae96867 100644 --- a/internal-api/src/main/java/datadog/trace/util/AgentTaskScheduler.java +++ b/internal-api/src/main/java/datadog/trace/util/AgentTaskScheduler.java @@ -20,379 +20,358 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@SuppressFBWarnings( - value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", - justification = "Not a singleton") +@SuppressFBWarnings(value = "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR", justification = "Not a singleton") public class AgentTaskScheduler implements Executor { - private static final Logger log = LoggerFactory.getLogger(AgentTaskScheduler.class); + private static final Logger log = LoggerFactory.getLogger(AgentTaskScheduler.class); - // not final for testing purposes - private static AgentTaskScheduler INSTANCE = new AgentTaskScheduler(TASK_SCHEDULER); + // not final for testing purposes + private static AgentTaskScheduler INSTANCE = new AgentTaskScheduler(TASK_SCHEDULER); - private static final long SHUTDOWN_TIMEOUT = 5; // seconds + private static final long SHUTDOWN_TIMEOUT = 5; // seconds - public static AgentTaskScheduler get() { - return INSTANCE; - } + public static AgentTaskScheduler get() { + return INSTANCE; + } - /** - * This method is only for testing. It shuts down the existing executor and swap the instance with - * a fresh one. - * - * @param timeout the amount of time to wait for the shutdown. - * @param unit the unit of the time amount. - */ - public static void shutdownAndReset(long timeout, TimeUnit unit) { - INSTANCE.shutdown(timeout, unit); - INSTANCE = new AgentTaskScheduler(TASK_SCHEDULER); - } + /** + * This method is only for testing. It shuts down the existing executor and swap the instance with + * a fresh one. + * + * @param timeout the amount of time to wait for the shutdown. + * @param unit the unit of the time amount. + */ + public static void shutdownAndReset(long timeout, TimeUnit unit) { + INSTANCE.shutdown(timeout, unit); + INSTANCE = new AgentTaskScheduler(TASK_SCHEDULER); + } - public interface Task { - void run(T target); - } + public interface Task { + void run(T target); + } - public interface Target { - T get(); - } + public interface Target { + T get(); + } - public static final class RunnableTask implements Task { - public static final RunnableTask INSTANCE = new RunnableTask(); + public static final class RunnableTask implements Task { + public static final RunnableTask INSTANCE = new RunnableTask(); - @Override - public void run(final Runnable target) { - target.run(); + @Override + public void run(final Runnable target) { + target.run(); + } + } + + public static class Scheduled implements Target { + private volatile T referent; + + private Scheduled(final T referent) { + this.referent = referent; + } + + @Override + public T get() { + return referent; + } + + public void cancel() { + referent = null; + } + } + + private static final class WeakTarget extends WeakReference implements Target { + private WeakTarget(final T referent) { + super(referent); + } } - } - public static class Scheduled implements Target { - private volatile T referent; + private final DelayQueue> workQueue = new DelayQueue<>(); + private final AgentThread agentThread; + private volatile Thread worker; + private volatile boolean shutdown; - private Scheduled(final T referent) { - this.referent = referent; + public AgentTaskScheduler(final AgentThread agentThread) { + this.agentThread = agentThread; } @Override - public T get() { - return referent; + public void execute(final Runnable target) { + schedule(RunnableTask.INSTANCE, target, 0, MILLISECONDS); } - public void cancel() { - referent = null; + public Scheduled schedule(final Task task, final T target, final long initialDelay, final TimeUnit unit) { + final Scheduled scheduled = new Scheduled<>(target); + scheduleTarget(task, scheduled, initialDelay, 0, unit); + return scheduled; } - } - private static final class WeakTarget extends WeakReference implements Target { - private WeakTarget(final T referent) { - super(referent); + public Scheduled schedule(final Runnable target, final long initialDelay, final TimeUnit unit) { + return schedule(RunnableTask.INSTANCE, target, initialDelay, unit); } - } - - private final DelayQueue> workQueue = new DelayQueue<>(); - private final AgentThread agentThread; - private volatile Thread worker; - private volatile boolean shutdown; - - public AgentTaskScheduler(final AgentThread agentThread) { - this.agentThread = agentThread; - } - - @Override - public void execute(final Runnable target) { - schedule(RunnableTask.INSTANCE, target, 0, MILLISECONDS); - } - - public Scheduled schedule( - final Task task, final T target, final long initialDelay, final TimeUnit unit) { - final Scheduled scheduled = new Scheduled<>(target); - scheduleTarget(task, scheduled, initialDelay, 0, unit); - return scheduled; - } - - public Scheduled schedule( - final Runnable target, final long initialDelay, final TimeUnit unit) { - return schedule(RunnableTask.INSTANCE, target, initialDelay, unit); - } - - /** - * Adds a random jitter of up to 10 seconds to the delay. This avoids a fleet of traced - * applications starting at the same time and scheduling the same publishing task in sync - */ - public Scheduled scheduleWithJitter( - final Task task, final T target, final long initialDelay, final TimeUnit unit) { - - // schedule to start after geometrically distributed number of seconds expressed in - // milliseconds, with p = 0.25, meaning the probability that the aggregator will not - // have started by the nth second is 0.25(0.75)^n-1 (or a 1% chance of not having - // started within 10 seconds, where a cap is applied) - long randomMillis = - unit.toMillis(initialDelay) - + Math.min( - (long) - (1000D - * Math.log(ThreadLocalRandom.current().nextDouble()) - / Math.log(1 - 0.25)), - 10_000); - - return schedule(task, target, randomMillis, MILLISECONDS); - } - - public Scheduled scheduleWithJitter( - final Runnable target, final long initialDelay, final TimeUnit unit) { - return scheduleWithJitter(RunnableTask.INSTANCE, target, initialDelay, unit); - } - - public Scheduled scheduleAtFixedRate( - final Task task, - final T target, - final long initialDelay, - final long period, - final TimeUnit unit) { - final Scheduled scheduled = new Scheduled<>(target); - scheduleTarget(task, scheduled, initialDelay, period, unit); - return scheduled; - } - - public Scheduled scheduleAtFixedRate( - final Runnable target, final long initialDelay, final long period, final TimeUnit unit) { - return scheduleAtFixedRate(RunnableTask.INSTANCE, target, initialDelay, period, unit); - } - - public void weakScheduleAtFixedRate( - final Task task, - final T target, - final long initialDelay, - final long period, - final TimeUnit unit) { - scheduleTarget(task, new WeakTarget<>(target), initialDelay, period, unit); - } - - public void weakScheduleAtFixedRate( - final Runnable target, final long initialDelay, final long period, final TimeUnit unit) { - weakScheduleAtFixedRate(RunnableTask.INSTANCE, target, initialDelay, period, unit); - } - - public Scheduled scheduleWithFixedDelay( - Task task, T target, long initialDelay, long period, TimeUnit unit) { - final Scheduled scheduled = new Scheduled<>(target); - scheduleTarget(task, scheduled, initialDelay, -period, unit); - return scheduled; - } - - public Scheduled scheduleWithFixedDelay( - Runnable target, long initialDelay, long period, TimeUnit unit) { - return scheduleWithFixedDelay(RunnableTask.INSTANCE, target, initialDelay, period, unit); - } - - @SuppressFBWarnings({ - "JLM_JSR166_UTILCONCURRENT_MONITORENTER", - "USO_UNSAFE_ACCESSIBLE_OBJECT_SYNCHRONIZATION" - }) - private void scheduleTarget( - final Task task, - final Target target, - final long initialDelay, - final long period, - final TimeUnit unit) { - - if (target == null || target.get() == null) { - return; + + /** + * Adds a random jitter of up to 10 seconds to the delay. This avoids a fleet of traced + * applications starting at the same time and scheduling the same publishing task in sync + */ + public Scheduled scheduleWithJitter( + final Task task, final T target, final long initialDelay, final TimeUnit unit) { + + // schedule to start after geometrically distributed number of seconds expressed in + // milliseconds, with p = 0.25, meaning the probability that the aggregator will not + // have started by the nth second is 0.25(0.75)^n-1 (or a 1% chance of not having + // started within 10 seconds, where a cap is applied) + long randomMillis = unit.toMillis(initialDelay) + + Math.min( + (long) (1000D * Math.log(ThreadLocalRandom.current().nextDouble()) / Math.log(1 - 0.25)), + 10_000); + + return schedule(task, target, randomMillis, MILLISECONDS); } - if (!shutdown && worker == null) { - synchronized (workQueue) { - if (!shutdown && worker == null) { - prepareWorkQueue(); - try { - worker = newAgentThread(agentThread, new Worker()); - // register hook after worker is assigned, but before we start it - Runtime.getRuntime().addShutdownHook(new ShutdownHook()); - worker.start(); - } catch (final IllegalStateException e) { - shutdown = true; // couldn't add hook, JVM is shutting down - } - } - } + public Scheduled scheduleWithJitter(final Runnable target, final long initialDelay, final TimeUnit unit) { + return scheduleWithJitter(RunnableTask.INSTANCE, target, initialDelay, unit); } - if (!shutdown) { - workQueue.offer(new PeriodicTask<>(task, target, initialDelay, period, unit)); - } else { - log.debug("Agent task scheduler is shutdown. Will not run {}", describeTask(task, target)); + public Scheduled scheduleAtFixedRate( + final Task task, final T target, final long initialDelay, final long period, final TimeUnit unit) { + final Scheduled scheduled = new Scheduled<>(target); + scheduleTarget(task, scheduled, initialDelay, period, unit); + return scheduled; } - } - - private void prepareWorkQueue() { - try { - // exercise 'poll' method to make sure all relevant queue synchronizer types are preloaded - // here rather than in the Worker when it calls 'take' - this avoids a potential loop-back - workQueue.poll(1, NANOSECONDS); - } catch (final InterruptedException e) { - // ignore, we only want to preload queue internals + + public Scheduled scheduleAtFixedRate( + final Runnable target, final long initialDelay, final long period, final TimeUnit unit) { + return scheduleAtFixedRate(RunnableTask.INSTANCE, target, initialDelay, period, unit); } - if (this == INSTANCE) { - // preload a future no-op task to ensure workQueue.take() will use await with timeout during - // premain - otherwise on Java 25 it will load ForkJoinPool which in turn loads ForkJoinTask, - // which then means we lose the chance to field-inject context into ForkJoinTask instances - workQueue.offer(FUTURE_NOOP_PLACEHOLDER); + + public void weakScheduleAtFixedRate( + final Task task, final T target, final long initialDelay, final long period, final TimeUnit unit) { + scheduleTarget(task, new WeakTarget<>(target), initialDelay, period, unit); } - } - // for testing - int taskCount() { - return workQueue.size(); - } + public void weakScheduleAtFixedRate( + final Runnable target, final long initialDelay, final long period, final TimeUnit unit) { + weakScheduleAtFixedRate(RunnableTask.INSTANCE, target, initialDelay, period, unit); + } - public boolean isShutdown() { - return shutdown; - } + public Scheduled scheduleWithFixedDelay( + Task task, T target, long initialDelay, long period, TimeUnit unit) { + final Scheduled scheduled = new Scheduled<>(target); + scheduleTarget(task, scheduled, initialDelay, -period, unit); + return scheduled; + } - public static void initialize() { - AgentTaskScheduler.INSTANCE.dummy(); - } + public Scheduled scheduleWithFixedDelay(Runnable target, long initialDelay, long period, TimeUnit unit) { + return scheduleWithFixedDelay(RunnableTask.INSTANCE, target, initialDelay, period, unit); + } - private void dummy() { - /* - * Dummy empty method body used only to make compiler happy when initializing this class by accessing - * the static INSTANCE field. - */ - } - - public void shutdown(final long timeout, final TimeUnit unit) { - shutdown = true; - final Thread t = worker; - if (t != null) { - t.interrupt(); - if (timeout > 0) { + @SuppressFBWarnings({"JLM_JSR166_UTILCONCURRENT_MONITORENTER", "USO_UNSAFE_ACCESSIBLE_OBJECT_SYNCHRONIZATION"}) + private void scheduleTarget( + final Task task, + final Target target, + final long initialDelay, + final long period, + final TimeUnit unit) { + + if (target == null || target.get() == null) { + return; + } + + if (!shutdown && worker == null) { + synchronized (workQueue) { + if (!shutdown && worker == null) { + prepareWorkQueue(); + try { + worker = newAgentThread(agentThread, new Worker()); + // register hook after worker is assigned, but before we start it + Runtime.getRuntime().addShutdownHook(new ShutdownHook()); + worker.start(); + } catch (final IllegalStateException e) { + shutdown = true; // couldn't add hook, JVM is shutting down + } + } + } + } + + if (!shutdown) { + workQueue.offer(new PeriodicTask<>(task, target, initialDelay, period, unit)); + } else { + log.debug("Agent task scheduler is shutdown. Will not run {}", describeTask(task, target)); + } + } + + private void prepareWorkQueue() { try { - t.join(unit.toMillis(timeout)); + // exercise 'poll' method to make sure all relevant queue synchronizer types are preloaded + // here rather than in the Worker when it calls 'take' - this avoids a potential loop-back + workQueue.poll(1, NANOSECONDS); } catch (final InterruptedException e) { - // continue shutdown... + // ignore, we only want to preload queue internals + } + if (this == INSTANCE) { + // preload a future no-op task to ensure workQueue.take() will use await with timeout during + // premain - otherwise on Java 25 it will load ForkJoinPool which in turn loads ForkJoinTask, + // which then means we lose the chance to field-inject context into ForkJoinTask instances + workQueue.offer(FUTURE_NOOP_PLACEHOLDER); } - } } - } - private static String describeTask(final Task task, final Target target) { - return "periodic task " + task.getClass().getSimpleName() + " with target " + target.get(); - } + // for testing + int taskCount() { + return workQueue.size(); + } - private final class ShutdownHook extends Thread { - ShutdownHook() { - super(AGENT_THREAD_GROUP, agentThread.threadName + "-shutdown-hook"); + public boolean isShutdown() { + return shutdown; } - @Override - public void run() { - shutdown(SHUTDOWN_TIMEOUT, SECONDS); + public static void initialize() { + AgentTaskScheduler.INSTANCE.dummy(); } - } - private final class Worker implements Runnable { - @Override - public void run() { - while (!shutdown) { - PeriodicTask work = null; - try { - work = workQueue.take(); - work.run(); - } catch (final Throwable e) { - if (work != null) { - log.debug("Uncaught exception from {}", work, e); - } - } finally { - if (work != null && work.reschedule()) { - workQueue.offer(work); - } + private void dummy() { + /* + * Dummy empty method body used only to make compiler happy when initializing this class by accessing + * the static INSTANCE field. + */ + } + + public void shutdown(final long timeout, final TimeUnit unit) { + shutdown = true; + final Thread t = worker; + if (t != null) { + t.interrupt(); + if (timeout > 0) { + try { + t.join(unit.toMillis(timeout)); + } catch (final InterruptedException e) { + // continue shutdown... + } + } } - } - workQueue.clear(); - worker = null; } - } - private static final AtomicInteger TASK_SEQUENCE_GENERATOR = new AtomicInteger(); + private static String describeTask(final Task task, final Target target) { + return "periodic task " + task.getClass().getSimpleName() + " with target " + target.get(); + } - private static final PeriodicTask FUTURE_NOOP_PLACEHOLDER = - new PeriodicTask<>(null, new Scheduled<>(null), 10, 0, MINUTES); + private final class ShutdownHook extends Thread { + ShutdownHook() { + super(AGENT_THREAD_GROUP, agentThread.threadName + "-shutdown-hook"); + } - private static final class PeriodicTask implements Delayed { + @Override + public void run() { + shutdown(SHUTDOWN_TIMEOUT, SECONDS); + } + } - private final Task task; - private final Target target; - private final long period; - private final int taskSequence; + private final class Worker implements Runnable { + @Override + public void run() { + while (!shutdown) { + PeriodicTask work = null; + try { + work = workQueue.take(); + work.run(); + } catch (final Throwable e) { + if (work != null) { + log.debug("Uncaught exception from {}", work, e); + } + } finally { + if (work != null && work.reschedule()) { + workQueue.offer(work); + } + } + } + workQueue.clear(); + worker = null; + } + } - private long nextFireTime; + private static final AtomicInteger TASK_SEQUENCE_GENERATOR = new AtomicInteger(); - public PeriodicTask( - final Task task, - final Target target, - final long initialDelay, - final long period, - final TimeUnit unit) { + private static final PeriodicTask FUTURE_NOOP_PLACEHOLDER = + new PeriodicTask<>(null, new Scheduled<>(null), 10, 0, MINUTES); - this.task = task; - this.target = target; - this.period = unit.toNanos(period); - this.taskSequence = TASK_SEQUENCE_GENERATOR.getAndIncrement(); + private static final class PeriodicTask implements Delayed { - nextFireTime = System.nanoTime() + unit.toNanos(initialDelay); - } + private final Task task; + private final Target target; + private final long period; + private final int taskSequence; - public void run() { - final T t = target.get(); - if (t != null) { - task.run(t); - } - } + private long nextFireTime; - public boolean reschedule() { - if (period > 0 && target.get() != null) { - nextFireTime += period; - return true; - } - if (period < 0 && target.get() != null) { - nextFireTime = System.nanoTime() - period; - return true; - } - return false; - } + public PeriodicTask( + final Task task, + final Target target, + final long initialDelay, + final long period, + final TimeUnit unit) { - @Override - public long getDelay(final TimeUnit unit) { - return unit.convert(nextFireTime - System.nanoTime(), NANOSECONDS); - } + this.task = task; + this.target = target; + this.period = unit.toNanos(period); + this.taskSequence = TASK_SEQUENCE_GENERATOR.getAndIncrement(); - @Override - public int compareTo(final Delayed other) { - if (this == other) { - return 0; - } - long taskOrder; - if (other instanceof PeriodicTask) { - final PeriodicTask otherTask = (PeriodicTask) other; - taskOrder = nextFireTime - otherTask.nextFireTime; - if (taskOrder == 0) { - taskOrder = taskSequence - otherTask.taskSequence; + nextFireTime = System.nanoTime() + unit.toNanos(initialDelay); } - } else { - taskOrder = getDelay(NANOSECONDS) - other.getDelay(NANOSECONDS); - } - return taskOrder < 0 ? -1 : (taskOrder > 0 ? 1 : 0); - } - @Override - public boolean equals(Object obj) { - try { - return obj == null ? false : (this.compareTo((Delayed) obj) != 0 ? false : true); - } catch (ClassCastException e) { - return false; - } - } + public void run() { + final T t = target.get(); + if (t != null) { + task.run(t); + } + } - @Override - public String toString() { - return describeTask(task, target); + public boolean reschedule() { + if (period > 0 && target.get() != null) { + nextFireTime += period; + return true; + } + if (period < 0 && target.get() != null) { + nextFireTime = System.nanoTime() - period; + return true; + } + return false; + } + + @Override + public long getDelay(final TimeUnit unit) { + return unit.convert(nextFireTime - System.nanoTime(), NANOSECONDS); + } + + @Override + public int compareTo(final Delayed other) { + if (this == other) { + return 0; + } + long taskOrder; + if (other instanceof PeriodicTask) { + final PeriodicTask otherTask = (PeriodicTask) other; + taskOrder = nextFireTime - otherTask.nextFireTime; + if (taskOrder == 0) { + taskOrder = taskSequence - otherTask.taskSequence; + } + } else { + taskOrder = getDelay(NANOSECONDS) - other.getDelay(NANOSECONDS); + } + return taskOrder < 0 ? -1 : (taskOrder > 0 ? 1 : 0); + } + + @Override + public boolean equals(Object obj) { + try { + return obj == null ? false : (this.compareTo((Delayed) obj) != 0 ? false : true); + } catch (ClassCastException e) { + return false; + } + } + + @Override + public String toString() { + return describeTask(task, target); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java b/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java index 81cd9a0b294..e6cb94c6ff8 100644 --- a/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java +++ b/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java @@ -5,129 +5,123 @@ /** A {@link ThreadFactory} implementation that starts all agent {@link Thread}s as daemons. */ public final class AgentThreadFactory implements ThreadFactory { - public static final ThreadGroup AGENT_THREAD_GROUP = new ThreadGroup("dd-trace-java"); + public static final ThreadGroup AGENT_THREAD_GROUP = new ThreadGroup("dd-trace-java"); - public static final long THREAD_JOIN_TIMOUT_MS = 800; + public static final long THREAD_JOIN_TIMOUT_MS = 800; - // known agent threads - public enum AgentThread { - TASK_SCHEDULER("dd-task-scheduler"), + // known agent threads + public enum AgentThread { + TASK_SCHEDULER("dd-task-scheduler"), - TRACE_STARTUP("dd-agent-startup-datadog-tracer"), - TRACE_MONITOR("dd-trace-monitor"), - TRACE_PROCESSOR("dd-trace-processor"), - SPAN_SAMPLING_PROCESSOR("dd-span-sampling-processor"), - TRACE_CASSANDRA_ASYNC_SESSION("dd-cassandra-session-executor"), + TRACE_STARTUP("dd-agent-startup-datadog-tracer"), + TRACE_MONITOR("dd-trace-monitor"), + TRACE_PROCESSOR("dd-trace-processor"), + SPAN_SAMPLING_PROCESSOR("dd-span-sampling-processor"), + TRACE_CASSANDRA_ASYNC_SESSION("dd-cassandra-session-executor"), - METRICS_AGGREGATOR("dd-metrics-aggregator"), - STATSD_CLIENT("dd-statsd-client"), + METRICS_AGGREGATOR("dd-metrics-aggregator"), + STATSD_CLIENT("dd-statsd-client"), - JMX_STARTUP("dd-agent-startup-jmxfetch"), - JMX_COLLECTOR("dd-jmx-collector"), + JMX_STARTUP("dd-agent-startup-jmxfetch"), + JMX_COLLECTOR("dd-jmx-collector"), - OTLP_METRICS_EXPORTER("dd-otlp-metrics-exporter"), - OTLP_LOGS_EXPORTER("dd-otlp-logs-exporter"), + OTLP_METRICS_EXPORTER("dd-otlp-metrics-exporter"), + OTLP_LOGS_EXPORTER("dd-otlp-logs-exporter"), - PROFILER_STARTUP("dd-agent-startup-datadog-profiler"), - PROFILER_RECORDING_SCHEDULER("dd-profiler-recording-scheduler"), - PROFILER_HTTP_DISPATCHER("dd-profiler-http-dispatcher"), + PROFILER_STARTUP("dd-agent-startup-datadog-profiler"), + PROFILER_RECORDING_SCHEDULER("dd-profiler-recording-scheduler"), + PROFILER_HTTP_DISPATCHER("dd-profiler-http-dispatcher"), - APPSEC_HTTP_DISPATCHER("dd-appsec-http-dispatcher"), + APPSEC_HTTP_DISPATCHER("dd-appsec-http-dispatcher"), - CRASHTRACKING_HTTP_DISPATCHER("dd-crashtracking-http-dispatcher"), + CRASHTRACKING_HTTP_DISPATCHER("dd-crashtracking-http-dispatcher"), - TELEMETRY("dd-telemetry"), + TELEMETRY("dd-telemetry"), - FLEET_MANAGEMENT_POLLER("dd-fleet-management-poller"), - REMOTE_CONFIG("dd-remote-config"), + FLEET_MANAGEMENT_POLLER("dd-fleet-management-poller"), + REMOTE_CONFIG("dd-remote-config"), - TRACER_FLARE("dd-tracer-flare"), + TRACER_FLARE("dd-tracer-flare"), - CWS_TLS("dd-cws-tls"), + CWS_TLS("dd-cws-tls"), - PROCESS_SUPERVISOR("dd-process-supervisor"), + PROCESS_SUPERVISOR("dd-process-supervisor"), - DATA_JOBS_MONITORING_SHUTDOWN_HOOK("dd-data-jobs-shutdown-hook"), + DATA_JOBS_MONITORING_SHUTDOWN_HOOK("dd-data-jobs-shutdown-hook"), - DATA_STREAMS_MONITORING("dd-data-streams-monitor"), + DATA_STREAMS_MONITORING("dd-data-streams-monitor"), - DEBUGGER_SNAPSHOT_SERIALIZER("dd-debugger-snapshot-serializer"), - DEBUGGER_HTTP_DISPATCHER("dd-debugger-upload-http-dispatcher"), + DEBUGGER_SNAPSHOT_SERIALIZER("dd-debugger-snapshot-serializer"), + DEBUGGER_HTTP_DISPATCHER("dd-debugger-upload-http-dispatcher"), - CI_SHELL_COMMAND("dd-ci-shell-command"), - CI_GIT_DATA_UPLOADER("dd-ci-git-data-uploader"), - CI_GIT_DATA_SHUTDOWN_HOOK("dd-ci-git-data-shutdown-hook"), - CI_PROJECT_CONFIGURATOR("dd-ci-project-configurator"), - CI_SIGNAL_SERVER("dd-ci-signal-server"), + CI_SHELL_COMMAND("dd-ci-shell-command"), + CI_GIT_DATA_UPLOADER("dd-ci-git-data-uploader"), + CI_GIT_DATA_SHUTDOWN_HOOK("dd-ci-git-data-shutdown-hook"), + CI_PROJECT_CONFIGURATOR("dd-ci-project-configurator"), + CI_SIGNAL_SERVER("dd-ci-signal-server"), - RETRANSFORMER("dd-retransformer"), + RETRANSFORMER("dd-retransformer"), - LOGS_INTAKE("dd-logs-intake"), + LOGS_INTAKE("dd-logs-intake"), - LLMOBS_EVALS_PROCESSOR("dd-llmobs-evals-processor"), + LLMOBS_EVALS_PROCESSOR("dd-llmobs-evals-processor"), - LLMOBS_FEEDBACK_PROCESSOR("dd-llmobs-feedback-processor"), + LLMOBS_FEEDBACK_PROCESSOR("dd-llmobs-feedback-processor"), - FEATURE_FLAG_EXPOSURE_PROCESSOR("dd-ffe-exposure-processor"), - FEATURE_FLAG_EVALUATION_PROCESSOR("dd-ffe-evaluation-processor"), - FEATURE_FLAG_CONFIGURATION_POLLER("dd-feature-flagging-http-poller"); + FEATURE_FLAG_EXPOSURE_PROCESSOR("dd-ffe-exposure-processor"), + FEATURE_FLAG_EVALUATION_PROCESSOR("dd-ffe-evaluation-processor"), + FEATURE_FLAG_CONFIGURATION_POLLER("dd-feature-flagging-http-poller"); - public final String threadName; + public final String threadName; - AgentThread(final String threadName) { - this.threadName = threadName; + AgentThread(final String threadName) { + this.threadName = threadName; + } } - } - - private final AgentThread agentThread; - - /** - * Constructs a new agent {@code ThreadFactory}. - * - * @param agentThread the agent thread created by this factory. - */ - public AgentThreadFactory(final AgentThread agentThread) { - this.agentThread = agentThread; - } - - @Override - public Thread newThread(final Runnable runnable) { - return newAgentThread(agentThread, runnable); - } - - /** - * Constructs a new agent {@code Thread} as a daemon with a null ContextClassLoader. - * - * @param agentThread the agent thread to create. - * @param runnable work to run on the new thread. - */ - public static Thread newAgentThread(final AgentThread agentThread, final Runnable runnable) { - return newAgentThread(agentThread, null, runnable, true); - } - - public static Thread newAgentThread( - final AgentThread agentThread, final Runnable runnable, boolean daemon) { - return newAgentThread(agentThread, null, runnable, daemon); - } - - public static Thread newAgentThread( - final AgentThread agentThread, - final String nameSuffix, - final Runnable runnable, - boolean daemon) { - final String threadName = - nameSuffix != null ? agentThread.threadName + nameSuffix : agentThread.threadName; - final Thread thread = new Thread(AGENT_THREAD_GROUP, runnable, threadName); - thread.setDaemon(daemon); - thread.setContextClassLoader(null); - thread.setUncaughtExceptionHandler( - new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(final Thread thread, final Throwable e) { - LoggerFactory.getLogger(runnable.getClass()) - .error("Uncaught exception {} in {}", e, agentThread.threadName, e); - } + + private final AgentThread agentThread; + + /** + * Constructs a new agent {@code ThreadFactory}. + * + * @param agentThread the agent thread created by this factory. + */ + public AgentThreadFactory(final AgentThread agentThread) { + this.agentThread = agentThread; + } + + @Override + public Thread newThread(final Runnable runnable) { + return newAgentThread(agentThread, runnable); + } + + /** + * Constructs a new agent {@code Thread} as a daemon with a null ContextClassLoader. + * + * @param agentThread the agent thread to create. + * @param runnable work to run on the new thread. + */ + public static Thread newAgentThread(final AgentThread agentThread, final Runnable runnable) { + return newAgentThread(agentThread, null, runnable, true); + } + + public static Thread newAgentThread(final AgentThread agentThread, final Runnable runnable, boolean daemon) { + return newAgentThread(agentThread, null, runnable, daemon); + } + + public static Thread newAgentThread( + final AgentThread agentThread, final String nameSuffix, final Runnable runnable, boolean daemon) { + final String threadName = nameSuffix != null ? agentThread.threadName + nameSuffix : agentThread.threadName; + final Thread thread = new Thread(AGENT_THREAD_GROUP, runnable, threadName); + thread.setDaemon(daemon); + thread.setContextClassLoader(null); + thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(final Thread thread, final Throwable e) { + LoggerFactory.getLogger(runnable.getClass()) + .error("Uncaught exception {} in {}", e, agentThread.threadName, e); + } }); - return thread; - } + return thread; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/BitUtils.java b/internal-api/src/main/java/datadog/trace/util/BitUtils.java index 8426c4e1cc4..fc3cdd17461 100644 --- a/internal-api/src/main/java/datadog/trace/util/BitUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/BitUtils.java @@ -1,34 +1,34 @@ package datadog.trace.util; public final class BitUtils { - private BitUtils() {} + private BitUtils() {} - /** - * Returns the next power of two greater than or equal to the given value. If the input is zero or - * negative, this method returns 1; - * - * @param value the input value - * @return the next power of two ≥ {@code value} - */ - public static int nextPowerOfTwo(int value) { - // The next power of two for 0 or 1 is 1. - if (value <= 1) { - return 1; - } + /** + * Returns the next power of two greater than or equal to the given value. If the input is zero or + * negative, this method returns 1; + * + * @param value the input value + * @return the next power of two ≥ {@code value} + */ + public static int nextPowerOfTwo(int value) { + // The next power of two for 0 or 1 is 1. + if (value <= 1) { + return 1; + } - // Compute how many leading zero bits there are in (value - 1). This gives us information about - // where the highest set bit is. - int n = Integer.numberOfLeadingZeros(value - 1); + // Compute how many leading zero bits there are in (value - 1). This gives us information about + // where the highest set bit is. + int n = Integer.numberOfLeadingZeros(value - 1); - // -1 in two's complement = 0xFFFF_FFFF (all bits set to 1). Unsigned right-shifting by n: (-1 - // >>> n) produces a mask of (32 - n) one-bits. - int result = (-1 >>> n) + 1; + // -1 in two's complement = 0xFFFF_FFFF (all bits set to 1). Unsigned right-shifting by n: (-1 + // >>> n) produces a mask of (32 - n) one-bits. + int result = (-1 >>> n) + 1; - // If result overflowed clamp it to the largest unsigned power of two fitting an int. - if (result <= 0) { - return 1 << 30; - } + // If result overflowed clamp it to the largest unsigned power of two fitting an int. + if (result <= 0) { + return 1 << 30; + } - return result; - } + return result; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/CollectionUtils.java b/internal-api/src/main/java/datadog/trace/util/CollectionUtils.java index 58a7431d612..641773169dc 100644 --- a/internal-api/src/main/java/datadog/trace/util/CollectionUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/CollectionUtils.java @@ -17,103 +17,102 @@ public final class CollectionUtils { - private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); - private static final MethodHandle IMMUTABLE_COPY_OF_SET = findCopyOf(Set.class, Collection.class); - private static final MethodHandle IMMUTABLE_COPY_OF_LIST = - findCopyOf(List.class, Collection.class); - private static final MethodHandle IMMUTABLE_COPY_OF_MAP = findCopyOf(Map.class, Map.class); + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + private static final MethodHandle IMMUTABLE_COPY_OF_SET = findCopyOf(Set.class, Collection.class); + private static final MethodHandle IMMUTABLE_COPY_OF_LIST = findCopyOf(List.class, Collection.class); + private static final MethodHandle IMMUTABLE_COPY_OF_MAP = findCopyOf(Map.class, Map.class); - /** - * Converts the input to an immutable set if available on the current platform. Otherwise returns - * the input as a set. - * - * @param input the input. - * @param the type of the elements of the input. - * @return an immutable copy of the input if possible. - */ - @SuppressWarnings("unchecked") - public static Set tryMakeImmutableSet(Collection input) { - if (null != IMMUTABLE_COPY_OF_SET) { - try { - return (Set) IMMUTABLE_COPY_OF_SET.invokeExact(input); - } catch (Throwable ignore) { - } + /** + * Converts the input to an immutable set if available on the current platform. Otherwise returns + * the input as a set. + * + * @param input the input. + * @param the type of the elements of the input. + * @return an immutable copy of the input if possible. + */ + @SuppressWarnings("unchecked") + public static Set tryMakeImmutableSet(Collection input) { + if (null != IMMUTABLE_COPY_OF_SET) { + try { + return (Set) IMMUTABLE_COPY_OF_SET.invokeExact(input); + } catch (Throwable ignore) { + } + } + return input instanceof Set ? (Set) input : new HashSet<>(input); } - return input instanceof Set ? (Set) input : new HashSet<>(input); - } - /** - * Converts the input to an immutable list if available on the current platform. Otherwise returns - * the input as a list. - * - * @param input the input. - * @param the type of the elements of the input. - * @return an immutable copy of the input if possible. - */ - @SuppressWarnings("unchecked") - public static List tryMakeImmutableList(Collection input) { - if (null != IMMUTABLE_COPY_OF_LIST) { - try { - return (List) IMMUTABLE_COPY_OF_LIST.invokeExact(input); - } catch (Throwable ignore) { - } + /** + * Converts the input to an immutable list if available on the current platform. Otherwise returns + * the input as a list. + * + * @param input the input. + * @param the type of the elements of the input. + * @return an immutable copy of the input if possible. + */ + @SuppressWarnings("unchecked") + public static List tryMakeImmutableList(Collection input) { + if (null != IMMUTABLE_COPY_OF_LIST) { + try { + return (List) IMMUTABLE_COPY_OF_LIST.invokeExact(input); + } catch (Throwable ignore) { + } + } + return input instanceof List ? (List) input : new ArrayList<>(input); } - return input instanceof List ? (List) input : new ArrayList<>(input); - } - /** - * Converts the input to an immutable map if available on the current platform. Otherwise returns - * the input. - * - * @param input the input. - * @param the key type - * @param the value type - * @return an immutable copy of the input if possible. - */ - @SuppressWarnings("unchecked") - public static Map tryMakeImmutableMap(Map input) { - if (null != IMMUTABLE_COPY_OF_MAP) { - try { - return (Map) IMMUTABLE_COPY_OF_MAP.invokeExact(input); - } catch (Throwable ignore) { - } + /** + * Converts the input to an immutable map if available on the current platform. Otherwise returns + * the input. + * + * @param input the input. + * @param the key type + * @param the value type + * @return an immutable copy of the input if possible. + */ + @SuppressWarnings("unchecked") + public static Map tryMakeImmutableMap(Map input) { + if (null != IMMUTABLE_COPY_OF_MAP) { + try { + return (Map) IMMUTABLE_COPY_OF_MAP.invokeExact(input); + } catch (Throwable ignore) { + } + } + return input; } - return input; - } - private static MethodHandle findCopyOf(Class clazz, Class arg) { - if (isJavaVersionAtLeast(10)) { - try { - return LOOKUP.findStatic(clazz, "copyOf", MethodType.methodType(clazz, arg)); - } catch (NoSuchMethodException | IllegalAccessException ignore) { - } + private static MethodHandle findCopyOf(Class clazz, Class arg) { + if (isJavaVersionAtLeast(10)) { + try { + return LOOKUP.findStatic(clazz, "copyOf", MethodType.methodType(clazz, arg)); + } catch (NoSuchMethodException | IllegalAccessException ignore) { + } + } + return null; } - return null; - } - /** Appends value to an array, growing it as needed; treats null arrays as empty. */ - public static T[] appendToArray(@Nullable T[] array, T value) { - T[] appended; - if (array == null) { - //noinspection unchecked - appended = (T[]) Array.newInstance(value.getClass(), 1); - appended[0] = value; - } else { - appended = Arrays.copyOf(array, array.length + 1); - appended[array.length] = value; + /** Appends value to an array, growing it as needed; treats null arrays as empty. */ + public static T[] appendToArray(@Nullable T[] array, T value) { + T[] appended; + if (array == null) { + //noinspection unchecked + appended = (T[]) Array.newInstance(value.getClass(), 1); + appended[0] = value; + } else { + appended = Arrays.copyOf(array, array.length + 1); + appended[array.length] = value; + } + return appended; } - return appended; - } - /** Checks whether an array contains an element; treats null arrays as empty. */ - public static boolean arrayContains(@Nullable T[] array, T value) { - if (array != null) { - for (T element : array) { - if (value.equals(element)) { - return true; + /** Checks whether an array contains an element; treats null arrays as empty. */ + public static boolean arrayContains(@Nullable T[] array, T value) { + if (array != null) { + for (T element : array) { + if (value.equals(element)) { + return true; + } + } } - } + return false; } - return false; - } } diff --git a/internal-api/src/main/java/datadog/trace/util/ComparableVersion.java b/internal-api/src/main/java/datadog/trace/util/ComparableVersion.java index 147607c71f0..590832f6732 100644 --- a/internal-api/src/main/java/datadog/trace/util/ComparableVersion.java +++ b/internal-api/src/main/java/datadog/trace/util/ComparableVersion.java @@ -12,512 +12,512 @@ // backported from org.apache.maven:maven-artifact:3.9.9 public class ComparableVersion implements Comparable { - private static final int MAX_INTITEM_LENGTH = 9; - private static final int MAX_LONGITEM_LENGTH = 18; - private String value; - private String canonical; - private ListItem items; - - public ComparableVersion(String version) { - this.parseVersion(version); - } - - public final void parseVersion(String version) { - this.value = version; - this.items = new ListItem(); - version = version.toLowerCase(Locale.ENGLISH); - ListItem list = this.items; - Deque stack = new ArrayDeque(); - stack.push(list); - boolean isDigit = false; - int startIndex = 0; - - for (int i = 0; i < version.length(); ++i) { - char c = version.charAt(i); - if (c == '.') { - if (i == startIndex) { - list.add(ComparableVersion.IntItem.ZERO); - } else { - list.add(parseItem(isDigit, version.substring(startIndex, i))); + private static final int MAX_INTITEM_LENGTH = 9; + private static final int MAX_LONGITEM_LENGTH = 18; + private String value; + private String canonical; + private ListItem items; + + public ComparableVersion(String version) { + this.parseVersion(version); + } + + public final void parseVersion(String version) { + this.value = version; + this.items = new ListItem(); + version = version.toLowerCase(Locale.ENGLISH); + ListItem list = this.items; + Deque stack = new ArrayDeque(); + stack.push(list); + boolean isDigit = false; + int startIndex = 0; + + for (int i = 0; i < version.length(); ++i) { + char c = version.charAt(i); + if (c == '.') { + if (i == startIndex) { + list.add(ComparableVersion.IntItem.ZERO); + } else { + list.add(parseItem(isDigit, version.substring(startIndex, i))); + } + + startIndex = i + 1; + } else if (c == '-') { + if (i == startIndex) { + list.add(ComparableVersion.IntItem.ZERO); + } else { + list.add(parseItem(isDigit, version.substring(startIndex, i))); + } + + startIndex = i + 1; + list.add(list = new ListItem()); + stack.push(list); + } else if (Character.isDigit(c)) { + if (!isDigit && i > startIndex) { + if (!list.isEmpty()) { + list.add(list = new ListItem()); + stack.push(list); + } + + list.add(new StringItem(version.substring(startIndex, i), true)); + startIndex = i; + list.add(list = new ListItem()); + stack.push(list); + } + + isDigit = true; + } else { + if (isDigit && i > startIndex) { + list.add(parseItem(true, version.substring(startIndex, i))); + startIndex = i; + list.add(list = new ListItem()); + stack.push(list); + } + + isDigit = false; + } } - startIndex = i + 1; - } else if (c == '-') { - if (i == startIndex) { - list.add(ComparableVersion.IntItem.ZERO); - } else { - list.add(parseItem(isDigit, version.substring(startIndex, i))); + if (version.length() > startIndex) { + if (!isDigit && !list.isEmpty()) { + list.add(list = new ListItem()); + stack.push(list); + } + + list.add(parseItem(isDigit, version.substring(startIndex))); } - startIndex = i + 1; - list.add(list = new ListItem()); - stack.push(list); - } else if (Character.isDigit(c)) { - if (!isDigit && i > startIndex) { - if (!list.isEmpty()) { - list.add(list = new ListItem()); - stack.push(list); - } - - list.add(new StringItem(version.substring(startIndex, i), true)); - startIndex = i; - list.add(list = new ListItem()); - stack.push(list); - } - - isDigit = true; - } else { - if (isDigit && i > startIndex) { - list.add(parseItem(true, version.substring(startIndex, i))); - startIndex = i; - list.add(list = new ListItem()); - stack.push(list); - } - - isDigit = false; - } + while (!stack.isEmpty()) { + list = (ListItem) stack.pop(); + list.normalize(); + } } - if (version.length() > startIndex) { - if (!isDigit && !list.isEmpty()) { - list.add(list = new ListItem()); - stack.push(list); - } - - list.add(parseItem(isDigit, version.substring(startIndex))); + private static Item parseItem(boolean isDigit, String buf) { + if (isDigit) { + buf = stripLeadingZeroes(buf); + if (buf.length() <= 9) { + return new IntItem(buf); + } else { + return buf.length() <= 18 ? new LongItem(buf) : new BigIntegerItem(buf); + } + } else { + return new StringItem(buf, false); + } } - while (!stack.isEmpty()) { - list = (ListItem) stack.pop(); - list.normalize(); - } - } - - private static Item parseItem(boolean isDigit, String buf) { - if (isDigit) { - buf = stripLeadingZeroes(buf); - if (buf.length() <= 9) { - return new IntItem(buf); - } else { - return buf.length() <= 18 ? new LongItem(buf) : new BigIntegerItem(buf); - } - } else { - return new StringItem(buf, false); - } - } + private static String stripLeadingZeroes(String buf) { + if (buf != null && !buf.isEmpty()) { + for (int i = 0; i < buf.length(); ++i) { + char c = buf.charAt(i); + if (c != '0') { + return buf.substring(i); + } + } - private static String stripLeadingZeroes(String buf) { - if (buf != null && !buf.isEmpty()) { - for (int i = 0; i < buf.length(); ++i) { - char c = buf.charAt(i); - if (c != '0') { - return buf.substring(i); + return buf; + } else { + return "0"; } - } - - return buf; - } else { - return "0"; } - } - - public int compareTo(ComparableVersion o) { - return this.items.compareTo(o.items); - } - - public String toString() { - return this.value; - } - public String getCanonical() { - if (this.canonical == null) { - this.canonical = this.items.toString(); + public int compareTo(ComparableVersion o) { + return this.items.compareTo(o.items); } - return this.canonical; - } - - public boolean equals(Object o) { - return o instanceof ComparableVersion && this.items.equals(((ComparableVersion) o).items); - } - - public int hashCode() { - return this.items.hashCode(); - } + public String toString() { + return this.value; + } - private static class IntItem implements Item { - private final int value; - public static final IntItem ZERO = new IntItem(); + public String getCanonical() { + if (this.canonical == null) { + this.canonical = this.items.toString(); + } - private IntItem() { - this.value = 0; + return this.canonical; } - IntItem(String str) { - this.value = Integer.parseInt(str); + public boolean equals(Object o) { + return o instanceof ComparableVersion && this.items.equals(((ComparableVersion) o).items); } - public int getType() { - return 3; + public int hashCode() { + return this.items.hashCode(); } - public boolean isNull() { - return this.value == 0; - } + private static class IntItem implements Item { + private final int value; + public static final IntItem ZERO = new IntItem(); - public int compareTo(Item item) { - if (item == null) { - return this.value == 0 ? 0 : 1; - } else { - switch (item.getType()) { - case 0: - case 4: - return -1; - case 1: - return 1; - case 2: - return 1; - case 3: - int itemValue = ((IntItem) item).value; - return this.value < itemValue ? -1 : (this.value == itemValue ? 0 : 1); - default: - throw new IllegalStateException("invalid item: " + item.getClass()); + private IntItem() { + this.value = 0; } - } - } - public boolean equals(Object o) { - if (this == o) { - return true; - } else if (o != null && this.getClass() == o.getClass()) { - IntItem intItem = (IntItem) o; - return this.value == intItem.value; - } else { - return false; - } - } + IntItem(String str) { + this.value = Integer.parseInt(str); + } - public int hashCode() { - return this.value; - } + public int getType() { + return 3; + } - public String toString() { - return Integer.toString(this.value); - } - } + public boolean isNull() { + return this.value == 0; + } - private static class LongItem implements Item { - private final long value; + public int compareTo(Item item) { + if (item == null) { + return this.value == 0 ? 0 : 1; + } else { + switch (item.getType()) { + case 0: + case 4: + return -1; + case 1: + return 1; + case 2: + return 1; + case 3: + int itemValue = ((IntItem) item).value; + return this.value < itemValue ? -1 : (this.value == itemValue ? 0 : 1); + default: + throw new IllegalStateException("invalid item: " + item.getClass()); + } + } + } - LongItem(String str) { - this.value = Long.parseLong(str); - } + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o != null && this.getClass() == o.getClass()) { + IntItem intItem = (IntItem) o; + return this.value == intItem.value; + } else { + return false; + } + } - public int getType() { - return 4; - } + public int hashCode() { + return this.value; + } - public boolean isNull() { - return this.value == 0L; + public String toString() { + return Integer.toString(this.value); + } } - public int compareTo(Item item) { - if (item == null) { - return this.value == 0L ? 0 : 1; - } else { - switch (item.getType()) { - case 0: - return -1; - case 1: - return 1; - case 2: - return 1; - case 3: - return 1; - case 4: - long itemValue = ((LongItem) item).value; - return this.value < itemValue ? -1 : (this.value == itemValue ? 0 : 1); - default: - throw new IllegalStateException("invalid item: " + item.getClass()); + private static class LongItem implements Item { + private final long value; + + LongItem(String str) { + this.value = Long.parseLong(str); } - } - } - public boolean equals(Object o) { - if (this == o) { - return true; - } else if (o != null && this.getClass() == o.getClass()) { - LongItem longItem = (LongItem) o; - return this.value == longItem.value; - } else { - return false; - } - } + public int getType() { + return 4; + } - public int hashCode() { - return (int) (this.value ^ this.value >>> 32); - } + public boolean isNull() { + return this.value == 0L; + } - public String toString() { - return Long.toString(this.value); - } - } + public int compareTo(Item item) { + if (item == null) { + return this.value == 0L ? 0 : 1; + } else { + switch (item.getType()) { + case 0: + return -1; + case 1: + return 1; + case 2: + return 1; + case 3: + return 1; + case 4: + long itemValue = ((LongItem) item).value; + return this.value < itemValue ? -1 : (this.value == itemValue ? 0 : 1); + default: + throw new IllegalStateException("invalid item: " + item.getClass()); + } + } + } - private static class BigIntegerItem implements Item { - private final BigInteger value; + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o != null && this.getClass() == o.getClass()) { + LongItem longItem = (LongItem) o; + return this.value == longItem.value; + } else { + return false; + } + } - BigIntegerItem(String str) { - this.value = new BigInteger(str); - } + public int hashCode() { + return (int) (this.value ^ this.value >>> 32); + } - public int getType() { - return 0; + public String toString() { + return Long.toString(this.value); + } } - public boolean isNull() { - return BigInteger.ZERO.equals(this.value); - } + private static class BigIntegerItem implements Item { + private final BigInteger value; - public int compareTo(Item item) { - if (item == null) { - return BigInteger.ZERO.equals(this.value) ? 0 : 1; - } else { - switch (item.getType()) { - case 0: - return this.value.compareTo(((BigIntegerItem) item).value); - case 1: - return 1; - case 2: - return 1; - case 3: - case 4: - return 1; - default: - throw new IllegalStateException("invalid item: " + item.getClass()); + BigIntegerItem(String str) { + this.value = new BigInteger(str); } - } - } - public boolean equals(Object o) { - if (this == o) { - return true; - } else if (o != null && this.getClass() == o.getClass()) { - BigIntegerItem that = (BigIntegerItem) o; - return this.value.equals(that.value); - } else { - return false; - } - } + public int getType() { + return 0; + } - public int hashCode() { - return this.value.hashCode(); - } + public boolean isNull() { + return BigInteger.ZERO.equals(this.value); + } - public String toString() { - return this.value.toString(); - } - } - - private static class StringItem implements Item { - private static final List QUALIFIERS = - Arrays.asList("alpha", "beta", "milestone", "rc", "snapshot", "", "sp"); - private static final Properties ALIASES = new Properties(); - private static final String RELEASE_VERSION_INDEX; - private final String value; - - StringItem(String value, boolean followedByDigit) { - if (followedByDigit && value.length() == 1) { - switch (value.charAt(0)) { - case 'a': - value = "alpha"; - break; - case 'b': - value = "beta"; - break; - case 'm': - value = "milestone"; - break; - default: - break; - } - } - - this.value = ALIASES.getProperty(value, value); - } + public int compareTo(Item item) { + if (item == null) { + return BigInteger.ZERO.equals(this.value) ? 0 : 1; + } else { + switch (item.getType()) { + case 0: + return this.value.compareTo(((BigIntegerItem) item).value); + case 1: + return 1; + case 2: + return 1; + case 3: + case 4: + return 1; + default: + throw new IllegalStateException("invalid item: " + item.getClass()); + } + } + } - public int getType() { - return 1; - } + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o != null && this.getClass() == o.getClass()) { + BigIntegerItem that = (BigIntegerItem) o; + return this.value.equals(that.value); + } else { + return false; + } + } - public boolean isNull() { - return comparableQualifier(this.value).compareTo(RELEASE_VERSION_INDEX) == 0; - } + public int hashCode() { + return this.value.hashCode(); + } - public static String comparableQualifier(String qualifier) { - int i = QUALIFIERS.indexOf(qualifier); - return i == -1 ? QUALIFIERS.size() + "-" + qualifier : String.valueOf(i); + public String toString() { + return this.value.toString(); + } } - public int compareTo(Item item) { - if (item == null) { - return comparableQualifier(this.value).compareTo(RELEASE_VERSION_INDEX); - } else { - switch (item.getType()) { - case 0: - case 3: - case 4: - return -1; - case 1: - return comparableQualifier(this.value) - .compareTo(comparableQualifier(((StringItem) item).value)); - case 2: - return -1; - default: - throw new IllegalStateException("invalid item: " + item.getClass()); - } - } - } + private static class StringItem implements Item { + private static final List QUALIFIERS = + Arrays.asList("alpha", "beta", "milestone", "rc", "snapshot", "", "sp"); + private static final Properties ALIASES = new Properties(); + private static final String RELEASE_VERSION_INDEX; + private final String value; + + StringItem(String value, boolean followedByDigit) { + if (followedByDigit && value.length() == 1) { + switch (value.charAt(0)) { + case 'a': + value = "alpha"; + break; + case 'b': + value = "beta"; + break; + case 'm': + value = "milestone"; + break; + default: + break; + } + } - public boolean equals(Object o) { - if (this == o) { - return true; - } else if (o != null && this.getClass() == o.getClass()) { - StringItem that = (StringItem) o; - return this.value.equals(that.value); - } else { - return false; - } - } + this.value = ALIASES.getProperty(value, value); + } - public int hashCode() { - return this.value.hashCode(); - } + public int getType() { + return 1; + } - public String toString() { - return this.value; - } + public boolean isNull() { + return comparableQualifier(this.value).compareTo(RELEASE_VERSION_INDEX) == 0; + } - static { - ALIASES.put("ga", ""); - ALIASES.put("final", ""); - ALIASES.put("release", ""); - ALIASES.put("cr", "rc"); - RELEASE_VERSION_INDEX = String.valueOf(QUALIFIERS.indexOf("")); - } - } + public static String comparableQualifier(String qualifier) { + int i = QUALIFIERS.indexOf(qualifier); + return i == -1 ? QUALIFIERS.size() + "-" + qualifier : String.valueOf(i); + } - private static class ListItem extends ArrayList implements Item { - private ListItem() { - super(); - } + public int compareTo(Item item) { + if (item == null) { + return comparableQualifier(this.value).compareTo(RELEASE_VERSION_INDEX); + } else { + switch (item.getType()) { + case 0: + case 3: + case 4: + return -1; + case 1: + return comparableQualifier(this.value) + .compareTo(comparableQualifier(((StringItem) item).value)); + case 2: + return -1; + default: + throw new IllegalStateException("invalid item: " + item.getClass()); + } + } + } - public int getType() { - return 2; - } + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o != null && this.getClass() == o.getClass()) { + StringItem that = (StringItem) o; + return this.value.equals(that.value); + } else { + return false; + } + } - public boolean isNull() { - return this.size() == 0; - } + public int hashCode() { + return this.value.hashCode(); + } + + public String toString() { + return this.value; + } - void normalize() { - for (int i = this.size() - 1; i >= 0; --i) { - Item lastItem = this.get(i); - if (lastItem.isNull()) { - this.remove(i); - } else if (!(lastItem instanceof ListItem)) { - break; + static { + ALIASES.put("ga", ""); + ALIASES.put("final", ""); + ALIASES.put("release", ""); + ALIASES.put("cr", "rc"); + RELEASE_VERSION_INDEX = String.valueOf(QUALIFIERS.indexOf("")); } - } } - public int compareTo(Item item) { - if (item == null) { - if (this.size() == 0) { - return 0; - } else { - for (Item i : this) { - int result = i.compareTo(null); - if (result != 0) { - return result; - } - } + private static class ListItem extends ArrayList implements Item { + private ListItem() { + super(); + } - return 0; + public int getType() { + return 2; } - } else { - switch (item.getType()) { - case 0: - case 3: - case 4: - return -1; - case 1: - return 1; - case 2: - Iterator left = this.iterator(); - Iterator right = ((ListItem) item).iterator(); - - while (left.hasNext() || right.hasNext()) { - Item l = left.hasNext() ? left.next() : null; - Item r = right.hasNext() ? right.next() : null; - int result = l == null ? (r == null ? 0 : -1 * r.compareTo(l)) : l.compareTo(r); - if (result != 0) { - return result; - } - } - return 0; - default: - throw new IllegalStateException("invalid item: " + item.getClass()); + public boolean isNull() { + return this.size() == 0; } - } - } - public String toString() { - StringBuilder buffer = new StringBuilder(); + void normalize() { + for (int i = this.size() - 1; i >= 0; --i) { + Item lastItem = this.get(i); + if (lastItem.isNull()) { + this.remove(i); + } else if (!(lastItem instanceof ListItem)) { + break; + } + } + } - for (Item item : this) { - if (buffer.length() > 0) { - buffer.append(item instanceof ListItem ? '-' : '.'); + public int compareTo(Item item) { + if (item == null) { + if (this.size() == 0) { + return 0; + } else { + for (Item i : this) { + int result = i.compareTo(null); + if (result != 0) { + return result; + } + } + + return 0; + } + } else { + switch (item.getType()) { + case 0: + case 3: + case 4: + return -1; + case 1: + return 1; + case 2: + Iterator left = this.iterator(); + Iterator right = ((ListItem) item).iterator(); + + while (left.hasNext() || right.hasNext()) { + Item l = left.hasNext() ? left.next() : null; + Item r = right.hasNext() ? right.next() : null; + int result = l == null ? (r == null ? 0 : -1 * r.compareTo(l)) : l.compareTo(r); + if (result != 0) { + return result; + } + } + + return 0; + default: + throw new IllegalStateException("invalid item: " + item.getClass()); + } + } } - buffer.append(item); - } + public String toString() { + StringBuilder buffer = new StringBuilder(); - return buffer.toString(); - } + for (Item item : this) { + if (buffer.length() > 0) { + buffer.append(item instanceof ListItem ? '-' : '.'); + } - private String toListString() { - StringBuilder buffer = new StringBuilder(); - buffer.append('['); + buffer.append(item); + } - for (Item item : this) { - if (buffer.length() > 1) { - buffer.append(", "); + return buffer.toString(); } - if (item instanceof ListItem) { - buffer.append(((ListItem) item).toListString()); - } else { - buffer.append(item); - } - } + private String toListString() { + StringBuilder buffer = new StringBuilder(); + buffer.append('['); + + for (Item item : this) { + if (buffer.length() > 1) { + buffer.append(", "); + } - buffer.append(']'); - return buffer.toString(); + if (item instanceof ListItem) { + buffer.append(((ListItem) item).toListString()); + } else { + buffer.append(item); + } + } + + buffer.append(']'); + return buffer.toString(); + } } - } - private interface Item { - int INT_ITEM = 3; - int LONG_ITEM = 4; - int BIGINTEGER_ITEM = 0; - int STRING_ITEM = 1; - int LIST_ITEM = 2; + private interface Item { + int INT_ITEM = 3; + int LONG_ITEM = 4; + int BIGINTEGER_ITEM = 0; + int STRING_ITEM = 1; + int LIST_ITEM = 2; - int compareTo(Item var1); + int compareTo(Item var1); - int getType(); + int getType(); - boolean isNull(); - } + boolean isNull(); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentEnumMap.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentEnumMap.java index 1fcc3440686..c671adffb36 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentEnumMap.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentEnumMap.java @@ -13,175 +13,175 @@ public class ConcurrentEnumMap, V> implements Map { - private final K[] enumConstants; - private final AtomicReferenceArray values; - - public ConcurrentEnumMap(Class enumClass) { - this.enumConstants = enumClass.getEnumConstants(); - this.values = new AtomicReferenceArray<>(enumConstants.length); - } - - private int indexOf(Object key) { - if (key == null) { - throw new NullPointerException("Key must not be null"); - } - if (key.getClass() != enumConstants[0].getDeclaringClass()) { - throw new IllegalArgumentException("Key has wrong enum type"); - } - @SuppressWarnings("unchecked") - K castKey = (K) key; - return castKey.ordinal(); - } - - @Override - public V get(Object key) { - int idx = indexOf(key); - return values.get(idx); - } - - @Override - public V put(K key, V value) { - int idx = indexOf(key); - return values.getAndSet(idx, value); - } - - @Override - public void putAll(@Nonnull Map m) { - for (Map.Entry e : m.entrySet()) { - put(e.getKey(), e.getValue()); - } - } - - @Override - public V remove(Object key) { - int idx = indexOf(key); - return values.getAndSet(idx, null); - } - - @Override - public V putIfAbsent(@Nonnull K key, V value) { - int idx = indexOf(key); - while (true) { - V existing = values.get(idx); - if (existing != null) { - return existing; - } - if (values.compareAndSet(idx, null, value)) { - return null; - } - } - } - - @Override - public boolean remove(@Nonnull Object key, Object value) { - int idx = indexOf(key); - V cur = values.get(idx); - if (cur == null || !cur.equals(value)) { - return false; - } - return values.compareAndSet(idx, cur, null); - } - - @Override - public boolean replace(@Nonnull K key, @Nonnull V oldValue, @Nonnull V newValue) { - int idx = indexOf(key); - return values.compareAndSet(idx, oldValue, newValue); - } - - @Override - public V replace(@Nonnull K key, @Nonnull V value) { - int idx = indexOf(key); - while (true) { - V cur = values.get(idx); - if (cur == null) { - return null; - } - if (values.compareAndSet(idx, cur, value)) { - return cur; - } - } - } - - @Override - public int size() { - int count = 0; - for (int i = 0; i < values.length(); i++) { - if (values.get(i) != null) { - count++; - } - } - return count; - } - - @Override - public boolean isEmpty() { - for (int i = 0; i < values.length(); i++) { - if (values.get(i) != null) { - return false; - } + private final K[] enumConstants; + private final AtomicReferenceArray values; + + public ConcurrentEnumMap(Class enumClass) { + this.enumConstants = enumClass.getEnumConstants(); + this.values = new AtomicReferenceArray<>(enumConstants.length); + } + + private int indexOf(Object key) { + if (key == null) { + throw new NullPointerException("Key must not be null"); + } + if (key.getClass() != enumConstants[0].getDeclaringClass()) { + throw new IllegalArgumentException("Key has wrong enum type"); + } + @SuppressWarnings("unchecked") + K castKey = (K) key; + return castKey.ordinal(); + } + + @Override + public V get(Object key) { + int idx = indexOf(key); + return values.get(idx); + } + + @Override + public V put(K key, V value) { + int idx = indexOf(key); + return values.getAndSet(idx, value); + } + + @Override + public void putAll(@Nonnull Map m) { + for (Map.Entry e : m.entrySet()) { + put(e.getKey(), e.getValue()); + } } - return true; - } - @Override - public boolean containsKey(Object key) { - return get(key) != null; - } + @Override + public V remove(Object key) { + int idx = indexOf(key); + return values.getAndSet(idx, null); + } - @Override - public boolean containsValue(Object value) { - for (int i = 0; i < values.length(); i++) { - V v = values.get(i); - if (v != null && v.equals(value)) { + @Override + public V putIfAbsent(@Nonnull K key, V value) { + int idx = indexOf(key); + while (true) { + V existing = values.get(idx); + if (existing != null) { + return existing; + } + if (values.compareAndSet(idx, null, value)) { + return null; + } + } + } + + @Override + public boolean remove(@Nonnull Object key, Object value) { + int idx = indexOf(key); + V cur = values.get(idx); + if (cur == null || !cur.equals(value)) { + return false; + } + return values.compareAndSet(idx, cur, null); + } + + @Override + public boolean replace(@Nonnull K key, @Nonnull V oldValue, @Nonnull V newValue) { + int idx = indexOf(key); + return values.compareAndSet(idx, oldValue, newValue); + } + + @Override + public V replace(@Nonnull K key, @Nonnull V value) { + int idx = indexOf(key); + while (true) { + V cur = values.get(idx); + if (cur == null) { + return null; + } + if (values.compareAndSet(idx, cur, value)) { + return cur; + } + } + } + + @Override + public int size() { + int count = 0; + for (int i = 0; i < values.length(); i++) { + if (values.get(i) != null) { + count++; + } + } + return count; + } + + @Override + public boolean isEmpty() { + for (int i = 0; i < values.length(); i++) { + if (values.get(i) != null) { + return false; + } + } return true; - } - } - return false; - } - - @Override - public void clear() { - for (int i = 0; i < values.length(); i++) { - values.set(i, null); - } - } - - @Nonnull - @Override - public Set keySet() { - Set keys = EnumSet.noneOf(enumConstants[0].getDeclaringClass()); - for (int i = 0; i < values.length(); i++) { - if (values.get(i) != null) { - keys.add(enumConstants[i]); - } - } - return Collections.unmodifiableSet(keys); - } - - @Nonnull - @Override - public Collection values() { - List list = new ArrayList<>(); - for (int i = 0; i < values.length(); i++) { - V value = values.get(i); - if (value != null) { - list.add(value); - } - } - return Collections.unmodifiableList(list); - } - - @Nonnull - @Override - public Set> entrySet() { - Set> entrySet = new java.util.HashSet<>(); - for (int i = 0; i < enumConstants.length; i++) { - V value = values.get(i); - if (value != null) { - K key = enumConstants[i]; - entrySet.add(new AbstractMap.SimpleEntry<>(key, value)); - } - } - return Collections.unmodifiableSet(entrySet); - } + } + + @Override + public boolean containsKey(Object key) { + return get(key) != null; + } + + @Override + public boolean containsValue(Object value) { + for (int i = 0; i < values.length(); i++) { + V v = values.get(i); + if (v != null && v.equals(value)) { + return true; + } + } + return false; + } + + @Override + public void clear() { + for (int i = 0; i < values.length(); i++) { + values.set(i, null); + } + } + + @Nonnull + @Override + public Set keySet() { + Set keys = EnumSet.noneOf(enumConstants[0].getDeclaringClass()); + for (int i = 0; i < values.length(); i++) { + if (values.get(i) != null) { + keys.add(enumConstants[i]); + } + } + return Collections.unmodifiableSet(keys); + } + + @Nonnull + @Override + public Collection values() { + List list = new ArrayList<>(); + for (int i = 0; i < values.length(); i++) { + V value = values.get(i); + if (value != null) { + list.add(value); + } + } + return Collections.unmodifiableList(list); + } + + @Nonnull + @Override + public Set> entrySet() { + Set> entrySet = new java.util.HashSet<>(); + for (int i = 0; i < enumConstants.length; i++) { + V value = values.get(i); + if (value != null) { + K key = enumConstants[i]; + entrySet.add(new AbstractMap.SimpleEntry<>(key, value)); + } + } + return Collections.unmodifiableSet(entrySet); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java index f860b6ad458..94e4caca213 100644 --- a/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/ConcurrentHashtable.java @@ -63,2014 +63,1976 @@ * (resets -- {@code FlatHashtable}). */ public final class ConcurrentHashtable { - private ConcurrentHashtable() {} - - /** - * Internal base class for concurrent entries. Stores the precomputed 64-bit keyHash and a {@code - * volatile} chain-next pointer used to link colliding entries within a single bucket. - * - *

The {@code next} pointer is {@code volatile} (unlike {@link Hashtable.Entry}) so that chain - * splices performed by {@link D1#remove}/{@link D2#remove} are visible to lock-free readers. - * - *

Subclasses add the key field(s) and a {@code matches(...)} method tailored to their key - * arity. See {@link D1.Entry} and {@link D2.Entry}; for higher arities, or for primitive key - * components, subclass this directly and drive the table with the static building blocks on - * {@link ConcurrentHashtable}. - * - *

The self-bound type parameter ({@code TEntry extends Entry}, mirroring {@code Enum>}) exists so {@link #matches(Entry)} can compare two already-built entries - * without an {@code Object}/{@code instanceof} boundary. It requires every concrete subclass to - * declare itself as its own type argument (see {@link D1.Entry}/{@link D2.Entry}); Java has no - * true {@code Self} type, so this is enforced by convention, not the compiler. - */ - public abstract static class Entry> { - public final long keyHash; - private volatile TEntry next = null; - - protected Entry(long keyHash) { - this.keyHash = keyHash; - } - - // Package-private: the only writers are the static insert/remove building blocks - // (insertHeadEntry, unlink) on the enclosing class, which reach it via the Entry bound. Custom - // tables mutate chains through those helpers, never by touching next directly. - final void setNext(TEntry next) { - this.next = next; - } - - @Nullable - public final TEntry next() { - return this.next; - } + private ConcurrentHashtable() {} /** - * Returns {@code true} if {@code other} is logically the same entry as this one (same key(s)), - * used to compare two already-built entries under the write lock (e.g. in {@link - * Reservation#tryGetOrInsertOrNull}). Deliberately narrower than {@code equals}/{@code - * hashCode}: this class doesn't need reflexive/symmetric-with-null-and-unrelated-types contract - * baggage, and a bespoke method avoids entries accidentally working as {@code HashSet}/{@code - * HashMap} keys via an unrelated identity notion. {@link D1.Entry}/{@link D2.Entry} implement - * this in terms of their existing key-based {@code matches(...)}, so most callers never write - * it directly. - */ - public abstract boolean matches(@Nonnull TEntry other); - } - - /** - * Single-key concurrent hash table. Lock-free on hit; locked on miss/mutation. - * - * @param the key type - * @param the user's {@link D1.Entry D1.Entry<K>} subclass - */ - @ThreadSafe - public static final class D1> { - - /** - * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in - * place after retrieving the entry via {@link D1#get}. + * Internal base class for concurrent entries. Stores the precomputed 64-bit keyHash and a {@code + * volatile} chain-next pointer used to link colliding entries within a single bucket. * - *

Deliberately parameterized on {@code K} alone, not self-bound on the concrete subclass: - * {@link D1} stores and links entries internally as {@code Entry} and casts back to {@code - * TEntry} at its API boundary, trading one unchecked cast (always sound -- the table only ever - * holds instances the caller's own {@code creator} produced) for a simpler subclass signature, - * e.g. {@code class MyEntry extends D1.Entry} rather than {@code D1.Entry}. + *

The {@code next} pointer is {@code volatile} (unlike {@link Hashtable.Entry}) so that chain + * splices performed by {@link D1#remove}/{@link D2#remove} are visible to lock-free readers. * - * @param the key type + *

Subclasses add the key field(s) and a {@code matches(...)} method tailored to their key + * arity. See {@link D1.Entry} and {@link D2.Entry}; for higher arities, or for primitive key + * components, subclass this directly and drive the table with the static building blocks on + * {@link ConcurrentHashtable}. + * + *

The self-bound type parameter ({@code TEntry extends Entry}, mirroring {@code Enum>}) exists so {@link #matches(Entry)} can compare two already-built entries + * without an {@code Object}/{@code instanceof} boundary. It requires every concrete subclass to + * declare itself as its own type argument (see {@link D1.Entry}/{@link D2.Entry}); Java has no + * true {@code Self} type, so this is enforced by convention, not the compiler. */ - public abstract static class Entry extends ConcurrentHashtable.Entry> { - @Nullable final K key; - - protected Entry(@Nullable K key) { - super(hash(key)); - this.key = key; - } - - /** The key this entry was created with. */ - @Nullable - public K key() { - return this.key; - } - - public boolean matches(@Nullable Object key) { - // equals() on the lookup param, not the field, so the JIT can devirtualize it once - // matches() inlines into get/getOrCreate (the caller's key type is known there). - return Objects.equals(key, this.key); - } - - /** {@link ConcurrentHashtable.Entry#matches(Entry)} in terms of the key-based overload. */ - @Override - public final boolean matches(@Nonnull Entry other) { - return matches(other.key); - } - - /** - * Returns the 64-bit lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so - * they don't collide with a real key that hashes to 0; real-key collisions in chains are - * resolved by {@link #matches(Object)}. - */ - public static long hash(@Nullable Object key) { - return (key == null) ? Long.MIN_VALUE : key.hashCode(); - } - } + public abstract static class Entry> { + public final long keyHash; + private volatile TEntry next = null; - private final State> state; + protected Entry(long keyHash) { + this.keyHash = keyHash; + } - private D1(State> state) { - this.state = state; - } + // Package-private: the only writers are the static insert/remove building blocks + // (insertHeadEntry, unlink) on the enclosing class, which reach it via the Entry bound. Custom + // tables mutate chains through those helpers, never by touching next directly. + final void setNext(TEntry next) { + this.next = next; + } - /** - * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is - * used only to infer {@code TEntry}; entries are created by the {@code creator} functions - * passed to the insertion methods. The table does not resize. - */ - @Nonnull - @SuppressWarnings("unchecked") - public static > D1 createBounded( - @Nonnull Class entryClass, int maxCapacity) { - // entryClass is erased away (see ConcurrentHashtable#createFixedBuckets), so treating it as - // Class> instead of the caller's concrete Class is safe. - Class> baseEntryClass = (Class>) (Class) entryClass; - return new D1<>(ConcurrentHashtable.createBounded(baseEntryClass, maxCapacity)); + @Nullable + public final TEntry next() { + return this.next; + } + + /** + * Returns {@code true} if {@code other} is logically the same entry as this one (same key(s)), + * used to compare two already-built entries under the write lock (e.g. in {@link + * Reservation#tryGetOrInsertOrNull}). Deliberately narrower than {@code equals}/{@code + * hashCode}: this class doesn't need reflexive/symmetric-with-null-and-unrelated-types contract + * baggage, and a bespoke method avoids entries accidentally working as {@code HashSet}/{@code + * HashMap} keys via an unrelated identity notion. {@link D1.Entry}/{@link D2.Entry} implement + * this in terms of their existing key-based {@code matches(...)}, so most callers never write + * it directly. + */ + public abstract boolean matches(@Nonnull TEntry other); } /** - * Sound because every entry ever inserted into {@link #state} was produced as a {@code TEntry}. + * Single-key concurrent hash table. Lock-free on hit; locked on miss/mutation. + * + * @param the key type + * @param the user's {@link D1.Entry D1.Entry<K>} subclass */ - @SuppressWarnings("unchecked") - private TEntry cast(@Nullable Entry entry) { - return (TEntry) entry; - } + @ThreadSafe + public static final class D1> { + + /** + * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in + * place after retrieving the entry via {@link D1#get}. + * + *

Deliberately parameterized on {@code K} alone, not self-bound on the concrete subclass: + * {@link D1} stores and links entries internally as {@code Entry} and casts back to {@code + * TEntry} at its API boundary, trading one unchecked cast (always sound -- the table only ever + * holds instances the caller's own {@code creator} produced) for a simpler subclass signature, + * e.g. {@code class MyEntry extends D1.Entry} rather than {@code D1.Entry}. + * + * @param the key type + */ + public abstract static class Entry extends ConcurrentHashtable.Entry> { + @Nullable + final K key; + + protected Entry(@Nullable K key) { + super(hash(key)); + this.key = key; + } + + /** The key this entry was created with. */ + @Nullable + public K key() { + return this.key; + } + + public boolean matches(@Nullable Object key) { + // equals() on the lookup param, not the field, so the JIT can devirtualize it once + // matches() inlines into get/getOrCreate (the caller's key type is known there). + return Objects.equals(key, this.key); + } + + /** {@link ConcurrentHashtable.Entry#matches(Entry)} in terms of the key-based overload. */ + @Override + public final boolean matches(@Nonnull Entry other) { + return matches(other.key); + } + + /** + * Returns the 64-bit lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so + * they don't collide with a real key that hashes to 0; real-key collisions in chains are + * resolved by {@link #matches(Object)}. + */ + public static long hash(@Nullable Object key) { + return (key == null) ? Long.MIN_VALUE : key.hashCode(); + } + } - /** See {@link #cast(Entry)}; casts the functional-interface reference, not each element. */ - @SuppressWarnings("unchecked") - private Predicate> castPredicate(Predicate predicate) { - return (Predicate>) predicate; - } + private final State> state; - @SuppressWarnings("unchecked") - private Consumer> castConsumer(Consumer consumer) { - return (Consumer>) consumer; - } + private D1(State> state) { + this.state = state; + } - @SuppressWarnings("unchecked") - private BiConsumer> castConsumer( - BiConsumer consumer) { - return (BiConsumer>) consumer; - } + /** + * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is + * used only to infer {@code TEntry}; entries are created by the {@code creator} functions + * passed to the insertion methods. The table does not resize. + */ + @Nonnull + @SuppressWarnings("unchecked") + public static > D1 createBounded( + @Nonnull Class entryClass, int maxCapacity) { + // entryClass is erased away (see ConcurrentHashtable#createFixedBuckets), so treating it as + // Class> instead of the caller's concrete Class is safe. + Class> baseEntryClass = (Class>) (Class) entryClass; + return new D1<>(ConcurrentHashtable.createBounded(baseEntryClass, maxCapacity)); + } - public int size() { - return state.sizeManager.estimateSize(); - } + /** + * Sound because every entry ever inserted into {@link #state} was produced as a {@code TEntry}. + */ + @SuppressWarnings("unchecked") + private TEntry cast(@Nullable Entry entry) { + return (TEntry) entry; + } - public boolean isFull() { - return state.sizeManager.isFull(); - } + /** See {@link #cast(Entry)}; casts the functional-interface reference, not each element. */ + @SuppressWarnings("unchecked") + private Predicate> castPredicate(Predicate predicate) { + return (Predicate>) predicate; + } - @Nullable - public TEntry get(@Nullable K key) { - long keyHash = D1.Entry.hash(key); - for (Entry curEntry = bucketFor(state, keyHash); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key)) { - return cast(curEntry); - } - } - return null; + @SuppressWarnings("unchecked") + private Consumer> castConsumer(Consumer consumer) { + return (Consumer>) consumer; + } + + @SuppressWarnings("unchecked") + private BiConsumer> castConsumer( + BiConsumer consumer) { + return (BiConsumer>) consumer; + } + + public int size() { + return state.sizeManager.estimateSize(); + } + + public boolean isFull() { + return state.sizeManager.isFull(); + } + + @Nullable + public TEntry get(@Nullable K key) { + long keyHash = D1.Entry.hash(key); + for (Entry curEntry = bucketFor(state, keyHash); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return cast(curEntry); + } + } + return null; + } + + /** + * Returns the entry for {@code key}, creating one via {@code creator} if absent and the table + * is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps {@link + * #tryGetOrCreateOrNull} — see that method for the refusal and ordering details. + */ + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K key, @Strategy @Nonnull Function creator) { + return Maybe.of(tryGetOrCreateOrNull(key, creator)); + } + + /** + * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly + * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and + * {@code key} was not already present. Re-checks under the lock to avoid duplicate entries + * under concurrent misses. + */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrCreateOrNull( + @Nullable K key, @Strategy @Nonnull Function creator) { + long keyHash = D1.Entry.hash(key); + int index = bucketIndex(state.buckets, keyHash); + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return cast(curEntry); + } + } + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return cast(curEntry); + } + } + // isFull() is checked before creating the entry, not before reserving a slot for it: + // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a + // throwing creator would leak that reservation forever. So we accept the entry only after + // creator succeeds, then increment. + if (state.sizeManager.isFull()) { + return null; + } + TEntry newEntry = creator.apply(key); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } finally { + lock.unlock(); + } + } + + /** + * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code + * evictable} to make room instead of refusing the insert. Refuses only when the table is full + * and nothing matches {@code evictable} — see {@link #tryGetOrCreateOrEvictOrNull} for + * the null-returning form and the eviction/creation ordering. + */ + @Nonnull + public Maybe tryGetOrCreateOrEvict( + @Nullable K key, + @Strategy @Nonnull Function creator, + @Strategy @Nonnull Predicate evictable) { + return Maybe.of(tryGetOrCreateOrEvictOrNull(key, creator, evictable)); + } + + /** + * Escape hatch for {@link #tryGetOrCreateOrEvict} for callers that want the nullable entry + * directly. Eviction runs before {@code creator}, not after: {@code creator} may throw, so + * freeing a slot and only then attempting the fallible create keeps a thrown exception from + * ever leaving a slot double-booked. A creator that throws after a successful eviction simply + * leaves the table one entry smaller — no corruption, just a wasted eviction. + */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrCreateOrEvictOrNull( + @Nullable K key, + @Strategy @Nonnull Function creator, + @Strategy @Nonnull Predicate evictable) { + long keyHash = D1.Entry.hash(key); + int index = bucketIndex(state.buckets, keyHash); + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return cast(curEntry); + } + } + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + return cast(curEntry); + } + } + if (state.sizeManager.isFull() + && state.sizeManager.evictOne(state.buckets, castPredicate(evictable)) == null) { + return null; + } + TEntry newEntry = creator.apply(key); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } finally { + lock.unlock(); + } + } + + /** + * Removes and returns the entry for {@code key}, or {@code null} if absent. Acquires the + * table-level lock to splice the chain; lock-free readers observe the removal via the volatile + * write of the predecessor's {@code next} (or the bucket head). + */ + @Nullable + public TEntry remove(@Nullable K key) { + long keyHash = D1.Entry.hash(key); + int index = bucketIndex(state.buckets, keyHash); + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + Entry prev = null; + for (Entry curEntry = bucketAt(state, index); + curEntry != null; + prev = curEntry, curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key)) { + unlink(state, index, prev, curEntry); + state.sizeManager.decrement(); + return cast(curEntry); + } + } + return null; + } finally { + lock.unlock(); + } + } + + /** + * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. + * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and + * concurrent writers are excluded; lock-free readers continue throughout. + */ + public boolean removeIf(@Strategy @Nonnull Predicate predicate) { + return ConcurrentHashtable.removeIf(state, castPredicate(predicate)); + } + + /** + * Removes all entries and invokes {@code drainedEntryConsumer} for each one. + * + *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, + * so a table-level operation (an insert, a reservation, an eviction) can interleave between + * buckets instead of waiting out the entire drain. This means the drain is no longer an atomic + * snapshot: an entry inserted into a not-yet-visited bucket while the drain is in progress is + * swept up too, and each bucket's capacity slot is released immediately before its consumer + * invocation runs, rather than once for the whole sweep at the end. The consumer should be + * quick and must not throw. If it does, later entries in the detached bucket remain counted but + * are no longer reachable from the table. Buckets not yet visited remain intact, and capacity + * already released is not restored. + * + *

Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. + * + * @param drainedEntryConsumer action invoked for each removed entry + */ + public void drain(@Strategy @Nonnull Consumer drainedEntryConsumer) { + ConcurrentHashtable.drain(state, castConsumer(drainedEntryConsumer)); + } + + /** + * Context-passing {@link #drain(Consumer)}. The lock is acquired and released bucket by bucket + * rather than held for the whole sweep, so a table-level operation (an insert, a reservation, + * an eviction) can interleave between buckets instead of waiting out the entire drain. This + * means the drain is no longer an atomic snapshot: an entry inserted into a not-yet-visited + * bucket while the drain is in progress is swept up too, and each bucket's capacity slot is + * released immediately before its consumer invocation runs, rather than once for the whole + * sweep at the end. + * + *

Pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus the + * accumulator as {@code context} (e.g. the target list or event builder) to avoid a + * capturing-lambda allocation. + * + * @param context type + * @param context context passed to each invocation of {@code drainedEntryConsumer} + * @param drainedEntryConsumer action invoked with the context and each removed entry + */ + public void drain( + C context, @Strategy @Nonnull BiConsumer drainedEntryConsumer) { + ConcurrentHashtable.drain(state, context, castConsumer(drainedEntryConsumer)); + } + + /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ + public void clear() { + ConcurrentHashtable.clear(state); + } + + public void forEach(@Strategy @Nonnull Consumer consumer) { + ConcurrentHashtable.forEach(state, castConsumer(consumer)); + } + + /** + * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link + * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. + */ + public void forEach(C context, @Strategy @Nonnull BiConsumer consumer) { + ConcurrentHashtable.forEach(state, context, castConsumer(consumer)); + } } /** - * Returns the entry for {@code key}, creating one via {@code creator} if absent and the table - * is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps {@link - * #tryGetOrCreateOrNull} — see that method for the refusal and ordering details. + * Two-key concurrent hash table. Key parts are passed directly to {@link #get} and {@link + * #tryGetOrCreate}, avoiding a composite wrapper whose allocation would otherwise rely on HotSpot + * escape analysis to disappear. Reads are lock-free; misses and mutations acquire the write lock. + * + * @param first key type + * @param second key type + * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass */ - @Nonnull - public Maybe tryGetOrCreate( - @Nullable K key, @Strategy @Nonnull Function creator) { - return Maybe.of(tryGetOrCreateOrNull(key, creator)); + @ThreadSafe + public static final class D2> { + + /** + * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in + * place after retrieving the entry via {@link D2#get}. + * + *

Deliberately parameterized on {@code K1}/{@code K2} alone, not self-bound on the concrete + * subclass -- see {@link D1.Entry} for why. + * + * @param first key type + * @param second key type + */ + public abstract static class Entry extends ConcurrentHashtable.Entry> { + @Nullable + final K1 key1; + + @Nullable + final K2 key2; + + protected Entry(@Nullable K1 key1, @Nullable K2 key2) { + super(hash(key1, key2)); + this.key1 = key1; + this.key2 = key2; + } + + /** The first key part this entry was created with. */ + @Nullable + public K1 key1() { + return this.key1; + } + + /** The second key part this entry was created with. */ + @Nullable + public K2 key2() { + return this.key2; + } + + public boolean matches(@Nullable K1 key1, @Nullable K2 key2) { + // equals() on the lookup params, not the fields, so the JIT can devirtualize them once + // matches() inlines into get/getOrCreate (the caller's key types are known there). + return Objects.equals(key1, this.key1) && Objects.equals(key2, this.key2); + } + + /** {@link ConcurrentHashtable.Entry#matches(Entry)} in terms of the key-based overload. */ + @Override + public final boolean matches(@Nonnull Entry other) { + return matches(other.key1, other.key2); + } + + /** Returns the 64-bit lookup hash combining both key parts via {@link LongHashingUtils}. */ + public static long hash(@Nullable Object key1, @Nullable Object key2) { + return LongHashingUtils.hash(key1, key2); + } + } + + private final State> state; + + private D2(State> state) { + this.state = state; + } + + /** + * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is + * used only to infer {@code TEntry}; entries are created by the {@code creator} functions + * passed to the insertion methods. The table does not resize. + */ + @Nonnull + @SuppressWarnings("unchecked") + public static > D2 createBounded( + @Nonnull Class entryClass, int maxCapacity) { + // entryClass is erased away (see ConcurrentHashtable#createFixedBuckets), so treating it as + // Class> instead of the caller's concrete Class is safe. + Class> baseEntryClass = (Class>) (Class) entryClass; + return new D2<>(ConcurrentHashtable.createBounded(baseEntryClass, maxCapacity)); + } + + /** + * Sound because every entry ever inserted into {@link #state} was produced as a {@code TEntry}. + */ + @SuppressWarnings("unchecked") + private TEntry cast(@Nullable Entry entry) { + return (TEntry) entry; + } + + /** See {@link #cast(Entry)}; casts the functional-interface reference, not each element. */ + @SuppressWarnings("unchecked") + private Predicate> castPredicate(Predicate predicate) { + return (Predicate>) predicate; + } + + @SuppressWarnings("unchecked") + private Consumer> castConsumer(Consumer consumer) { + return (Consumer>) consumer; + } + + @SuppressWarnings("unchecked") + private BiConsumer> castConsumer( + BiConsumer consumer) { + return (BiConsumer>) consumer; + } + + public int size() { + return state.sizeManager.estimateSize(); + } + + public boolean isFull() { + return state.sizeManager.isFull(); + } + + @Nullable + public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { + long keyHash = D2.Entry.hash(key1, key2); + for (Entry curEntry = bucketFor(state, keyHash); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return cast(curEntry); + } + } + return null; + } + + /** + * Returns the entry for {@code (key1, key2)}, creating one via {@code creator} if absent and + * the table is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps + * {@link #tryGetOrCreateOrNull} — see that method for the refusal and ordering details. + */ + @Nonnull + public Maybe tryGetOrCreate( + @Nullable K1 key1, + @Nullable K2 key2, + @Strategy @Nonnull BiFunction creator) { + return Maybe.of(tryGetOrCreateOrNull(key1, key2, creator)); + } + + /** + * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly + * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and + * {@code (key1, key2)} was not already present. Re-checks under the lock to avoid duplicate + * entries under concurrent misses. + */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrCreateOrNull( + @Nullable K1 key1, + @Nullable K2 key2, + @Strategy @Nonnull BiFunction creator) { + long keyHash = D2.Entry.hash(key1, key2); + int index = bucketIndex(state.buckets, keyHash); + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return cast(curEntry); + } + } + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return cast(curEntry); + } + } + // isFull() is checked before creating the entry, not before reserving a slot for it: + // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a + // throwing creator would leak that reservation forever. So we accept the entry only after + // creator succeeds, then increment. + if (state.sizeManager.isFull()) { + return null; + } + TEntry newEntry = creator.apply(key1, key2); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } finally { + lock.unlock(); + } + } + + /** + * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code + * evictable} to make room instead of refusing the insert. Refuses only when the table is full + * and nothing matches {@code evictable} — see {@link #tryGetOrCreateOrEvictOrNull} for + * the null-returning form and the eviction/creation ordering. + */ + @Nonnull + public Maybe tryGetOrCreateOrEvict( + @Nullable K1 key1, + @Nullable K2 key2, + @Strategy @Nonnull BiFunction creator, + @Strategy @Nonnull Predicate evictable) { + return Maybe.of(tryGetOrCreateOrEvictOrNull(key1, key2, creator, evictable)); + } + + /** + * Escape hatch for {@link #tryGetOrCreateOrEvict} for callers that want the nullable entry + * directly. Eviction runs before {@code creator}, not after: {@code creator} may throw, so + * freeing a slot and only then attempting the fallible create keeps a thrown exception from + * ever leaving a slot double-booked. A creator that throws after a successful eviction simply + * leaves the table one entry smaller — no corruption, just a wasted eviction. + */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrCreateOrEvictOrNull( + @Nullable K1 key1, + @Nullable K2 key2, + @Strategy @Nonnull BiFunction creator, + @Strategy @Nonnull Predicate evictable) { + long keyHash = D2.Entry.hash(key1, key2); + int index = bucketIndex(state.buckets, keyHash); + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return cast(curEntry); + } + } + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + for (Entry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + return cast(curEntry); + } + } + if (state.sizeManager.isFull() + && state.sizeManager.evictOne(state.buckets, castPredicate(evictable)) == null) { + return null; + } + TEntry newEntry = creator.apply(key1, key2); + insertHeadEntryAt(state, index, newEntry); + state.sizeManager.increment(); + return newEntry; + } finally { + lock.unlock(); + } + } + + /** + * Removes and returns the entry for {@code (key1, key2)}, or {@code null} if absent. Acquires + * the table-level lock to splice the chain; lock-free readers observe the removal via the + * volatile write of the predecessor's {@code next} (or the bucket head). + */ + @Nullable + public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { + long keyHash = D2.Entry.hash(key1, key2); + int index = bucketIndex(state.buckets, keyHash); + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + Entry prev = null; + for (Entry curEntry = bucketAt(state, index); + curEntry != null; + prev = curEntry, curEntry = curEntry.next()) { + if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { + unlink(state, index, prev, curEntry); + state.sizeManager.decrement(); + return cast(curEntry); + } + } + return null; + } finally { + lock.unlock(); + } + } + + /** + * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. + * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and + * concurrent writers are excluded; lock-free readers continue throughout. + */ + public boolean removeIf(@Strategy @Nonnull Predicate predicate) { + return ConcurrentHashtable.removeIf(state, castPredicate(predicate)); + } + + /** + * Removes all entries and invokes {@code drainedEntryConsumer} for each one. + * + *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, + * so a table-level operation (an insert, a reservation, an eviction) can interleave between + * buckets instead of waiting out the entire drain. This means the drain is no longer an atomic + * snapshot: an entry inserted into a not-yet-visited bucket while the drain is in progress is + * swept up too, and each bucket's capacity slot is released immediately before its consumer + * invocation runs, rather than once for the whole sweep at the end. The consumer should be + * quick and must not throw. If it does, later entries in the detached bucket remain counted but + * are no longer reachable from the table. Buckets not yet visited remain intact, and capacity + * already released is not restored. + * + *

Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. + * + * @param drainedEntryConsumer action invoked for each removed entry + */ + public void drain(@Strategy @Nonnull Consumer drainedEntryConsumer) { + ConcurrentHashtable.drain(state, castConsumer(drainedEntryConsumer)); + } + + /** + * Context-passing {@link #drain(Consumer)}. The lock is acquired and released bucket by bucket + * rather than held for the whole sweep, so a table-level operation (an insert, a reservation, + * an eviction) can interleave between buckets instead of waiting out the entire drain. This + * means the drain is no longer an atomic snapshot: an entry inserted into a not-yet-visited + * bucket while the drain is in progress is swept up too, and each bucket's capacity slot is + * released immediately before its consumer invocation runs, rather than once for the whole + * sweep at the end. + * + *

Pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus the + * accumulator as {@code context} (e.g. the target list or event builder) to avoid a + * capturing-lambda allocation. + * + * @param context type + * @param context context passed to each invocation of {@code drainedEntryConsumer} + * @param drainedEntryConsumer action invoked with the context and each removed entry + */ + public void drain( + C context, @Strategy @Nonnull BiConsumer drainedEntryConsumer) { + ConcurrentHashtable.drain(state, context, castConsumer(drainedEntryConsumer)); + } + + /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ + public void clear() { + ConcurrentHashtable.clear(state); + } + + public void forEach(@Strategy @Nonnull Consumer consumer) { + ConcurrentHashtable.forEach(state, castConsumer(consumer)); + } + + /** + * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link + * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. + */ + public void forEach(C context, @Strategy @Nonnull BiConsumer consumer) { + ConcurrentHashtable.forEach(state, context, castConsumer(consumer)); + } } /** - * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly - * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and - * {@code key} was not already present. Re-checks under the lock to avoid duplicate entries - * under concurrent misses. + * Tracks a capped table's occupancy and eviction position. + * + *

The count includes live entries and outstanding reservations. Its {@link AtomicInteger} + * provides volatile visibility and atomic updates, allowing size queries and {@link + * #tryReserve()} without the table lock. The plain {@code evictionCursor} is instead protected by + * the table write lock; methods annotated with {@link GuardedBy} require that lock. */ - @StrategyConsumer - @Nullable - public TEntry tryGetOrCreateOrNull( - @Nullable K key, @Strategy @Nonnull Function creator) { - long keyHash = D1.Entry.hash(key); - int index = bucketIndex(state.buckets, keyHash); - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key)) { - return cast(curEntry); - } - } - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key)) { - return cast(curEntry); - } - } - // isFull() is checked before creating the entry, not before reserving a slot for it: - // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a - // throwing creator would leak that reservation forever. So we accept the entry only after - // creator succeeds, then increment. - if (state.sizeManager.isFull()) { - return null; - } - TEntry newEntry = creator.apply(key); - insertHeadEntryAt(state, index, newEntry); - state.sizeManager.increment(); - return newEntry; - } finally { - lock.unlock(); - } + @ThreadSafe + public static final class SizeManager { + private final AtomicInteger size = new AtomicInteger(); + private final int capacity; + + /** + * Bucket index the last eviction removed from. The next scan resumes here, so a sustained + * eviction stream doesn't repeatedly re-walk the same hot entries clustered near bucket 0. + */ + @GuardedBy("the corresponding State's getTableWriteLock(state)") + private int evictionCursor; + + public SizeManager(int capacity) { + this.capacity = capacity; + } + + /** Live entries. Safe to call without the write lock. */ + public int estimateSize() { + return size.get(); + } + + public int capacity() { + return capacity; + } + + /** {@code true} once {@link #estimateSize()} has reached {@link #capacity()}. */ + public boolean isFull() { + return size.get() >= capacity; + } + + /** + * Atomically reserves one slot without taking the table write lock. It claims first with {@link + * AtomicInteger#incrementAndGet()} and refunds values above capacity, so concurrent callers + * cannot both acquire the last slot. Returns {@code false} with the count unchanged when the + * table is full. + * + *

Every successful claim must be filled or released with {@link #decrement()}. Prefer {@link + * ConcurrentHashtable#tryReserve(State, long)}, whose {@link Reservation#close()} gives back an + * unused claim automatically. + */ + public boolean tryReserve() { + if (size.incrementAndGet() > capacity) { + size.decrementAndGet(); + return false; + } + return true; + } + + /** + * Reserves one slot, evicting an entry matching {@code evictable} when the table is full. + * Returns {@code false} without changing the table when no entry can be evicted. + * + *

The reservation survives concurrent drain and clear operations. The caller must fill it; + * an abandoned reservation permanently consumes capacity. + */ + @GuardedBy("the corresponding State's getTableWriteLock(state)") + public > boolean tryReserveOrEvict( + @Nonnull AtomicReferenceArray buckets, @Strategy @Nonnull Predicate evictable) { + if (tryReserve()) { + return true; + } + if (evictOne(buckets, evictable) == null) { + return false; + } + // evictOne already decremented; the slot it freed is ours. + size.incrementAndGet(); + return true; + } + + /** Call after successfully linking a new entry. */ + public void increment() { + size.incrementAndGet(); + } + + /** Call after successfully unlinking an entry. */ + public void decrement() { + size.decrementAndGet(); + } + + /** + * Releases {@code removed} slots after a sweep and resets the {@code evictionCursor}. + * Outstanding reservations remain counted. + */ + @GuardedBy("the corresponding State's getTableWriteLock(state)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = "evictionCursor is read and written only while the corresponding State's write lock is" + + " held; SpotBugs cannot model that dynamic guard") + public void release(int removed) { + if (removed != 0) { + size.addAndGet(-removed); + } + evictionCursor = 0; + } + + /** + * Removes and returns the first entry matching {@code evictable}, scanning from the previous + * eviction position and wrapping once. Returns {@code null} without changing the count when no + * entry matches. + * + *

This operation may inspect every live entry while holding the table write lock, so the + * predicate should be quick. + */ + @GuardedBy("the corresponding State's getTableWriteLock(state)") + @Nullable + public > TEntry evictOne( + @Nonnull AtomicReferenceArray buckets, @Strategy @Nonnull Predicate evictable) { + TEntry evicted = evictOneInRange(buckets, evictable, evictionCursor, buckets.length()); + if (evicted == null && evictionCursor != 0) { + evicted = evictOneInRange(buckets, evictable, 0, evictionCursor); + } + if (evicted != null) { + size.decrementAndGet(); + return evicted; + } + // Nothing matched anywhere; step the evictionCursor on regardless so repeated refusals don't + // all + // restart the (wasted) scan from the same bucket. + evictionCursor = bucketIndex(buckets, evictionCursor + 1); + return null; + } + + @GuardedBy("the corresponding State's getTableWriteLock(state)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = "evictionCursor is read and written only while the corresponding State's write lock is" + + " held; SpotBugs cannot model that dynamic guard") + @StrategyConsumer + @Nullable + private > TEntry evictOneInRange( + @Nonnull AtomicReferenceArray buckets, + @Strategy @Nonnull Predicate evictable, + int startBucket, + int endBucket) { + for (int i = startBucket; i < endBucket; i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (evictable.test(e)) { + unlinkUnchecked(buckets, i, prev, e); + evictionCursor = i; + return e; + } + prev = e; + } + } + return null; + } + + /** + * Unlinks every entry matching {@code evictable} in one full pass, decrementing the count for + * each, and returns how many were removed. Resets the scan position, since a full pass leaves + * nothing later to resume from. + */ + @GuardedBy("the corresponding State's getTableWriteLock(state)") + @SuppressFBWarnings( + value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", + justification = "evictionCursor is read and written only while the corresponding State's write lock is" + + " held; SpotBugs cannot model that dynamic guard") + @StrategyConsumer + public > int evictAll( + @Nonnull AtomicReferenceArray buckets, @Strategy @Nonnull Predicate evictable) { + int count = 0; + for (int i = 0; i < buckets.length(); i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (evictable.test(e)) { + unlinkUnchecked(buckets, i, prev, e); + size.decrementAndGet(); + count++; + } else { + prev = e; + } + } + } + evictionCursor = 0; + return count; + } } /** - * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code - * evictable} to make room instead of refusing the insert. Refuses only when the table is full - * and nothing matches {@code evictable} — see {@link #tryGetOrCreateOrEvictOrNull} for - * the null-returning form and the eviction/creation ordering. + * Bucket array and occupancy manager for a caller-defined capped table. Keep them paired and + * prefer the {@code State}-accepting helpers so structural changes update the count consistently. + * + *

{@code sizeManager} is intentionally package-private: callers outside this class must go + * through the {@code State}-accepting static helpers ({@link #estimateSize}, {@link #isFull}, + * {@link #tryReserve}, {@link #tryReserveOrEvict}, {@link #evictOne}, {@link #evictAll}) rather + * than reach into the manager directly. */ - @Nonnull - public Maybe tryGetOrCreateOrEvict( - @Nullable K key, - @Strategy @Nonnull Function creator, - @Strategy @Nonnull Predicate evictable) { - return Maybe.of(tryGetOrCreateOrEvictOrNull(key, creator, evictable)); + public static final class State> { + public final AtomicReferenceArray buckets; + final SizeManager sizeManager; + + /** + * The table's write lock. Unlike the bare-array helpers' opaque {@code synchronized} monitor, + * this is a real {@link ReentrantLock}. A {@link Reservation} can acquire it in {@link + * ConcurrentHashtable#tryReserve(State, long)} and release it later in {@link + * Reservation#close()}, unlike a lexically scoped {@code synchronized} block. + */ + final ReentrantLock writeLock = new ReentrantLock(); + + private State(AtomicReferenceArray buckets, int maxCapacity) { + this.buckets = buckets; + this.sizeManager = new SizeManager(maxCapacity); + } } /** - * Escape hatch for {@link #tryGetOrCreateOrEvict} for callers that want the nullable entry - * directly. Eviction runs before {@code creator}, not after: {@code creator} may throw, so - * freeing a slot and only then attempting the fallible create keeps a thrown exception from - * ever leaving a slot double-booked. A creator that throws after a successful eviction simply - * leaves the table one entry smaller — no corruption, just a wasted eviction. + * Creates a bucket array for {@code maxCapacity} entries and pairs it with a manager enforcing + * that cap. {@code entryClass} is used only to infer {@code TEntry}. */ - @StrategyConsumer - @Nullable - public TEntry tryGetOrCreateOrEvictOrNull( - @Nullable K key, - @Strategy @Nonnull Function creator, - @Strategy @Nonnull Predicate evictable) { - long keyHash = D1.Entry.hash(key); - int index = bucketIndex(state.buckets, keyHash); - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key)) { - return cast(curEntry); - } - } - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key)) { - return cast(curEntry); - } - } - if (state.sizeManager.isFull() - && state.sizeManager.evictOne(state.buckets, castPredicate(evictable)) == null) { - return null; - } - TEntry newEntry = creator.apply(key); - insertHeadEntryAt(state, index, newEntry); - state.sizeManager.increment(); - return newEntry; - } finally { - lock.unlock(); - } + @Nonnull + public static > State createBounded( + @Nonnull Class entryClass, int maxCapacity) { + return new State<>(createFixedBuckets(entryClass, maxCapacity), maxCapacity); } - /** - * Removes and returns the entry for {@code key}, or {@code null} if absent. Acquires the - * table-level lock to splice the chain; lock-free readers observe the removal via the volatile - * write of the predecessor's {@code next} (or the bucket head). - */ - @Nullable - public TEntry remove(@Nullable K key) { - long keyHash = D1.Entry.hash(key); - int index = bucketIndex(state.buckets, keyHash); - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - Entry prev = null; - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - prev = curEntry, curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key)) { - unlink(state, index, prev, curEntry); - state.sizeManager.decrement(); - return cast(curEntry); - } - } - return null; - } finally { - lock.unlock(); - } + /** Live entries in {@code state}; see {@link SizeManager#estimateSize()}. Lock-free. */ + public static int estimateSize(@Nonnull State state) { + return state.sizeManager.estimateSize(); } /** - * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. - * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and - * concurrent writers are excluded; lock-free readers continue throughout. + * {@code true} once {@code state} is at capacity; see {@link SizeManager#isFull()}. Lock-free. */ - public boolean removeIf(@Strategy @Nonnull Predicate predicate) { - return ConcurrentHashtable.removeIf(state, castPredicate(predicate)); + public static boolean isFull(@Nonnull State state) { + return state.sizeManager.isFull(); } /** - * Removes all entries and invokes {@code drainedEntryConsumer} for each one. + * Claims one slot in {@code state} for {@code keyHash} and returns a handle for completing the + * find-or-insert protocol, or an empty handle if the table is full. Never returns {@code null}, + * so this always composes with try-with-resources: * - *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, - * so a table-level operation (an insert, a reservation, an eviction) can interleave between - * buckets instead of waiting out the entire drain. This means the drain is no longer an atomic - * snapshot: an entry inserted into a not-yet-visited bucket while the drain is in progress is - * swept up too, and each bucket's capacity slot is released immediately before its consumer - * invocation runs, rather than once for the whole sweep at the end. The consumer should be - * quick and must not throw. If it does, later entries in the detached bucket remain counted but - * are no longer reachable from the table. Buckets not yet visited remain intact, and capacity - * already released is not restored. + *

{@code
+     * try (Reservation r = ConcurrentHashtable.tryReserve(state, keyHash)) {
+     *   return r.tryGetOrInsertOrNull(component1, component2, component3, TEntry::new);
+     * }
+     * }
+ * + *

Run a lock-free scan first (see {@link #bucketFor}/{@link #bucketAt}) and only call this + * once that scan has missed — a successful reservation isn't required for correctness (the + * reservation itself, and the locked comparison inside {@link Reservation#tryGetOrInsertOrNull}, + * are the source of truth), it just avoids paying for a lock and a factory call when a hit was + * already visible lock-free. * - *

Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. + *

Checks {@link SizeManager#isFull()} lock-free first. If the table looks full, {@code + * keyHash}'s bucket is checked too: if it's empty, nothing could possibly match this key -- + * unless a concurrent reservation for this exact key is in flight, having already + * claimed the table's last slot (so {@code isFull()} is genuinely {@code true}) without yet + * linking its entry (so the bucket still looks empty). That window is confirmed away under the + * write lock before this returns a definitively empty reservation without ever taking it -- + * otherwise the concurrent duplicate landing in that exact window would be dropped instead of + * counted. Once the lock is held (either because the fast peek above couldn't rule out a match, + * or because the locked recheck couldn't either), this reserves the slot -- returning a real + * reservation that holds the lock open across this call, to be released later by {@link + * Reservation#close()} -- or, if the table is genuinely still full, keeps the lock open anyway + * (rather than releasing it immediately) so {@link Reservation#finish} can still scan {@code + * keyHash}'s bucket the next time the caller supplies a candidate entry. That's what closes the + * race where a concurrent insert for the same logical duplicate lands in the exact window between + * this caller's own lock-free scan and its reservation attempt: without the lock held open here, + * that concurrent duplicate would go uncounted. Holding the lock for the reservation's whole + * lifetime (rather than just across this call, the way {@link SizeManager#tryReserve()} does) is + * also what lets {@link Reservation#tryGetOrInsertOrNull} skip a second, separately-locked + * comparison pass: the reserve-then-insert-or-discard sequence is one uninterrupted critical + * section, so no concurrent reservation for the same logical duplicate can slip in between. * - * @param drainedEntryConsumer action invoked for each removed entry + *

Always returns a non-null handle — even when the table is full — so the caller must check + * {@link Reservation#isReserved()} (or simply call {@link Reservation#tryGetOrInsertOrNull}, + * which returns {@code null} on an absent reservation) rather than assume every reservation is + * real. A reservation can also report {@link Reservation#isReserved()} {@code true} while still + * having no slot to insert into (the lock-held-but-full case above); either way, {@code + * tryGetOrInsertOrNull}/{@code tryGetOrInsert} return the concurrent match found under the lock, + * or {@code null} -- never a newly linked entry when no slot was claimed. + * + * @param keyHash hash of the key the caller is about to look up or insert */ - public void drain(@Strategy @Nonnull Consumer drainedEntryConsumer) { - ConcurrentHashtable.drain(state, castConsumer(drainedEntryConsumer)); + @Nonnull + @SuppressFBWarnings( + value = "UL_UNRELEASED_LOCK", + justification = "Every path either unlocks before returning (the confirmed-empty case) or hands the" + + " held lock off to the returned Reservation, released later by Reservation#close;" + + " SpotBugs can't correlate that transfer across the two lock() call sites here.") + public static > Reservation tryReserve( + @Nonnull State state, long keyHash) { + int index = bucketIndex(state.buckets, keyHash); + ReentrantLock lock = state.writeLock; + if (state.sizeManager.isFull() && bucketAt(state, index) == null) { + lock.lock(); + // The lock-free peek above can be stale: a concurrent reservation for this exact key may + // have already claimed the table's last slot (isFull() true) without yet linking its entry + // (bucket still looks empty). Recheck under the lock before trusting it, or that concurrent + // duplicate would be dropped instead of counted. + if (state.sizeManager.isFull() && bucketAt(state, index) == null) { + lock.unlock(); + return new Reservation<>(null, false); + } + if (state.sizeManager.tryReserve()) { + return new Reservation<>(state, true); + } + return new Reservation<>(state, false); + } + lock.lock(); + if (state.sizeManager.tryReserve()) { + return new Reservation<>(state, true); + } + return new Reservation<>(state, false); } /** - * Context-passing {@link #drain(Consumer)}. The lock is acquired and released bucket by bucket - * rather than held for the whole sweep, so a table-level operation (an insert, a reservation, - * an eviction) can interleave between buckets instead of waiting out the entire drain. This - * means the drain is no longer an atomic snapshot: an entry inserted into a not-yet-visited - * bucket while the drain is in progress is swept up too, and each bucket's capacity slot is - * released immediately before its consumer invocation runs, rather than once for the whole - * sweep at the end. + * Handle returned by {@link #tryReserve}, gating {@link #tryGetOrInsertOrNull} behind a claimed + * slot and auto-cancelling it on {@link #close} if it's never consumed. A single {@code + * Reservation} must be used for at most one {@code tryGetOrInsertOrNull}/{@code tryGetOrInsert} + * call -- a second call throws {@link IllegalStateException} rather than silently linking a + * second entry while the size counter only reflects the first. * - *

Pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus the - * accumulator as {@code context} (e.g. the target list or event builder) to avoid a - * capturing-lambda allocation. + *

Overloaded up to 4 key components ({@link #tryGetOrInsertOrNull(Object, Function)} through + * {@link #tryGetOrInsertOrNull(Object, Object, Object, Object, Function4)}) so a non-capturing + * method reference can build the entry directly from its natural constructor arguments, without + * an intermediate holder object or a capturing lambda. * - * @param context type - * @param context context passed to each invocation of {@code drainedEntryConsumer} - * @param drainedEntryConsumer action invoked with the context and each removed entry + *

A non-empty reservation holds the table's write lock until {@link #close()}, even after + * insertion or a matching entry is returned. Keep its lifetime short: other writers using this + * lock must wait until the reservation is closed. + * + *

Create, use, and close the reservation on the same thread: its {@link ReentrantLock} must be + * released by the thread that acquired it. + * + * @param the table's entry type, itself self-bound (see {@link + * ConcurrentHashtable.Entry}) */ - public void drain( - C context, @Strategy @Nonnull BiConsumer drainedEntryConsumer) { - ConcurrentHashtable.drain(state, context, castConsumer(drainedEntryConsumer)); - } + @NotThreadSafe + public static final class Reservation> implements AutoCloseable { + @Nullable + private final State state; + + private final boolean slotClaimed; + private boolean completed; + private boolean consumed; + + private Reservation(@Nullable State state, boolean slotClaimed) { + this.state = state; + this.slotClaimed = slotClaimed; + } - /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ - public void clear() { - ConcurrentHashtable.clear(state); - } + /** + * {@code true} unless this reservation is definitively empty -- i.e. {@link #tryReserve} could + * rule out a match for the target key without even taking the write lock. A {@code true} + * reservation may still have no slot to insert into: {@link #tryReserve} keeps the lock open on + * a full table when the target bucket is populated, purely so {@link #finish} can still scan it + * for a concurrent match. Callers should call {@code tryGetOrInsertOrNull}/{@code + * tryGetOrInsert} and check its result rather than assume {@code true} means a slot was + * claimed. + */ + public boolean isReserved() { + return state != null; + } - public void forEach(@Strategy @Nonnull Consumer consumer) { - ConcurrentHashtable.forEach(state, castConsumer(consumer)); - } + /** + * Escape hatch for a caller that already built {@code newEntry} itself -- e.g. more than 4 key + * components, or components the caller wants to keep as primitives rather than boxing them into + * a {@code Function}'s type argument: + * + *

{@code
+         * try (Reservation r = ConcurrentHashtable.tryReserve(state, keyHash)) {
+         *   if (!r.isReserved()) {
+         *     return null;
+         *   }
+         *   return r.tryGetOrInsertOrNull(new TEntry(longComponent1, longComponent2));
+         * }
+         * }
+ * + * See {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)} for the general contract. + */ + @Nullable + public TEntry tryGetOrInsertOrNull(@Nonnull TEntry newEntry) { + return state == null ? null : finish(newEntry); + } - /** - * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link - * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. - */ - public void forEach( - C context, @Strategy @Nonnull BiConsumer consumer) { - ConcurrentHashtable.forEach(state, context, castConsumer(consumer)); - } - } - - /** - * Two-key concurrent hash table. Key parts are passed directly to {@link #get} and {@link - * #tryGetOrCreate}, avoiding a composite wrapper whose allocation would otherwise rely on HotSpot - * escape analysis to disappear. Reads are lock-free; misses and mutations acquire the write lock. - * - * @param first key type - * @param second key type - * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass - */ - @ThreadSafe - public static final class D2> { + /** + * One key component; see {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)} for the + * general contract. + */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrInsertOrNull(A a, @Strategy @Nonnull Function factory) { + return state == null ? null : finish(factory.apply(a)); + } - /** - * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in - * place after retrieving the entry via {@link D2#get}. - * - *

Deliberately parameterized on {@code K1}/{@code K2} alone, not self-bound on the concrete - * subclass -- see {@link D1.Entry} for why. - * - * @param first key type - * @param second key type - */ - public abstract static class Entry extends ConcurrentHashtable.Entry> { - @Nullable final K1 key1; - @Nullable final K2 key2; - - protected Entry(@Nullable K1 key1, @Nullable K2 key2) { - super(hash(key1, key2)); - this.key1 = key1; - this.key2 = key2; - } - - /** The first key part this entry was created with. */ - @Nullable - public K1 key1() { - return this.key1; - } - - /** The second key part this entry was created with. */ - @Nullable - public K2 key2() { - return this.key2; - } - - public boolean matches(@Nullable K1 key1, @Nullable K2 key2) { - // equals() on the lookup params, not the fields, so the JIT can devirtualize them once - // matches() inlines into get/getOrCreate (the caller's key types are known there). - return Objects.equals(key1, this.key1) && Objects.equals(key2, this.key2); - } - - /** {@link ConcurrentHashtable.Entry#matches(Entry)} in terms of the key-based overload. */ - @Override - public final boolean matches(@Nonnull Entry other) { - return matches(other.key1, other.key2); - } - - /** Returns the 64-bit lookup hash combining both key parts via {@link LongHashingUtils}. */ - public static long hash(@Nullable Object key1, @Nullable Object key2) { - return LongHashingUtils.hash(key1, key2); - } - } + /** + * Two key components. Builds {@code factory.apply(...)} (skipped entirely if this reservation + * is definitively empty — see {@link #isReserved()}) and either links the result as a new entry + * or discards it in favor of an existing match found under the write lock. Returns {@code null} + * when there is neither a slot to claim nor a match to return -- either because this + * reservation is definitively empty, or (see {@link #tryReserve}) because the table was still + * full even under the lock and no concurrent match was found either; otherwise always returns a + * real entry (the newly built one, or the concurrent match). + * + *

The factory runs while the reservation holds the write lock. Its result is required for + * the entry-to-entry comparison, including when the table is full and no slot was claimed. + */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrInsertOrNull( + A a, B b, @Strategy @Nonnull BiFunction factory) { + return state == null ? null : finish(factory.apply(a, b)); + } - private final State> state; + /** + * Three key components; see {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)} for the + * general contract. + */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrInsertOrNull( + A a, + B b, + C c, + @Strategy @Nonnull TriFunction factory) { + return state == null ? null : finish(factory.apply(a, b, c)); + } - private D2(State> state) { - this.state = state; - } + /** Four key components; see {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)}. */ + @StrategyConsumer + @Nullable + public TEntry tryGetOrInsertOrNull( + A a, + B b, + C c, + D d, + @Strategy @Nonnull Function4 factory) { + return state == null ? null : finish(factory.apply(a, b, c, d)); + } - /** - * Creates a fixed-size table holding at most {@code maxCapacity} entries. {@code entryClass} is - * used only to infer {@code TEntry}; entries are created by the {@code creator} functions - * passed to the insertion methods. The table does not resize. - */ - @Nonnull - @SuppressWarnings("unchecked") - public static > D2 createBounded( - @Nonnull Class entryClass, int maxCapacity) { - // entryClass is erased away (see ConcurrentHashtable#createFixedBuckets), so treating it as - // Class> instead of the caller's concrete Class is safe. - Class> baseEntryClass = (Class>) (Class) entryClass; - return new D2<>(ConcurrentHashtable.createBounded(baseEntryClass, maxCapacity)); - } + /** {@link Maybe}-wrapping counterpart of {@link #tryGetOrInsertOrNull(Entry)}. */ + @Nonnull + public Maybe tryGetOrInsert(@Nonnull TEntry newEntry) { + return Maybe.of(tryGetOrInsertOrNull(newEntry)); + } - /** - * Sound because every entry ever inserted into {@link #state} was produced as a {@code TEntry}. - */ - @SuppressWarnings("unchecked") - private TEntry cast(@Nullable Entry entry) { - return (TEntry) entry; - } + /** + * {@link Maybe}-wrapping counterpart of {@link #tryGetOrInsertOrNull(Object, Function)}, for + * callers who'd rather make the "this can fail unlike unbounded collections" outcome visible in + * the return type than rely on a {@code null} check — mirrors {@link D1#tryGetOrCreate} / + * {@link D2#tryGetOrCreate} wrapping their own {@code ...OrNull} methods. + */ + @Nonnull + public Maybe tryGetOrInsert(A a, @Strategy @Nonnull Function factory) { + return Maybe.of(tryGetOrInsertOrNull(a, factory)); + } - /** See {@link #cast(Entry)}; casts the functional-interface reference, not each element. */ - @SuppressWarnings("unchecked") - private Predicate> castPredicate(Predicate predicate) { - return (Predicate>) predicate; - } + /** Two key components; see {@link #tryGetOrInsert(Object, Function)}. */ + @Nonnull + public Maybe tryGetOrInsert( + A a, B b, @Strategy @Nonnull BiFunction factory) { + return Maybe.of(tryGetOrInsertOrNull(a, b, factory)); + } - @SuppressWarnings("unchecked") - private Consumer> castConsumer(Consumer consumer) { - return (Consumer>) consumer; - } + /** Three key components; see {@link #tryGetOrInsert(Object, Function)}. */ + @Nonnull + public Maybe tryGetOrInsert( + A a, + B b, + C c, + @Strategy @Nonnull TriFunction factory) { + return Maybe.of(tryGetOrInsertOrNull(a, b, c, factory)); + } - @SuppressWarnings("unchecked") - private BiConsumer> castConsumer( - BiConsumer consumer) { - return (BiConsumer>) consumer; - } + /** Four key components; see {@link #tryGetOrInsert(Object, Function)}. */ + @Nonnull + public Maybe tryGetOrInsert( + A a, + B b, + C c, + D d, + @Strategy @Nonnull Function4 factory) { + return Maybe.of(tryGetOrInsertOrNull(a, b, c, d, factory)); + } - public int size() { - return state.sizeManager.estimateSize(); - } + /** + * Runs the locked comparison/link decision. The write lock is already held -- acquired by + * {@link #tryReserve} and not yet released -- so this needs no {@code synchronized}/{@code + * lock()} of its own. + * + *

Always scans for a match first, whether or not a slot was actually claimed: a {@link + * #tryReserve} reservation with no slot still holds the lock specifically so this scan can run. + * Only links {@code newEntry} when a slot was claimed; otherwise a miss here means there really + * is nothing to return, so this returns {@code null}. + * + * @throws IllegalStateException if this reservation was already used for a prior {@code + * tryGetOrInsertOrNull}/{@code tryGetOrInsert} call -- reusing one could link a second, + * distinct entry into the table while the size counter only reflects the first. + */ + @Nullable + private TEntry finish(@Nonnull TEntry newEntry) { + if (completed) { + throw new IllegalStateException("Reservation already used for tryGetOrInsertOrNull/tryGetOrInsert"); + } + completed = true; + int index = bucketIndex(state.buckets, newEntry.keyHash); + for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { + if (curEntry.keyHash == newEntry.keyHash && curEntry.matches(newEntry)) { + return curEntry; + } + } + if (!slotClaimed) { + return null; + } + insertHeadEntryAt(state, index, newEntry); + consumed = true; + return newEntry; + } - public boolean isFull() { - return state.sizeManager.isFull(); + /** + * Releases the write lock {@link #tryReserve} acquired, giving back a claimed slot first if it + * was never consumed. A no-op on a definitively empty reservation, which never acquired the + * lock. + */ + @Override + public void close() { + if (state == null) { + return; + } + try { + if (slotClaimed && !consumed) { + state.sizeManager.decrement(); + } + } finally { + state.writeLock.unlock(); + } + } } - @Nullable - public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { - long keyHash = D2.Entry.hash(key1, key2); - for (Entry curEntry = bucketFor(state, keyHash); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { - return cast(curEntry); - } - } - return null; + /** Four-argument analogue of {@link java.util.function.BiFunction}. */ + @Strategy + @FunctionalInterface + public interface Function4 { + R apply(A a, B b, C c, D d); } /** - * Returns the entry for {@code (key1, key2)}, creating one via {@code creator} if absent and - * the table is under capacity. Lock-free on hit; acquires a table-level lock on miss. Wraps - * {@link #tryGetOrCreateOrNull} — see that method for the refusal and ordering details. + * Reserves one slot in {@code state}, evicting an entry matching {@code evictable} when + * necessary. Returns {@code false} if the table is full and nothing can be evicted. This method + * acquires the table write lock. + * + *

The reservation survives drain and clear operations. Complete it with {@link + * #insertReserved}; abandoning it permanently consumes capacity. */ - @Nonnull - public Maybe tryGetOrCreate( - @Nullable K1 key1, - @Nullable K2 key2, - @Strategy @Nonnull BiFunction creator) { - return Maybe.of(tryGetOrCreateOrNull(key1, key2, creator)); + public static > boolean tryReserveOrEvict( + @Nonnull State state, @Strategy @Nonnull Predicate evictable) { + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); + } finally { + lock.unlock(); + } } /** - * Escape hatch for {@link #tryGetOrCreate} for callers that want the nullable entry directly - * rather than a {@link Maybe} wrapper. Returns {@code null} when the table is at capacity and - * {@code (key1, key2)} was not already present. Re-checks under the lock to avoid duplicate - * entries under concurrent misses. + * Unlinks the first entry in {@code state} matching {@code evictable}, resuming from where the + * last eviction looked, and decrements the count. {@code null} if nothing matched anywhere. + * Self-locking. */ - @StrategyConsumer @Nullable - public TEntry tryGetOrCreateOrNull( - @Nullable K1 key1, - @Nullable K2 key2, - @Strategy @Nonnull BiFunction creator) { - long keyHash = D2.Entry.hash(key1, key2); - int index = bucketIndex(state.buckets, keyHash); - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { - return cast(curEntry); - } - } - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { - return cast(curEntry); - } - } - // isFull() is checked before creating the entry, not before reserving a slot for it: - // creator.apply() can throw, and if we'd already reserved (incremented) the slot, a - // throwing creator would leak that reservation forever. So we accept the entry only after - // creator succeeds, then increment. - if (state.sizeManager.isFull()) { - return null; - } - TEntry newEntry = creator.apply(key1, key2); - insertHeadEntryAt(state, index, newEntry); - state.sizeManager.increment(); - return newEntry; - } finally { - lock.unlock(); - } + public static > TEntry evictOne( + @Nonnull State state, @Strategy @Nonnull Predicate evictable) { + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + return state.sizeManager.evictOne(state.buckets, evictable); + } finally { + lock.unlock(); + } } /** - * {@link #tryGetOrCreate}, but when the table is full, evicts one entry matching {@code - * evictable} to make room instead of refusing the insert. Refuses only when the table is full - * and nothing matches {@code evictable} — see {@link #tryGetOrCreateOrEvictOrNull} for - * the null-returning form and the eviction/creation ordering. + * Unlinks every entry in {@code state} matching {@code evictable}, decrementing per removal, and + * returns how many went. Self-locking. */ - @Nonnull - public Maybe tryGetOrCreateOrEvict( - @Nullable K1 key1, - @Nullable K2 key2, - @Strategy @Nonnull BiFunction creator, - @Strategy @Nonnull Predicate evictable) { - return Maybe.of(tryGetOrCreateOrEvictOrNull(key1, key2, creator, evictable)); + public static > int evictAll( + @Nonnull State state, @Strategy @Nonnull Predicate evictable) { + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + return state.sizeManager.evictAll(state.buckets, evictable); + } finally { + lock.unlock(); + } } + // --------------------------------------------------------------------------------------------- + // Static building blocks over a caller-owned bucket array (formerly the nested Support class). + // Use these to assemble a custom table (higher arity, primitive keys, extra value fields) when + // D1/D2 don't fit; D1/D2 delegate to them internally. The whole-table mutators (removeIf, drain, + // clear) self-lock on the array; the single-slot write primitives (insertHeadEntry, unlink) do + // not lock and must be called under the caller's own synchronized (getWriteLock(buckets, + // keyHash)) block. + // Readers + // (bucket walks, forEach) are lock-free. + // --------------------------------------------------------------------------------------------- + /** - * Escape hatch for {@link #tryGetOrCreateOrEvict} for callers that want the nullable entry - * directly. Eviction runs before {@code creator}, not after: {@code creator} may throw, so - * freeing a slot and only then attempting the fallible create keeps a thrown exception from - * ever leaving a slot double-booked. A creator that throws after a successful eviction simply - * leaves the table one entry smaller — no corruption, just a wasted eviction. + * Creates a bucket array whose length is {@link #sizeFor(int) sizeFor(capacity)}. Because {@link + * AtomicReferenceArray}'s element type is erased at runtime, {@code entryClass} is not used for + * reflective allocation or runtime type checks; it only lets the compiler infer {@code TEntry}. */ - @StrategyConsumer - @Nullable - public TEntry tryGetOrCreateOrEvictOrNull( - @Nullable K1 key1, - @Nullable K2 key2, - @Strategy @Nonnull BiFunction creator, - @Strategy @Nonnull Predicate evictable) { - long keyHash = D2.Entry.hash(key1, key2); - int index = bucketIndex(state.buckets, keyHash); - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { - return cast(curEntry); - } - } - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { - return cast(curEntry); - } - } - if (state.sizeManager.isFull() - && state.sizeManager.evictOne(state.buckets, castPredicate(evictable)) == null) { - return null; - } - TEntry newEntry = creator.apply(key1, key2); - insertHeadEntryAt(state, index, newEntry); - state.sizeManager.increment(); - return newEntry; - } finally { - lock.unlock(); - } + @Nonnull + public static > AtomicReferenceArray createFixedBuckets( + @Nonnull Class entryClass, int capacity) { + return new AtomicReferenceArray<>(sizeFor(capacity)); } + /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */ + static final int MAX_BUCKETS = 1 << 30; + /** - * Removes and returns the entry for {@code (key1, key2)}, or {@code null} if absent. Acquires - * the table-level lock to splice the chain; lock-free readers observe the removal via the - * volatile write of the predecessor's {@code next} (or the bucket head). + * Returns the bucket-array length to allocate for a table sized to hold {@code requestedSize} + * entries: {@code requestedSize} rounded up to the next power of two, capped at {@link + * #MAX_BUCKETS}. Throws {@link IllegalArgumentException} for negative inputs or inputs above the + * cap. */ - @Nullable - public TEntry remove(@Nullable K1 key1, @Nullable K2 key2) { - long keyHash = D2.Entry.hash(key1, key2); - int index = bucketIndex(state.buckets, keyHash); - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - Entry prev = null; - for (Entry curEntry = bucketAt(state, index); - curEntry != null; - prev = curEntry, curEntry = curEntry.next()) { - if (curEntry.keyHash == keyHash && curEntry.matches(key1, key2)) { - unlink(state, index, prev, curEntry); - state.sizeManager.decrement(); - return cast(curEntry); - } - } - return null; - } finally { - lock.unlock(); - } + public static int sizeFor(int requestedSize) { + if (requestedSize < 0) { + throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); + } + if (requestedSize > MAX_BUCKETS) { + throw new IllegalArgumentException( + "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); + } + if (requestedSize <= 1) { + return 1; + } + return Integer.highestOneBit(requestedSize - 1) << 1; } /** - * Removes every entry matching {@code predicate}, returning {@code true} if any were removed. - * Holds the table-level lock for the whole sweep, so the predicate sees a stable table and - * concurrent writers are excluded; lock-free readers continue throughout. + * Returns the opaque monitor guarding writes to the bucket selected by {@code keyHash}. Use this + * monitor for a keyed scan-and-mutate operation; do not depend on its identity or granularity. + * + * @see #getWriteLockAt(AtomicReferenceArray, int) + * @see #getTableWriteLock(AtomicReferenceArray) */ - public boolean removeIf(@Strategy @Nonnull Predicate predicate) { - return ConcurrentHashtable.removeIf(state, castPredicate(predicate)); + @Nonnull + public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets, long keyHash) { + return getWriteLockAt(buckets, bucketIndex(buckets, keyHash)); } /** - * Removes all entries and invokes {@code drainedEntryConsumer} for each one. - * - *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, - * so a table-level operation (an insert, a reservation, an eviction) can interleave between - * buckets instead of waiting out the entire drain. This means the drain is no longer an atomic - * snapshot: an entry inserted into a not-yet-visited bucket while the drain is in progress is - * swept up too, and each bucket's capacity slot is released immediately before its consumer - * invocation runs, rather than once for the whole sweep at the end. The consumer should be - * quick and must not throw. If it does, later entries in the detached bucket remain counted but - * are no longer reachable from the table. Buckets not yet visited remain intact, and capacity - * already released is not restored. - * - *

Use {@link #drain(Object, BiConsumer)} to avoid a capturing lambda. - * - * @param drainedEntryConsumer action invoked for each removed entry + * Returns the {@link State}'s {@link ReentrantLock}, held for the same keyed scan-and-mutate + * operations as {@link #getWriteLock(AtomicReferenceArray, long)}. Unlike the bare-array + * overload's opaque {@code synchronized} monitor, this lock can be acquired in one call and + * released in a later one -- see {@link Reservation}. */ - public void drain(@Strategy @Nonnull Consumer drainedEntryConsumer) { - ConcurrentHashtable.drain(state, castConsumer(drainedEntryConsumer)); + @Nonnull + public static ReentrantLock getWriteLock(@Nonnull State state, long keyHash) { + return state.writeLock; } /** - * Context-passing {@link #drain(Consumer)}. The lock is acquired and released bucket by bucket - * rather than held for the whole sweep, so a table-level operation (an insert, a reservation, - * an eviction) can interleave between buckets instead of waiting out the entire drain. This - * means the drain is no longer an atomic snapshot: an entry inserted into a not-yet-visited - * bucket while the drain is in progress is swept up too, and each bucket's capacity slot is - * released immediately before its consumer invocation runs, rather than once for the whole - * sweep at the end. - * - *

Pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus the - * accumulator as {@code context} (e.g. the target list or event builder) to avoid a - * capturing-lambda allocation. - * - * @param context type - * @param context context passed to each invocation of {@code drainedEntryConsumer} - * @param drainedEntryConsumer action invoked with the context and each removed entry + * {@link #getWriteLock(AtomicReferenceArray, long)} for a bucket index that has already been + * computed — the shape a {@code getOrCreate} wants when it reuses the index from its lock-free + * pre-check. This is the primitive; the {@code keyHash} form maps the hash through {@link + * #bucketIndex} and calls it. */ - public void drain( - C context, @Strategy @Nonnull BiConsumer drainedEntryConsumer) { - ConcurrentHashtable.drain(state, context, castConsumer(drainedEntryConsumer)); + @Nonnull + public static Object getWriteLockAt(@Nonnull AtomicReferenceArray buckets, int bucketIndex) { + return buckets; } - /** Removes all entries. Lock-free readers mid-walk complete against the entries they hold. */ - public void clear() { - ConcurrentHashtable.clear(state); + /** {@link #getWriteLock(State, long)}, for a bucket index that has already been computed. */ + @Nonnull + public static ReentrantLock getWriteLockAt(@Nonnull State state, int bucketIndex) { + return state.writeLock; } - public void forEach(@Strategy @Nonnull Consumer consumer) { - ConcurrentHashtable.forEach(state, castConsumer(consumer)); + /** + * Returns the opaque monitor guarding operations that span all buckets. Whole-table helpers such + * as {@link #drain}, {@link #clear}, and {@link #removeIf} acquire this monitor internally. + */ + @Nonnull + public static Object getTableWriteLock(@Nonnull AtomicReferenceArray buckets) { + return buckets; } /** - * Context-passing forEach. Avoids a capturing-lambda allocation — pass a non-capturing {@link - * BiConsumer} (typically a {@code static final}) plus whatever side-band state it needs. + * Returns the {@link State}'s {@link ReentrantLock}, guarding operations that span all buckets. + * Unlike {@link #getTableWriteLock(AtomicReferenceArray)}'s opaque {@code synchronized} monitor, + * this lock can be acquired in one call and released in a later one -- see {@link Reservation}. */ - public void forEach( - C context, @Strategy @Nonnull BiConsumer consumer) { - ConcurrentHashtable.forEach(state, context, castConsumer(consumer)); + @Nonnull + public static ReentrantLock getTableWriteLock(@Nonnull State state) { + return state.writeLock; + } + + public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long keyHash) { + // Bucket lengths are powers of two, so masking replaces a more expensive modulo operation. + return (int) (keyHash & (buckets.length() - 1)); } - } - - /** - * Tracks a capped table's occupancy and eviction position. - * - *

The count includes live entries and outstanding reservations. Its {@link AtomicInteger} - * provides volatile visibility and atomic updates, allowing size queries and {@link - * #tryReserve()} without the table lock. The plain {@code evictionCursor} is instead protected by - * the table write lock; methods annotated with {@link GuardedBy} require that lock. - */ - @ThreadSafe - public static final class SizeManager { - private final AtomicInteger size = new AtomicInteger(); - private final int capacity; /** - * Bucket index the last eviction removed from. The next scan resumes here, so a sustained - * eviction stream doesn't repeatedly re-walk the same hot entries clustered near bucket 0. + * Returns the head entry of the bucket that {@code keyHash} maps to. The bucket read is a + * volatile read of the slot, so it is safe from any thread without a lock. + * + *

Named distinctly from {@link #bucketAt} (rather than overloaded on {@code long} vs. {@code + * int}) deliberately: a caller with a primitive {@code int}-typed key hash that called an + * overloaded {@code bucket(buckets, intHash)} would silently bind to the {@code int}-index + * overload instead of widening to this one, reading the raw hash as an array index — out-of-range + * hashes throw {@link IndexOutOfBoundsException}, in-range-but-wrong ones silently read the wrong + * bucket. */ - @GuardedBy("the corresponding State's getTableWriteLock(state)") - private int evictionCursor; - - public SizeManager(int capacity) { - this.capacity = capacity; + @Nullable + public static > TEntry bucketFor( + @Nonnull AtomicReferenceArray buckets, long keyHash) { + return buckets.get(bucketIndex(buckets, keyHash)); } - /** Live entries. Safe to call without the write lock. */ - public int estimateSize() { - return size.get(); + /** {@link #bucketFor(AtomicReferenceArray, long)} over a {@link State}. */ + @Nullable + public static > TEntry bucketFor(@Nonnull State state, long keyHash) { + return bucketFor(state.buckets, keyHash); } - public int capacity() { - return capacity; + /** + * Returns the head entry of the bucket at {@code index}. Use when the bucket index is already + * computed (e.g. inside {@code getOrCreate} where the same index is reused across the lock + * boundary). See {@link #bucketFor} for why this is a distinct name rather than an {@code int} + * overload of it. + */ + @Nullable + public static > TEntry bucketAt( + @Nonnull AtomicReferenceArray buckets, int index) { + return buckets.get(index); } - /** {@code true} once {@link #estimateSize()} has reached {@link #capacity()}. */ - public boolean isFull() { - return size.get() >= capacity; + /** {@link #bucketAt(AtomicReferenceArray, int)} over a {@link State}. */ + @Nullable + public static > TEntry bucketAt(@Nonnull State state, int index) { + return bucketAt(state.buckets, index); } /** - * Atomically reserves one slot without taking the table write lock. It claims first with {@link - * AtomicInteger#incrementAndGet()} and refunds values above capacity, so concurrent callers - * cannot both acquire the last slot. Returns {@code false} with the count unchanged when the - * table is full. + * Returns a lock-free iterator over the candidates for {@code keyHash}: entries in the bucket + * chain that {@code keyHash} maps to (starting from {@link #bucketFor(AtomicReferenceArray, + * long)}) whose own {@link Entry#keyHash} equals it, skipping any other entry sharing the same + * bucket via hash collision on {@link #bucketIndex}. Callers only need a {@code matches} check + * against the entries this yields, not a {@code keyHash} check of their own. * - *

Every successful claim must be filled or released with {@link #decrement()}. Prefer {@link - * ConcurrentHashtable#tryReserve(State, long)}, whose {@link Reservation#close()} gives back an - * unused claim automatically. + *

Each step follows {@link Entry#next()}, so the iterator reflects entries linked at the time + * each step runs rather than a point-in-time snapshot -- entries inserted ahead of the iterator's + * current position after iteration starts may or may not be observed, and a concurrently removed + * entry remains reachable because {@code unlink()} deliberately retains its {@code next} link for + * in-flight readers. */ - public boolean tryReserve() { - if (size.incrementAndGet() > capacity) { - size.decrementAndGet(); - return false; - } - return true; + @Nonnull + public static > Iterator hashIterator( + @Nonnull AtomicReferenceArray buckets, long keyHash) { + return new Iterator() { + private TEntry next = advance(bucketFor(buckets, keyHash)); + + private TEntry advance(TEntry candidate) { + while (candidate != null && candidate.keyHash != keyHash) { + candidate = candidate.next(); + } + return candidate; + } + + @Override + public boolean hasNext() { + return next != null; + } + + @Override + public TEntry next() { + TEntry current = next; + if (current == null) { + throw new NoSuchElementException(); + } + next = advance(current.next()); + return current; + } + }; + } + + /** {@link #hashIterator(AtomicReferenceArray, long)} over a {@link State}. */ + @Nonnull + public static > Iterator hashIterator( + @Nonnull State state, long keyHash) { + return hashIterator(state.buckets, keyHash); } /** - * Reserves one slot, evicting an entry matching {@code evictable} when the table is full. - * Returns {@code false} without changing the table when no entry can be evicted. - * - *

The reservation survives concurrent drain and clear operations. The caller must fill it; - * an abandoned reservation permanently consumes capacity. + * {@link Iterable} wrapper around {@link #hashIterator(AtomicReferenceArray, long)}, for callers + * that want a plain for-each loop over the candidates for {@code keyHash} rather than driving the + * {@link Iterator} by hand. */ - @GuardedBy("the corresponding State's getTableWriteLock(state)") - public > boolean tryReserveOrEvict( - @Nonnull AtomicReferenceArray buckets, - @Strategy @Nonnull Predicate evictable) { - if (tryReserve()) { - return true; - } - if (evictOne(buckets, evictable) == null) { - return false; - } - // evictOne already decremented; the slot it freed is ours. - size.incrementAndGet(); - return true; + @Nonnull + public static > Iterable hashIterable( + @Nonnull AtomicReferenceArray buckets, long keyHash) { + return () -> hashIterator(buckets, keyHash); } - /** Call after successfully linking a new entry. */ - public void increment() { - size.incrementAndGet(); + /** {@link #hashIterable(AtomicReferenceArray, long)} over a {@link State}. */ + @Nonnull + public static > Iterable hashIterable( + @Nonnull State state, long keyHash) { + return hashIterable(state.buckets, keyHash); } - /** Call after successfully unlinking an entry. */ - public void decrement() { - size.decrementAndGet(); + /** + * Publishes {@code entry} as the head of bucket {@code index}. The helper writes the entry's + * {@code next} link before the volatile {@link AtomicReferenceArray#set}; a volatile bucket read + * that observes the new head also sees the initialized entry and its link. The caller must hold + * {@link #getWriteLockAt}; this method does not acquire a lock or update size accounting. + * + *

The entry must be unlinked and must not be reused after removal. Removal intentionally + * retains its {@code next} link for readers already traversing that chain. + */ + @GuardedBy("getWriteLockAt(buckets, index)") + public static > void insertHeadEntryAt( + @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { + assert Thread.holdsLock(getWriteLockAt(buckets, index)) + : "insertHeadEntryAt called without holding getWriteLockAt(buckets, index)"; + insertHeadEntryAtUnchecked(buckets, index, entry); + } + + /** {@link #insertHeadEntryAt(AtomicReferenceArray, int, Entry)} over a {@link State}. */ + @GuardedBy("getWriteLockAt(state, index)") + public static > void insertHeadEntryAt( + @Nonnull State state, int index, @Nonnull TEntry entry) { + assert getWriteLockAt(state, index).isHeldByCurrentThread() + : "insertHeadEntryAt called without holding getWriteLockAt(state, index)"; + insertHeadEntryAtUnchecked(state.buckets, index, entry); + } + + /** Splicing logic shared by both {@link #insertHeadEntryAt} overloads, without their assert. */ + private static > void insertHeadEntryAtUnchecked( + @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { + assert entry.next() == null + : "Entry already linked -- inserting the same Entry instance twice corrupts the chain" + + " (unlink() deliberately leaves a removed entry's next intact for in-flight" + + " readers, so a removed entry must never be reinserted)"; + entry.setNext(buckets.get(index)); + buckets.set(index, entry); } /** - * Releases {@code removed} slots after a sweep and resets the {@code evictionCursor}. - * Outstanding reservations remain counted. + * Convenience form of {@link #insertHeadEntryAt} that derives the bucket index from {@code + * keyHash}. Prefer {@link #insertHeadEntryAt} when the index is already computed (e.g. a {@code + * getOrCreate} that reuses it across the lock-free pre-check). */ - @GuardedBy("the corresponding State's getTableWriteLock(state)") - @SuppressFBWarnings( - value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", - justification = - "evictionCursor is read and written only while the corresponding State's write lock is" - + " held; SpotBugs cannot model that dynamic guard") - public void release(int removed) { - if (removed != 0) { - size.addAndGet(-removed); - } - evictionCursor = 0; + @GuardedBy("getWriteLock(buckets, keyHash)") + public static > void insertHeadEntryFor( + @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) { + insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); } /** - * Removes and returns the first entry matching {@code evictable}, scanning from the previous - * eviction position and wrapping once. Returns {@code null} without changing the count when no - * entry matches. + * Links an already-built entry after a successful {@link #tryReserveOrEvict} or {@link + * SizeManager#tryReserve()} call. This method does not acquire a lock or update the count. * - *

This operation may inspect every live entry while holding the table write lock, so the - * predicate should be quick. + *

Complete all fallible work before reserving. There is no cancellation operation, so an + * abandoned reservation permanently consumes capacity. Drain and clear do not cancel outstanding + * reservations. */ - @GuardedBy("the corresponding State's getTableWriteLock(state)") - @Nullable - public > TEntry evictOne( - @Nonnull AtomicReferenceArray buckets, - @Strategy @Nonnull Predicate evictable) { - TEntry evicted = evictOneInRange(buckets, evictable, evictionCursor, buckets.length()); - if (evicted == null && evictionCursor != 0) { - evicted = evictOneInRange(buckets, evictable, 0, evictionCursor); - } - if (evicted != null) { - size.decrementAndGet(); - return evicted; - } - // Nothing matched anywhere; step the evictionCursor on regardless so repeated refusals don't - // all - // restart the (wasted) scan from the same bucket. - evictionCursor = bucketIndex(buckets, evictionCursor + 1); - return null; - } - - @GuardedBy("the corresponding State's getTableWriteLock(state)") - @SuppressFBWarnings( - value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", - justification = - "evictionCursor is read and written only while the corresponding State's write lock is" - + " held; SpotBugs cannot model that dynamic guard") - @StrategyConsumer - @Nullable - private > TEntry evictOneInRange( - @Nonnull AtomicReferenceArray buckets, - @Strategy @Nonnull Predicate evictable, - int startBucket, - int endBucket) { - for (int i = startBucket; i < endBucket; i++) { - TEntry prev = null; - for (TEntry e = buckets.get(i); e != null; e = e.next()) { - if (evictable.test(e)) { - unlinkUnchecked(buckets, i, prev, e); - evictionCursor = i; - return e; - } - prev = e; - } - } - return null; + @GuardedBy("getTableWriteLock(state)") + public static > void insertReserved( + @Nonnull State state, long keyHash, @Nonnull TEntry entry) { + assert getTableWriteLock(state).isHeldByCurrentThread() + : "insertReserved called without holding getTableWriteLock(state)"; + insertHeadEntryAtUnchecked(state.buckets, bucketIndex(state.buckets, keyHash), entry); } /** - * Unlinks every entry matching {@code evictable} in one full pass, decrementing the count for - * each, and returns how many were removed. Resets the scan position, since a full pass leaves - * nothing later to resume from. + * Splices {@code entry} out of the chain at {@code index}. {@code prev} is the in-chain + * predecessor, or {@code null} when {@code entry} is the bucket head. Re-points the predecessor + * (or the bucket head slot) past {@code entry} via a volatile write so lock-free readers see the + * removal. {@code entry}'s own {@code next} is deliberately left intact so a reader already + * positioned on it can still traverse forward. This is a single-slot primitive: it does not lock, + * so call it inside the caller's {@code synchronized (getWriteLockAt(buckets, index))} block. + * Does not touch size accounting. */ - @GuardedBy("the corresponding State's getTableWriteLock(state)") - @SuppressFBWarnings( - value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", - justification = - "evictionCursor is read and written only while the corresponding State's write lock is" - + " held; SpotBugs cannot model that dynamic guard") - @StrategyConsumer - public > int evictAll( - @Nonnull AtomicReferenceArray buckets, - @Strategy @Nonnull Predicate evictable) { - int count = 0; - for (int i = 0; i < buckets.length(); i++) { - TEntry prev = null; - for (TEntry e = buckets.get(i); e != null; e = e.next()) { - if (evictable.test(e)) { - unlinkUnchecked(buckets, i, prev, e); - size.decrementAndGet(); - count++; - } else { - prev = e; - } - } - } - evictionCursor = 0; - return count; + @GuardedBy("getWriteLockAt(buckets, index)") + public static > void unlink( + @Nonnull AtomicReferenceArray buckets, int index, @Nullable TEntry prev, @Nonnull TEntry entry) { + assert Thread.holdsLock(getWriteLockAt(buckets, index)) + : "unlink called without holding getWriteLockAt(buckets, index)"; + unlinkUnchecked(buckets, index, prev, entry); + } + + /** {@link #unlink(AtomicReferenceArray, int, Entry, Entry)} over a {@link State}. */ + @GuardedBy("getWriteLockAt(state, index)") + public static > void unlink( + @Nonnull State state, int index, @Nullable TEntry prev, @Nonnull TEntry entry) { + assert getWriteLockAt(state, index).isHeldByCurrentThread() + : "unlink called without holding getWriteLockAt(state, index)"; + unlinkUnchecked(state.buckets, index, prev, entry); + } + + /** Splicing logic shared by both {@link #unlink} overloads, without their assert. */ + private static > void unlinkUnchecked( + @Nonnull AtomicReferenceArray buckets, int index, @Nullable TEntry prev, @Nonnull TEntry entry) { + TEntry next = entry.next(); + if (prev == null) { + buckets.set(index, next); + } else { + prev.setNext(next); + } } - } - - /** - * Bucket array and occupancy manager for a caller-defined capped table. Keep them paired and - * prefer the {@code State}-accepting helpers so structural changes update the count consistently. - * - *

{@code sizeManager} is intentionally package-private: callers outside this class must go - * through the {@code State}-accepting static helpers ({@link #estimateSize}, {@link #isFull}, - * {@link #tryReserve}, {@link #tryReserveOrEvict}, {@link #evictOne}, {@link #evictAll}) rather - * than reach into the manager directly. - */ - public static final class State> { - public final AtomicReferenceArray buckets; - final SizeManager sizeManager; /** - * The table's write lock. Unlike the bare-array helpers' opaque {@code synchronized} monitor, - * this is a real {@link ReentrantLock}. A {@link Reservation} can acquire it in {@link - * ConcurrentHashtable#tryReserve(State, long)} and release it later in {@link - * Reservation#close()}, unlike a lexically scoped {@code synchronized} block. + * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code size} + * once per removal. Self-locking: synchronizes on {@code buckets} for the whole sweep, so the + * predicate sees a stable table and concurrent writers are excluded; lock-free readers continue + * throughout. */ - final ReentrantLock writeLock = new ReentrantLock(); - - private State(AtomicReferenceArray buckets, int maxCapacity) { - this.buckets = buckets; - this.sizeManager = new SizeManager(maxCapacity); - } - } - - /** - * Creates a bucket array for {@code maxCapacity} entries and pairs it with a manager enforcing - * that cap. {@code entryClass} is used only to infer {@code TEntry}. - */ - @Nonnull - public static > State createBounded( - @Nonnull Class entryClass, int maxCapacity) { - return new State<>(createFixedBuckets(entryClass, maxCapacity), maxCapacity); - } - - /** Live entries in {@code state}; see {@link SizeManager#estimateSize()}. Lock-free. */ - public static int estimateSize(@Nonnull State state) { - return state.sizeManager.estimateSize(); - } - - /** - * {@code true} once {@code state} is at capacity; see {@link SizeManager#isFull()}. Lock-free. - */ - public static boolean isFull(@Nonnull State state) { - return state.sizeManager.isFull(); - } - - /** - * Claims one slot in {@code state} for {@code keyHash} and returns a handle for completing the - * find-or-insert protocol, or an empty handle if the table is full. Never returns {@code null}, - * so this always composes with try-with-resources: - * - *

{@code
-   * try (Reservation r = ConcurrentHashtable.tryReserve(state, keyHash)) {
-   *   return r.tryGetOrInsertOrNull(component1, component2, component3, TEntry::new);
-   * }
-   * }
- * - *

Run a lock-free scan first (see {@link #bucketFor}/{@link #bucketAt}) and only call this - * once that scan has missed — a successful reservation isn't required for correctness (the - * reservation itself, and the locked comparison inside {@link Reservation#tryGetOrInsertOrNull}, - * are the source of truth), it just avoids paying for a lock and a factory call when a hit was - * already visible lock-free. - * - *

Checks {@link SizeManager#isFull()} lock-free first. If the table looks full, {@code - * keyHash}'s bucket is checked too: if it's empty, nothing could possibly match this key -- - * unless a concurrent reservation for this exact key is in flight, having already - * claimed the table's last slot (so {@code isFull()} is genuinely {@code true}) without yet - * linking its entry (so the bucket still looks empty). That window is confirmed away under the - * write lock before this returns a definitively empty reservation without ever taking it -- - * otherwise the concurrent duplicate landing in that exact window would be dropped instead of - * counted. Once the lock is held (either because the fast peek above couldn't rule out a match, - * or because the locked recheck couldn't either), this reserves the slot -- returning a real - * reservation that holds the lock open across this call, to be released later by {@link - * Reservation#close()} -- or, if the table is genuinely still full, keeps the lock open anyway - * (rather than releasing it immediately) so {@link Reservation#finish} can still scan {@code - * keyHash}'s bucket the next time the caller supplies a candidate entry. That's what closes the - * race where a concurrent insert for the same logical duplicate lands in the exact window between - * this caller's own lock-free scan and its reservation attempt: without the lock held open here, - * that concurrent duplicate would go uncounted. Holding the lock for the reservation's whole - * lifetime (rather than just across this call, the way {@link SizeManager#tryReserve()} does) is - * also what lets {@link Reservation#tryGetOrInsertOrNull} skip a second, separately-locked - * comparison pass: the reserve-then-insert-or-discard sequence is one uninterrupted critical - * section, so no concurrent reservation for the same logical duplicate can slip in between. - * - *

Always returns a non-null handle — even when the table is full — so the caller must check - * {@link Reservation#isReserved()} (or simply call {@link Reservation#tryGetOrInsertOrNull}, - * which returns {@code null} on an absent reservation) rather than assume every reservation is - * real. A reservation can also report {@link Reservation#isReserved()} {@code true} while still - * having no slot to insert into (the lock-held-but-full case above); either way, {@code - * tryGetOrInsertOrNull}/{@code tryGetOrInsert} return the concurrent match found under the lock, - * or {@code null} -- never a newly linked entry when no slot was claimed. - * - * @param keyHash hash of the key the caller is about to look up or insert - */ - @Nonnull - @SuppressFBWarnings( - value = "UL_UNRELEASED_LOCK", - justification = - "Every path either unlocks before returning (the confirmed-empty case) or hands the" - + " held lock off to the returned Reservation, released later by Reservation#close;" - + " SpotBugs can't correlate that transfer across the two lock() call sites here.") - public static > Reservation tryReserve( - @Nonnull State state, long keyHash) { - int index = bucketIndex(state.buckets, keyHash); - ReentrantLock lock = state.writeLock; - if (state.sizeManager.isFull() && bucketAt(state, index) == null) { - lock.lock(); - // The lock-free peek above can be stale: a concurrent reservation for this exact key may - // have already claimed the table's last slot (isFull() true) without yet linking its entry - // (bucket still looks empty). Recheck under the lock before trusting it, or that concurrent - // duplicate would be dropped instead of counted. - if (state.sizeManager.isFull() && bucketAt(state, index) == null) { - lock.unlock(); - return new Reservation<>(null, false); - } - if (state.sizeManager.tryReserve()) { - return new Reservation<>(state, true); - } - return new Reservation<>(state, false); - } - lock.lock(); - if (state.sizeManager.tryReserve()) { - return new Reservation<>(state, true); - } - return new Reservation<>(state, false); - } - - /** - * Handle returned by {@link #tryReserve}, gating {@link #tryGetOrInsertOrNull} behind a claimed - * slot and auto-cancelling it on {@link #close} if it's never consumed. A single {@code - * Reservation} must be used for at most one {@code tryGetOrInsertOrNull}/{@code tryGetOrInsert} - * call -- a second call throws {@link IllegalStateException} rather than silently linking a - * second entry while the size counter only reflects the first. - * - *

Overloaded up to 4 key components ({@link #tryGetOrInsertOrNull(Object, Function)} through - * {@link #tryGetOrInsertOrNull(Object, Object, Object, Object, Function4)}) so a non-capturing - * method reference can build the entry directly from its natural constructor arguments, without - * an intermediate holder object or a capturing lambda. - * - *

A non-empty reservation holds the table's write lock until {@link #close()}, even after - * insertion or a matching entry is returned. Keep its lifetime short: other writers using this - * lock must wait until the reservation is closed. - * - *

Create, use, and close the reservation on the same thread: its {@link ReentrantLock} must be - * released by the thread that acquired it. - * - * @param the table's entry type, itself self-bound (see {@link - * ConcurrentHashtable.Entry}) - */ - @NotThreadSafe - public static final class Reservation> implements AutoCloseable { - @Nullable private final State state; - private final boolean slotClaimed; - private boolean completed; - private boolean consumed; - - private Reservation(@Nullable State state, boolean slotClaimed) { - this.state = state; - this.slotClaimed = slotClaimed; + @StrategyConsumer + public static > boolean removeIf( + @Nonnull AtomicReferenceArray buckets, + @Nonnull AtomicInteger size, + @Strategy @Nonnull Predicate predicate) { + synchronized (getTableWriteLock(buckets)) { + boolean removed = false; + for (int i = 0; i < buckets.length(); i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (predicate.test(e)) { + unlink(buckets, i, prev, e); + size.decrementAndGet(); + removed = true; + // prev stays put: e is now unlinked, so the last survivor remains the predecessor. + } else { + prev = e; + } + } + } + return removed; + } } /** - * {@code true} unless this reservation is definitively empty -- i.e. {@link #tryReserve} could - * rule out a match for the target key without even taking the write lock. A {@code true} - * reservation may still have no slot to insert into: {@link #tryReserve} keeps the lock open on - * a full table when the target bucket is populated, purely so {@link #finish} can still scan it - * for a concurrent match. Callers should call {@code tryGetOrInsertOrNull}/{@code - * tryGetOrInsert} and check its result rather than assume {@code true} means a slot was - * claimed. + * {@link #removeIf(AtomicReferenceArray, AtomicInteger, Predicate)} variant for callers tracking + * occupancy with a {@link State} instead of a bare counter — used by {@link D1#removeIf} and + * {@link D2#removeIf}. */ - public boolean isReserved() { - return state != null; + @StrategyConsumer + public static > boolean removeIf( + @Nonnull State state, @Strategy @Nonnull Predicate predicate) { + // TODO: no caller contends on this lock today; if one does, consider the same per-bucket + // locking drain() uses, trading the "predicate sees a stable table" guarantee for throughput. + AtomicReferenceArray buckets = state.buckets; + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + boolean removed = false; + for (int i = 0; i < buckets.length(); i++) { + TEntry prev = null; + for (TEntry e = buckets.get(i); e != null; e = e.next()) { + if (predicate.test(e)) { + unlink(state, i, prev, e); + state.sizeManager.decrement(); + removed = true; + } else { + prev = e; + } + } + } + return removed; + } finally { + lock.unlock(); + } } /** - * Escape hatch for a caller that already built {@code newEntry} itself -- e.g. more than 4 key - * components, or components the caller wants to keep as primitives rather than boxing them into - * a {@code Function}'s type argument: + * Removes all entries from {@code buckets} and invokes {@code drainedEntryConsumer} for each one. * - *

{@code
-     * try (Reservation r = ConcurrentHashtable.tryReserve(state, keyHash)) {
-     *   if (!r.isReserved()) {
-     *     return null;
-     *   }
-     *   return r.tryGetOrInsertOrNull(new TEntry(longComponent1, longComponent2));
-     * }
-     * }
+ *

The drain holds the table write lock while detaching buckets and invoking the consumer. For + * each removed entry, the consumer is invoked synchronously after its bucket is detached. A + * lock-free reader already traversing the detached chain can continue through its retained links. * - * See {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)} for the general contract. + *

This overload does not update size accounting. The consumer should be quick and must not + * throw; failures are not rolled back. + * + * @param entry type + * @param buckets bucket array to drain + * @param drainedEntryConsumer action invoked for each entry after its bucket is detached */ - @Nullable - public TEntry tryGetOrInsertOrNull(@Nonnull TEntry newEntry) { - return state == null ? null : finish(newEntry); + public static > void drain( + @Nonnull AtomicReferenceArray buckets, + @Strategy @Nonnull Consumer drainedEntryConsumer) { + drainCounting(buckets, drainedEntryConsumer); } /** - * One key component; see {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)} for the - * general contract. + * {@link #drain(AtomicReferenceArray, Consumer)} returning the number of entries passed to {@code + * drainedEntryConsumer} for size accounting. */ @StrategyConsumer - @Nullable - public TEntry tryGetOrInsertOrNull( - A a, @Strategy @Nonnull Function factory) { - return state == null ? null : finish(factory.apply(a)); + private static > int drainCounting( + @Nonnull AtomicReferenceArray buckets, + @Strategy @Nonnull Consumer drainedEntryConsumer) { + int removed = 0; + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + TEntry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (TEntry e = head; e != null; e = e.next()) { + removed++; + drainedEntryConsumer.accept(e); + } + } + } + return removed; } /** - * Two key components. Builds {@code factory.apply(...)} (skipped entirely if this reservation - * is definitively empty — see {@link #isReserved()}) and either links the result as a new entry - * or discards it in favor of an existing match found under the write lock. Returns {@code null} - * when there is neither a slot to claim nor a match to return -- either because this - * reservation is definitively empty, or (see {@link #tryReserve}) because the table was still - * full even under the lock and no concurrent match was found either; otherwise always returns a - * real entry (the newly built one, or the concurrent match). + * Removes all entries from {@code buckets} and invokes {@code drainedEntryConsumer} with {@code + * context} and each removed entry. * - *

The factory runs while the reservation holds the write lock. Its result is required for - * the entry-to-entry comparison, including when the table is full and no slot was claimed. - */ - @StrategyConsumer - @Nullable - public TEntry tryGetOrInsertOrNull( - A a, B b, @Strategy @Nonnull BiFunction factory) { - return state == null ? null : finish(factory.apply(a, b)); - } - - /** - * Three key components; see {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)} for the - * general contract. + *

The drain holds the table write lock while detaching buckets and invoking the consumer. For + * each removed entry, the consumer is invoked synchronously after its bucket is detached. This + * overload does not update size accounting. The consumer should be quick and must not throw; + * failures are not rolled back. + * + * @param context type + * @param entry type + * @param buckets bucket array to drain + * @param context context passed to each invocation of {@code drainedEntryConsumer} + * @param drainedEntryConsumer action invoked with the context and each removed entry */ - @StrategyConsumer - @Nullable - public TEntry tryGetOrInsertOrNull( - A a, - B b, - C c, - @Strategy @Nonnull TriFunction factory) { - return state == null ? null : finish(factory.apply(a, b, c)); + public static > void drain( + @Nonnull AtomicReferenceArray buckets, + C context, + @Strategy @Nonnull BiConsumer drainedEntryConsumer) { + drainCounting(buckets, context, drainedEntryConsumer); } - /** Four key components; see {@link #tryGetOrInsertOrNull(Object, Object, BiFunction)}. */ + /** {@link #drainCounting(AtomicReferenceArray, Consumer)}, context-passing form. */ @StrategyConsumer - @Nullable - public TEntry tryGetOrInsertOrNull( - A a, - B b, - C c, - D d, - @Strategy @Nonnull - Function4 factory) { - return state == null ? null : finish(factory.apply(a, b, c, d)); - } - - /** {@link Maybe}-wrapping counterpart of {@link #tryGetOrInsertOrNull(Entry)}. */ - @Nonnull - public Maybe tryGetOrInsert(@Nonnull TEntry newEntry) { - return Maybe.of(tryGetOrInsertOrNull(newEntry)); + private static > int drainCounting( + @Nonnull AtomicReferenceArray buckets, + C context, + @Strategy @Nonnull BiConsumer drainedEntryConsumer) { + int removed = 0; + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + TEntry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (TEntry e = head; e != null; e = e.next()) { + removed++; + drainedEntryConsumer.accept(context, e); + } + } + } + return removed; } /** - * {@link Maybe}-wrapping counterpart of {@link #tryGetOrInsertOrNull(Object, Function)}, for - * callers who'd rather make the "this can fail unlike unbounded collections" outcome visible in - * the return type than rely on a {@code null} check — mirrors {@link D1#tryGetOrCreate} / - * {@link D2#tryGetOrCreate} wrapping their own {@code ...OrNull} methods. + * Removes all entries from {@code state}, invokes {@code drainedEntryConsumer} for each one, and + * releases one capacity slot for each removed entry. + * + *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, so + * a table-level operation (an insert, a reservation, an eviction) can interleave between buckets + * instead of waiting out the entire drain. This means the drain is no longer an atomic snapshot: + * an entry inserted into a not-yet-visited bucket while the drain is in progress is swept up too, + * and each bucket's capacity slot is released immediately before its consumer invocation runs, + * rather than once for the whole sweep at the end. Outstanding reservations remain counted. + * + *

The consumer should be quick and must not throw. If it does, later entries in the detached + * bucket remain counted but are no longer reachable from the table. Buckets not yet visited + * remain intact, and capacity already released is not restored. + * + * @param entry type + * @param state table state to drain + * @param drainedEntryConsumer action invoked for each entry after its bucket is detached */ - @Nonnull - public Maybe tryGetOrInsert( - A a, @Strategy @Nonnull Function factory) { - return Maybe.of(tryGetOrInsertOrNull(a, factory)); - } - - /** Two key components; see {@link #tryGetOrInsert(Object, Function)}. */ - @Nonnull - public Maybe tryGetOrInsert( - A a, B b, @Strategy @Nonnull BiFunction factory) { - return Maybe.of(tryGetOrInsertOrNull(a, b, factory)); + public static > void drain( + @Nonnull State state, @Strategy @Nonnull Consumer drainedEntryConsumer) { + ReentrantLock lock = getTableWriteLock(state); + AtomicReferenceArray buckets = state.buckets; + for (int i = 0; i < buckets.length(); i++) { + lock.lock(); + try { + TEntry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (TEntry e = head; e != null; e = e.next()) { + state.sizeManager.decrement(); + drainedEntryConsumer.accept(e); + } + } finally { + lock.unlock(); + } + } + lock.lock(); + try { + state.sizeManager.release(0); // full sweep: reset the scan position + } finally { + lock.unlock(); + } } - /** Three key components; see {@link #tryGetOrInsert(Object, Function)}. */ - @Nonnull - public Maybe tryGetOrInsert( - A a, - B b, - C c, - @Strategy @Nonnull TriFunction factory) { - return Maybe.of(tryGetOrInsertOrNull(a, b, c, factory)); + /** + * Removes all entries from {@code state}, invokes {@code drainedEntryConsumer} with {@code + * context} and each removed entry, and releases one capacity slot for each removed entry. + * + *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, so + * a table-level operation (an insert, a reservation, an eviction) can interleave between buckets + * instead of waiting out the entire drain. This means the drain is no longer an atomic snapshot: + * an entry inserted into a not-yet-visited bucket while the drain is in progress is swept up too, + * and each bucket's capacity slot is released immediately before its consumer invocation runs, + * rather than once for the whole sweep at the end. Outstanding reservations remain counted. + * + *

The consumer should be quick and must not throw. If it does, later entries in the detached + * bucket remain counted but are no longer reachable from the table. Buckets not yet visited + * remain intact, and capacity already released is not restored. + * + * @param context type + * @param entry type + * @param state table state to drain + * @param context context passed to each invocation of {@code drainedEntryConsumer} + * @param drainedEntryConsumer action invoked with the context and each removed entry + */ + public static > void drain( + @Nonnull State state, + C context, + @Strategy @Nonnull BiConsumer drainedEntryConsumer) { + ReentrantLock lock = getTableWriteLock(state); + AtomicReferenceArray buckets = state.buckets; + for (int i = 0; i < buckets.length(); i++) { + lock.lock(); + try { + TEntry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (TEntry e = head; e != null; e = e.next()) { + state.sizeManager.decrement(); + drainedEntryConsumer.accept(context, e); + } + } finally { + lock.unlock(); + } + } + lock.lock(); + try { + state.sizeManager.release(0); // full sweep: reset the scan position + } finally { + lock.unlock(); + } } - /** Four key components; see {@link #tryGetOrInsert(Object, Function)}. */ - @Nonnull - public Maybe tryGetOrInsert( - A a, - B b, - C c, - D d, - @Strategy @Nonnull - Function4 factory) { - return Maybe.of(tryGetOrInsertOrNull(a, b, c, d, factory)); + /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */ + public static void clear(@Nonnull AtomicReferenceArray buckets) { + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + buckets.set(i, null); + } + } } /** - * Runs the locked comparison/link decision. The write lock is already held -- acquired by - * {@link #tryReserve} and not yet released -- so this needs no {@code synchronized}/{@code - * lock()} of its own. - * - *

Always scans for a match first, whether or not a slot was actually claimed: a {@link - * #tryReserve} reservation with no slot still holds the lock specifically so this scan can run. - * Only links {@code newEntry} when a slot was claimed; otherwise a miss here means there really - * is nothing to return, so this returns {@code null}. - * - * @throws IllegalStateException if this reservation was already used for a prior {@code - * tryGetOrInsertOrNull}/{@code tryGetOrInsert} call -- reusing one could link a second, - * distinct entry into the table while the size counter only reflects the first. + * {@link #clear(AtomicReferenceArray)} returning how many entries it detached, so a {@link State} + * form can subtract exactly that rather than zeroing — see {@link SizeManager#release(int)} for + * why that distinction matters. Unlike the plain form this walks the chains, making it O(entries) + * rather than O(buckets); clear is a rare, whole-table operation, so the walk is affordable and + * keeping the count honest is worth more than the constant. */ - @Nullable - private TEntry finish(@Nonnull TEntry newEntry) { - if (completed) { - throw new IllegalStateException( - "Reservation already used for tryGetOrInsertOrNull/tryGetOrInsert"); - } - completed = true; - int index = bucketIndex(state.buckets, newEntry.keyHash); - for (TEntry curEntry = bucketAt(state, index); curEntry != null; curEntry = curEntry.next()) { - if (curEntry.keyHash == newEntry.keyHash && curEntry.matches(newEntry)) { - return curEntry; - } - } - if (!slotClaimed) { - return null; - } - insertHeadEntryAt(state, index, newEntry); - consumed = true; - return newEntry; + private static int clearCounting(@Nonnull AtomicReferenceArray> buckets) { + int removed = 0; + synchronized (getTableWriteLock(buckets)) { + for (int i = 0; i < buckets.length(); i++) { + Entry head = buckets.get(i); + if (head == null) { + continue; + } + buckets.set(i, null); + for (Entry e = head; e != null; e = e.next()) { + removed++; + } + } + } + return removed; } /** - * Releases the write lock {@link #tryReserve} acquired, giving back a claimed slot first if it - * was never consumed. A no-op on a definitively empty reservation, which never acquired the - * lock. + * {@link #clear(AtomicReferenceArray)} over a {@link State}: also resets its {@link SizeManager}. */ - @Override - public void close() { - if (state == null) { - return; - } - try { - if (slotClaimed && !consumed) { - state.sizeManager.decrement(); - } - } finally { - state.writeLock.unlock(); - } - } - } - - /** Four-argument analogue of {@link java.util.function.BiFunction}. */ - @Strategy - @FunctionalInterface - public interface Function4 { - R apply(A a, B b, C c, D d); - } - - /** - * Reserves one slot in {@code state}, evicting an entry matching {@code evictable} when - * necessary. Returns {@code false} if the table is full and nothing can be evicted. This method - * acquires the table write lock. - * - *

The reservation survives drain and clear operations. Complete it with {@link - * #insertReserved}; abandoning it permanently consumes capacity. - */ - public static > boolean tryReserveOrEvict( - @Nonnull State state, @Strategy @Nonnull Predicate evictable) { - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); - } finally { - lock.unlock(); - } - } - - /** - * Unlinks the first entry in {@code state} matching {@code evictable}, resuming from where the - * last eviction looked, and decrements the count. {@code null} if nothing matched anywhere. - * Self-locking. - */ - @Nullable - public static > TEntry evictOne( - @Nonnull State state, @Strategy @Nonnull Predicate evictable) { - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - return state.sizeManager.evictOne(state.buckets, evictable); - } finally { - lock.unlock(); - } - } - - /** - * Unlinks every entry in {@code state} matching {@code evictable}, decrementing per removal, and - * returns how many went. Self-locking. - */ - public static > int evictAll( - @Nonnull State state, @Strategy @Nonnull Predicate evictable) { - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - return state.sizeManager.evictAll(state.buckets, evictable); - } finally { - lock.unlock(); - } - } - - // --------------------------------------------------------------------------------------------- - // Static building blocks over a caller-owned bucket array (formerly the nested Support class). - // Use these to assemble a custom table (higher arity, primitive keys, extra value fields) when - // D1/D2 don't fit; D1/D2 delegate to them internally. The whole-table mutators (removeIf, drain, - // clear) self-lock on the array; the single-slot write primitives (insertHeadEntry, unlink) do - // not lock and must be called under the caller's own synchronized (getWriteLock(buckets, - // keyHash)) block. - // Readers - // (bucket walks, forEach) are lock-free. - // --------------------------------------------------------------------------------------------- - - /** - * Creates a bucket array whose length is {@link #sizeFor(int) sizeFor(capacity)}. Because {@link - * AtomicReferenceArray}'s element type is erased at runtime, {@code entryClass} is not used for - * reflective allocation or runtime type checks; it only lets the compiler infer {@code TEntry}. - */ - @Nonnull - public static > AtomicReferenceArray createFixedBuckets( - @Nonnull Class entryClass, int capacity) { - return new AtomicReferenceArray<>(sizeFor(capacity)); - } - - /** Upper bound on the bucket-array length returned by {@link #sizeFor(int)}. */ - static final int MAX_BUCKETS = 1 << 30; - - /** - * Returns the bucket-array length to allocate for a table sized to hold {@code requestedSize} - * entries: {@code requestedSize} rounded up to the next power of two, capped at {@link - * #MAX_BUCKETS}. Throws {@link IllegalArgumentException} for negative inputs or inputs above the - * cap. - */ - public static int sizeFor(int requestedSize) { - if (requestedSize < 0) { - throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); - } - if (requestedSize > MAX_BUCKETS) { - throw new IllegalArgumentException( - "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); - } - if (requestedSize <= 1) { - return 1; - } - return Integer.highestOneBit(requestedSize - 1) << 1; - } - - /** - * Returns the opaque monitor guarding writes to the bucket selected by {@code keyHash}. Use this - * monitor for a keyed scan-and-mutate operation; do not depend on its identity or granularity. - * - * @see #getWriteLockAt(AtomicReferenceArray, int) - * @see #getTableWriteLock(AtomicReferenceArray) - */ - @Nonnull - public static Object getWriteLock(@Nonnull AtomicReferenceArray buckets, long keyHash) { - return getWriteLockAt(buckets, bucketIndex(buckets, keyHash)); - } - - /** - * Returns the {@link State}'s {@link ReentrantLock}, held for the same keyed scan-and-mutate - * operations as {@link #getWriteLock(AtomicReferenceArray, long)}. Unlike the bare-array - * overload's opaque {@code synchronized} monitor, this lock can be acquired in one call and - * released in a later one -- see {@link Reservation}. - */ - @Nonnull - public static ReentrantLock getWriteLock(@Nonnull State state, long keyHash) { - return state.writeLock; - } - - /** - * {@link #getWriteLock(AtomicReferenceArray, long)} for a bucket index that has already been - * computed — the shape a {@code getOrCreate} wants when it reuses the index from its lock-free - * pre-check. This is the primitive; the {@code keyHash} form maps the hash through {@link - * #bucketIndex} and calls it. - */ - @Nonnull - public static Object getWriteLockAt(@Nonnull AtomicReferenceArray buckets, int bucketIndex) { - return buckets; - } - - /** {@link #getWriteLock(State, long)}, for a bucket index that has already been computed. */ - @Nonnull - public static ReentrantLock getWriteLockAt(@Nonnull State state, int bucketIndex) { - return state.writeLock; - } - - /** - * Returns the opaque monitor guarding operations that span all buckets. Whole-table helpers such - * as {@link #drain}, {@link #clear}, and {@link #removeIf} acquire this monitor internally. - */ - @Nonnull - public static Object getTableWriteLock(@Nonnull AtomicReferenceArray buckets) { - return buckets; - } - - /** - * Returns the {@link State}'s {@link ReentrantLock}, guarding operations that span all buckets. - * Unlike {@link #getTableWriteLock(AtomicReferenceArray)}'s opaque {@code synchronized} monitor, - * this lock can be acquired in one call and released in a later one -- see {@link Reservation}. - */ - @Nonnull - public static ReentrantLock getTableWriteLock(@Nonnull State state) { - return state.writeLock; - } - - public static int bucketIndex(@Nonnull AtomicReferenceArray buckets, long keyHash) { - // Bucket lengths are powers of two, so masking replaces a more expensive modulo operation. - return (int) (keyHash & (buckets.length() - 1)); - } - - /** - * Returns the head entry of the bucket that {@code keyHash} maps to. The bucket read is a - * volatile read of the slot, so it is safe from any thread without a lock. - * - *

Named distinctly from {@link #bucketAt} (rather than overloaded on {@code long} vs. {@code - * int}) deliberately: a caller with a primitive {@code int}-typed key hash that called an - * overloaded {@code bucket(buckets, intHash)} would silently bind to the {@code int}-index - * overload instead of widening to this one, reading the raw hash as an array index — out-of-range - * hashes throw {@link IndexOutOfBoundsException}, in-range-but-wrong ones silently read the wrong - * bucket. - */ - @Nullable - public static > TEntry bucketFor( - @Nonnull AtomicReferenceArray buckets, long keyHash) { - return buckets.get(bucketIndex(buckets, keyHash)); - } - - /** {@link #bucketFor(AtomicReferenceArray, long)} over a {@link State}. */ - @Nullable - public static > TEntry bucketFor( - @Nonnull State state, long keyHash) { - return bucketFor(state.buckets, keyHash); - } - - /** - * Returns the head entry of the bucket at {@code index}. Use when the bucket index is already - * computed (e.g. inside {@code getOrCreate} where the same index is reused across the lock - * boundary). See {@link #bucketFor} for why this is a distinct name rather than an {@code int} - * overload of it. - */ - @Nullable - public static > TEntry bucketAt( - @Nonnull AtomicReferenceArray buckets, int index) { - return buckets.get(index); - } - - /** {@link #bucketAt(AtomicReferenceArray, int)} over a {@link State}. */ - @Nullable - public static > TEntry bucketAt( - @Nonnull State state, int index) { - return bucketAt(state.buckets, index); - } - - /** - * Returns a lock-free iterator over the candidates for {@code keyHash}: entries in the bucket - * chain that {@code keyHash} maps to (starting from {@link #bucketFor(AtomicReferenceArray, - * long)}) whose own {@link Entry#keyHash} equals it, skipping any other entry sharing the same - * bucket via hash collision on {@link #bucketIndex}. Callers only need a {@code matches} check - * against the entries this yields, not a {@code keyHash} check of their own. - * - *

Each step follows {@link Entry#next()}, so the iterator reflects entries linked at the time - * each step runs rather than a point-in-time snapshot -- entries inserted ahead of the iterator's - * current position after iteration starts may or may not be observed, and a concurrently removed - * entry remains reachable because {@code unlink()} deliberately retains its {@code next} link for - * in-flight readers. - */ - @Nonnull - public static > Iterator hashIterator( - @Nonnull AtomicReferenceArray buckets, long keyHash) { - return new Iterator() { - private TEntry next = advance(bucketFor(buckets, keyHash)); - - private TEntry advance(TEntry candidate) { - while (candidate != null && candidate.keyHash != keyHash) { - candidate = candidate.next(); - } - return candidate; - } - - @Override - public boolean hasNext() { - return next != null; - } - - @Override - public TEntry next() { - TEntry current = next; - if (current == null) { - throw new NoSuchElementException(); - } - next = advance(current.next()); - return current; - } - }; - } - - /** {@link #hashIterator(AtomicReferenceArray, long)} over a {@link State}. */ - @Nonnull - public static > Iterator hashIterator( - @Nonnull State state, long keyHash) { - return hashIterator(state.buckets, keyHash); - } - - /** - * {@link Iterable} wrapper around {@link #hashIterator(AtomicReferenceArray, long)}, for callers - * that want a plain for-each loop over the candidates for {@code keyHash} rather than driving the - * {@link Iterator} by hand. - */ - @Nonnull - public static > Iterable hashIterable( - @Nonnull AtomicReferenceArray buckets, long keyHash) { - return () -> hashIterator(buckets, keyHash); - } - - /** {@link #hashIterable(AtomicReferenceArray, long)} over a {@link State}. */ - @Nonnull - public static > Iterable hashIterable( - @Nonnull State state, long keyHash) { - return hashIterable(state.buckets, keyHash); - } - - /** - * Publishes {@code entry} as the head of bucket {@code index}. The helper writes the entry's - * {@code next} link before the volatile {@link AtomicReferenceArray#set}; a volatile bucket read - * that observes the new head also sees the initialized entry and its link. The caller must hold - * {@link #getWriteLockAt}; this method does not acquire a lock or update size accounting. - * - *

The entry must be unlinked and must not be reused after removal. Removal intentionally - * retains its {@code next} link for readers already traversing that chain. - */ - @GuardedBy("getWriteLockAt(buckets, index)") - public static > void insertHeadEntryAt( - @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { - assert Thread.holdsLock(getWriteLockAt(buckets, index)) - : "insertHeadEntryAt called without holding getWriteLockAt(buckets, index)"; - insertHeadEntryAtUnchecked(buckets, index, entry); - } - - /** {@link #insertHeadEntryAt(AtomicReferenceArray, int, Entry)} over a {@link State}. */ - @GuardedBy("getWriteLockAt(state, index)") - public static > void insertHeadEntryAt( - @Nonnull State state, int index, @Nonnull TEntry entry) { - assert getWriteLockAt(state, index).isHeldByCurrentThread() - : "insertHeadEntryAt called without holding getWriteLockAt(state, index)"; - insertHeadEntryAtUnchecked(state.buckets, index, entry); - } - - /** Splicing logic shared by both {@link #insertHeadEntryAt} overloads, without their assert. */ - private static > void insertHeadEntryAtUnchecked( - @Nonnull AtomicReferenceArray buckets, int index, @Nonnull TEntry entry) { - assert entry.next() == null - : "Entry already linked -- inserting the same Entry instance twice corrupts the chain" - + " (unlink() deliberately leaves a removed entry's next intact for in-flight" - + " readers, so a removed entry must never be reinserted)"; - entry.setNext(buckets.get(index)); - buckets.set(index, entry); - } - - /** - * Convenience form of {@link #insertHeadEntryAt} that derives the bucket index from {@code - * keyHash}. Prefer {@link #insertHeadEntryAt} when the index is already computed (e.g. a {@code - * getOrCreate} that reuses it across the lock-free pre-check). - */ - @GuardedBy("getWriteLock(buckets, keyHash)") - public static > void insertHeadEntryFor( - @Nonnull AtomicReferenceArray buckets, long keyHash, @Nonnull TEntry entry) { - insertHeadEntryAt(buckets, bucketIndex(buckets, keyHash), entry); - } - - /** - * Links an already-built entry after a successful {@link #tryReserveOrEvict} or {@link - * SizeManager#tryReserve()} call. This method does not acquire a lock or update the count. - * - *

Complete all fallible work before reserving. There is no cancellation operation, so an - * abandoned reservation permanently consumes capacity. Drain and clear do not cancel outstanding - * reservations. - */ - @GuardedBy("getTableWriteLock(state)") - public static > void insertReserved( - @Nonnull State state, long keyHash, @Nonnull TEntry entry) { - assert getTableWriteLock(state).isHeldByCurrentThread() - : "insertReserved called without holding getTableWriteLock(state)"; - insertHeadEntryAtUnchecked(state.buckets, bucketIndex(state.buckets, keyHash), entry); - } - - /** - * Splices {@code entry} out of the chain at {@code index}. {@code prev} is the in-chain - * predecessor, or {@code null} when {@code entry} is the bucket head. Re-points the predecessor - * (or the bucket head slot) past {@code entry} via a volatile write so lock-free readers see the - * removal. {@code entry}'s own {@code next} is deliberately left intact so a reader already - * positioned on it can still traverse forward. This is a single-slot primitive: it does not lock, - * so call it inside the caller's {@code synchronized (getWriteLockAt(buckets, index))} block. - * Does not touch size accounting. - */ - @GuardedBy("getWriteLockAt(buckets, index)") - public static > void unlink( - @Nonnull AtomicReferenceArray buckets, - int index, - @Nullable TEntry prev, - @Nonnull TEntry entry) { - assert Thread.holdsLock(getWriteLockAt(buckets, index)) - : "unlink called without holding getWriteLockAt(buckets, index)"; - unlinkUnchecked(buckets, index, prev, entry); - } - - /** {@link #unlink(AtomicReferenceArray, int, Entry, Entry)} over a {@link State}. */ - @GuardedBy("getWriteLockAt(state, index)") - public static > void unlink( - @Nonnull State state, int index, @Nullable TEntry prev, @Nonnull TEntry entry) { - assert getWriteLockAt(state, index).isHeldByCurrentThread() - : "unlink called without holding getWriteLockAt(state, index)"; - unlinkUnchecked(state.buckets, index, prev, entry); - } - - /** Splicing logic shared by both {@link #unlink} overloads, without their assert. */ - private static > void unlinkUnchecked( - @Nonnull AtomicReferenceArray buckets, - int index, - @Nullable TEntry prev, - @Nonnull TEntry entry) { - TEntry next = entry.next(); - if (prev == null) { - buckets.set(index, next); - } else { - prev.setNext(next); - } - } - - /** - * Removes every entry matching {@code predicate} from {@code buckets}, decrementing {@code size} - * once per removal. Self-locking: synchronizes on {@code buckets} for the whole sweep, so the - * predicate sees a stable table and concurrent writers are excluded; lock-free readers continue - * throughout. - */ - @StrategyConsumer - public static > boolean removeIf( - @Nonnull AtomicReferenceArray buckets, - @Nonnull AtomicInteger size, - @Strategy @Nonnull Predicate predicate) { - synchronized (getTableWriteLock(buckets)) { - boolean removed = false; - for (int i = 0; i < buckets.length(); i++) { - TEntry prev = null; - for (TEntry e = buckets.get(i); e != null; e = e.next()) { - if (predicate.test(e)) { - unlink(buckets, i, prev, e); - size.decrementAndGet(); - removed = true; - // prev stays put: e is now unlinked, so the last survivor remains the predecessor. - } else { - prev = e; - } - } - } - return removed; - } - } - - /** - * {@link #removeIf(AtomicReferenceArray, AtomicInteger, Predicate)} variant for callers tracking - * occupancy with a {@link State} instead of a bare counter — used by {@link D1#removeIf} and - * {@link D2#removeIf}. - */ - @StrategyConsumer - public static > boolean removeIf( - @Nonnull State state, @Strategy @Nonnull Predicate predicate) { - // TODO: no caller contends on this lock today; if one does, consider the same per-bucket - // locking drain() uses, trading the "predicate sees a stable table" guarantee for throughput. - AtomicReferenceArray buckets = state.buckets; - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - boolean removed = false; - for (int i = 0; i < buckets.length(); i++) { - TEntry prev = null; - for (TEntry e = buckets.get(i); e != null; e = e.next()) { - if (predicate.test(e)) { - unlink(state, i, prev, e); - state.sizeManager.decrement(); - removed = true; - } else { - prev = e; - } - } - } - return removed; - } finally { - lock.unlock(); - } - } - - /** - * Removes all entries from {@code buckets} and invokes {@code drainedEntryConsumer} for each one. - * - *

The drain holds the table write lock while detaching buckets and invoking the consumer. For - * each removed entry, the consumer is invoked synchronously after its bucket is detached. A - * lock-free reader already traversing the detached chain can continue through its retained links. - * - *

This overload does not update size accounting. The consumer should be quick and must not - * throw; failures are not rolled back. - * - * @param entry type - * @param buckets bucket array to drain - * @param drainedEntryConsumer action invoked for each entry after its bucket is detached - */ - public static > void drain( - @Nonnull AtomicReferenceArray buckets, - @Strategy @Nonnull Consumer drainedEntryConsumer) { - drainCounting(buckets, drainedEntryConsumer); - } - - /** - * {@link #drain(AtomicReferenceArray, Consumer)} returning the number of entries passed to {@code - * drainedEntryConsumer} for size accounting. - */ - @StrategyConsumer - private static > int drainCounting( - @Nonnull AtomicReferenceArray buckets, - @Strategy @Nonnull Consumer drainedEntryConsumer) { - int removed = 0; - synchronized (getTableWriteLock(buckets)) { - for (int i = 0; i < buckets.length(); i++) { - TEntry head = buckets.get(i); - if (head == null) { - continue; - } - buckets.set(i, null); - for (TEntry e = head; e != null; e = e.next()) { - removed++; - drainedEntryConsumer.accept(e); - } - } - } - return removed; - } - - /** - * Removes all entries from {@code buckets} and invokes {@code drainedEntryConsumer} with {@code - * context} and each removed entry. - * - *

The drain holds the table write lock while detaching buckets and invoking the consumer. For - * each removed entry, the consumer is invoked synchronously after its bucket is detached. This - * overload does not update size accounting. The consumer should be quick and must not throw; - * failures are not rolled back. - * - * @param context type - * @param entry type - * @param buckets bucket array to drain - * @param context context passed to each invocation of {@code drainedEntryConsumer} - * @param drainedEntryConsumer action invoked with the context and each removed entry - */ - public static > void drain( - @Nonnull AtomicReferenceArray buckets, - C context, - @Strategy @Nonnull BiConsumer drainedEntryConsumer) { - drainCounting(buckets, context, drainedEntryConsumer); - } - - /** {@link #drainCounting(AtomicReferenceArray, Consumer)}, context-passing form. */ - @StrategyConsumer - private static > int drainCounting( - @Nonnull AtomicReferenceArray buckets, - C context, - @Strategy @Nonnull BiConsumer drainedEntryConsumer) { - int removed = 0; - synchronized (getTableWriteLock(buckets)) { - for (int i = 0; i < buckets.length(); i++) { - TEntry head = buckets.get(i); - if (head == null) { - continue; - } - buckets.set(i, null); - for (TEntry e = head; e != null; e = e.next()) { - removed++; - drainedEntryConsumer.accept(context, e); - } - } - } - return removed; - } - - /** - * Removes all entries from {@code state}, invokes {@code drainedEntryConsumer} for each one, and - * releases one capacity slot for each removed entry. - * - *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, so - * a table-level operation (an insert, a reservation, an eviction) can interleave between buckets - * instead of waiting out the entire drain. This means the drain is no longer an atomic snapshot: - * an entry inserted into a not-yet-visited bucket while the drain is in progress is swept up too, - * and each bucket's capacity slot is released immediately before its consumer invocation runs, - * rather than once for the whole sweep at the end. Outstanding reservations remain counted. - * - *

The consumer should be quick and must not throw. If it does, later entries in the detached - * bucket remain counted but are no longer reachable from the table. Buckets not yet visited - * remain intact, and capacity already released is not restored. - * - * @param entry type - * @param state table state to drain - * @param drainedEntryConsumer action invoked for each entry after its bucket is detached - */ - public static > void drain( - @Nonnull State state, - @Strategy @Nonnull Consumer drainedEntryConsumer) { - ReentrantLock lock = getTableWriteLock(state); - AtomicReferenceArray buckets = state.buckets; - for (int i = 0; i < buckets.length(); i++) { - lock.lock(); - try { - TEntry head = buckets.get(i); - if (head == null) { - continue; - } - buckets.set(i, null); - for (TEntry e = head; e != null; e = e.next()) { - state.sizeManager.decrement(); - drainedEntryConsumer.accept(e); - } - } finally { - lock.unlock(); - } - } - lock.lock(); - try { - state.sizeManager.release(0); // full sweep: reset the scan position - } finally { - lock.unlock(); - } - } - - /** - * Removes all entries from {@code state}, invokes {@code drainedEntryConsumer} with {@code - * context} and each removed entry, and releases one capacity slot for each removed entry. - * - *

The lock is acquired and released bucket by bucket rather than held for the whole sweep, so - * a table-level operation (an insert, a reservation, an eviction) can interleave between buckets - * instead of waiting out the entire drain. This means the drain is no longer an atomic snapshot: - * an entry inserted into a not-yet-visited bucket while the drain is in progress is swept up too, - * and each bucket's capacity slot is released immediately before its consumer invocation runs, - * rather than once for the whole sweep at the end. Outstanding reservations remain counted. - * - *

The consumer should be quick and must not throw. If it does, later entries in the detached - * bucket remain counted but are no longer reachable from the table. Buckets not yet visited - * remain intact, and capacity already released is not restored. - * - * @param context type - * @param entry type - * @param state table state to drain - * @param context context passed to each invocation of {@code drainedEntryConsumer} - * @param drainedEntryConsumer action invoked with the context and each removed entry - */ - public static > void drain( - @Nonnull State state, - C context, - @Strategy @Nonnull BiConsumer drainedEntryConsumer) { - ReentrantLock lock = getTableWriteLock(state); - AtomicReferenceArray buckets = state.buckets; - for (int i = 0; i < buckets.length(); i++) { - lock.lock(); - try { - TEntry head = buckets.get(i); - if (head == null) { - continue; - } - buckets.set(i, null); - for (TEntry e = head; e != null; e = e.next()) { - state.sizeManager.decrement(); - drainedEntryConsumer.accept(context, e); - } - } finally { - lock.unlock(); - } - } - lock.lock(); - try { - state.sizeManager.release(0); // full sweep: reset the scan position - } finally { - lock.unlock(); - } - } - - /** Nulls every bucket head. Self-locking: synchronizes on {@code buckets}. */ - public static void clear(@Nonnull AtomicReferenceArray buckets) { - synchronized (getTableWriteLock(buckets)) { - for (int i = 0; i < buckets.length(); i++) { - buckets.set(i, null); - } + public static void clear(@Nonnull State state) { + ReentrantLock lock = getTableWriteLock(state); + lock.lock(); + try { + state.sizeManager.release(clearCounting(state.buckets)); + } finally { + lock.unlock(); + } } - } - - /** - * {@link #clear(AtomicReferenceArray)} returning how many entries it detached, so a {@link State} - * form can subtract exactly that rather than zeroing — see {@link SizeManager#release(int)} for - * why that distinction matters. Unlike the plain form this walks the chains, making it O(entries) - * rather than O(buckets); clear is a rare, whole-table operation, so the walk is affordable and - * keeping the count honest is worth more than the constant. - */ - private static int clearCounting(@Nonnull AtomicReferenceArray> buckets) { - int removed = 0; - synchronized (getTableWriteLock(buckets)) { - for (int i = 0; i < buckets.length(); i++) { - Entry head = buckets.get(i); - if (head == null) { - continue; - } - buckets.set(i, null); - for (Entry e = head; e != null; e = e.next()) { - removed++; - } - } + + @StrategyConsumer + public static > void forEach( + @Nonnull AtomicReferenceArray buckets, @Strategy @Nonnull Consumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { + consumer.accept(curEntry); + } + } } - return removed; - } - - /** - * {@link #clear(AtomicReferenceArray)} over a {@link State}: also resets its {@link SizeManager}. - */ - public static void clear(@Nonnull State state) { - ReentrantLock lock = getTableWriteLock(state); - lock.lock(); - try { - state.sizeManager.release(clearCounting(state.buckets)); - } finally { - lock.unlock(); + + @StrategyConsumer + public static > void forEach( + @Nonnull AtomicReferenceArray buckets, + C context, + @Strategy @Nonnull BiConsumer consumer) { + for (int i = 0; i < buckets.length(); i++) { + for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { + consumer.accept(context, curEntry); + } + } } - } - - @StrategyConsumer - public static > void forEach( - @Nonnull AtomicReferenceArray buckets, - @Strategy @Nonnull Consumer consumer) { - for (int i = 0; i < buckets.length(); i++) { - for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { - consumer.accept(curEntry); - } + + /** {@link #forEach(AtomicReferenceArray, Consumer)} over a {@link State}. */ + public static > void forEach( + @Nonnull State state, @Strategy @Nonnull Consumer consumer) { + forEach(state.buckets, consumer); } - } - - @StrategyConsumer - public static > void forEach( - @Nonnull AtomicReferenceArray buckets, - C context, - @Strategy @Nonnull BiConsumer consumer) { - for (int i = 0; i < buckets.length(); i++) { - for (TEntry curEntry = buckets.get(i); curEntry != null; curEntry = curEntry.next()) { - consumer.accept(context, curEntry); - } + + /** {@link #forEach(AtomicReferenceArray, Object, BiConsumer)} over a {@link State}. */ + public static > void forEach( + @Nonnull State state, + C context, + @Strategy @Nonnull BiConsumer consumer) { + forEach(state.buckets, context, consumer); } - } - - /** {@link #forEach(AtomicReferenceArray, Consumer)} over a {@link State}. */ - public static > void forEach( - @Nonnull State state, @Strategy @Nonnull Consumer consumer) { - forEach(state.buckets, consumer); - } - - /** {@link #forEach(AtomicReferenceArray, Object, BiConsumer)} over a {@link State}. */ - public static > void forEach( - @Nonnull State state, - C context, - @Strategy @Nonnull BiConsumer consumer) { - forEach(state.buckets, context, consumer); - } } diff --git a/internal-api/src/main/java/datadog/trace/util/FNV64Hash.java b/internal-api/src/main/java/datadog/trace/util/FNV64Hash.java index ec0120b66db..f1b7cf46892 100644 --- a/internal-api/src/main/java/datadog/trace/util/FNV64Hash.java +++ b/internal-api/src/main/java/datadog/trace/util/FNV64Hash.java @@ -8,62 +8,61 @@ *

http://www.isthe.com/chongo/tech/comp/fnv/index.html#FNV-1 */ public class FNV64Hash { - private static final long FNV_INIT = 0xcbf29ce484222325L; - private static final long FNV_PRIME = 0x100000001b3L; + private static final long FNV_INIT = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; - public enum Version { - v1, - v1A - } + public enum Version { + v1, + v1A + } - public static long generateHash(String data, Version version) { - return generateHash(data.getBytes(StandardCharsets.UTF_8), version); - } + public static long generateHash(String data, Version version) { + return generateHash(data.getBytes(StandardCharsets.UTF_8), version); + } - public static long continueHash(long currentHash, String data, Version version) { - return continueHash(currentHash, data.getBytes(StandardCharsets.UTF_8), version); - } + public static long continueHash(long currentHash, String data, Version version) { + return continueHash(currentHash, data.getBytes(StandardCharsets.UTF_8), version); + } - public static long generateHash(byte[] data, Version version) { - return generateHash(data, 0, data.length, version); - } + public static long generateHash(byte[] data, Version version) { + return generateHash(data, 0, data.length, version); + } - public static long continueHash(long currentHash, byte[] data, Version version) { - return continueHash(currentHash, data, 0, data.length, version); - } + public static long continueHash(long currentHash, byte[] data, Version version) { + return continueHash(currentHash, data, 0, data.length, version); + } - public static long generateHash(byte[] data, int start, int length, Version version) { - return continueHash(FNV_INIT, data, start, length, version); - } + public static long generateHash(byte[] data, int start, int length, Version version) { + return continueHash(FNV_INIT, data, start, length, version); + } - public static long continueHash( - long currentHash, byte[] data, int start, int length, Version version) { - if (version == Version.v1) { - return generateFNV1Hash(currentHash, data, start, length); - } else { - return generateFNV1AHash(currentHash, data, start, length); + public static long continueHash(long currentHash, byte[] data, int start, int length, Version version) { + if (version == Version.v1) { + return generateFNV1Hash(currentHash, data, start, length); + } else { + return generateFNV1AHash(currentHash, data, start, length); + } } - } - private static long generateFNV1Hash(long currentHash, byte[] data, int start, int length) { - long hash = currentHash; + private static long generateFNV1Hash(long currentHash, byte[] data, int start, int length) { + long hash = currentHash; - for (int i = start; i < start + length; i++) { - hash *= FNV_PRIME; - hash ^= 0xffL & data[i]; + for (int i = start; i < start + length; i++) { + hash *= FNV_PRIME; + hash ^= 0xffL & data[i]; + } + + return hash; } - return hash; - } + private static long generateFNV1AHash(long currentHash, byte[] data, int start, int length) { + long hash = currentHash; - private static long generateFNV1AHash(long currentHash, byte[] data, int start, int length) { - long hash = currentHash; + for (int i = start; i < start + length; i++) { + hash ^= 0xffL & data[i]; + hash *= FNV_PRIME; + } - for (int i = start; i < start + length; i++) { - hash ^= 0xffL & data[i]; - hash *= FNV_PRIME; + return hash; } - - return hash; - } } diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java index 47802f0d90f..0f15217cb51 100644 --- a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -76,997 +76,987 @@ * #getOrCreate} (so hits stay a single probe and the create path is warmup-rare). */ public final class FlatHashtable { - private FlatHashtable() {} - - /** - * Optional structure-free entry base carrying only a cached {@code hash} — an - * optimization, not plumbing (open addressing needs no {@code next}), so extending it is - * never required: bring any entry type and supply a {@link HashStrategy} yourself instead. Caller - * contract: {@code hash} must equal the table's {@link MatchingStrategy#hashKey} for this entry's - * key (the raw hash — the table applies its own spread), so the entry lands where {@link - * #get} looks. - */ - public abstract static class Entry { - public final long hash; - - protected Entry(long hash) { - this.hash = hash; - } - } - - /** - * Single-key, {@code HashMap}-style convenience over the {@linkplain FlatHashtable static core}: - * {@link #get} / {@link #getOrCreate} / {@link #insert} / {@link #forEach} without writing a - * {@link MatchingStrategy}. Reach for it when you want something quick that beats {@code - * HashMap} — the entry carries its own value fields, so updating an existing value is - * allocation-free (look up once, then write the returned entry). - * - *

Fixed or growable, chosen at construction. {@link #createFixed} keeps the raw core's - * bounded posture — the table holds up to {@code maxCapacity} entries, then {@link #getOrCreate} - * caps and returns {@code null} (the caller supplies the overflow default). {@link - * #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code initialCapacity} - * is a sizing hint, not a cap, the table doubles when it fills past its load factor, and {@code - * getOrCreate} never returns {@code null}. The distinct factory names make the choice explicit at - * the call site (there's no ambiguous {@code (Class, int)} constructor); {@code Capacity} always - * counts entries — contrast the chained {@code Hashtable.D1}, whose factory counts - * buckets. - * - *

Entry-centric, not strategy-based. Supply a {@link D1.Entry} subclass carrying the - * key and value fields; key equality is {@link Object#equals} by default (override {@link - * Entry#matches(Object)} for e.g. reference equality on interned keys). The strategy-based core - * is untouched and remains the expert path. - * - *

No {@code remove}. The open-addressed core has no tombstones (an empty slot - * terminates a probe), so deletion isn't supported — this layer is for accumulate / interner / - * counter workloads, not churn. - * - *

Not thread-safe. - * - * @param the key type - * @param the user's {@link D1.Entry D1.Entry<K>} subclass - */ - public static final class D1> { + private FlatHashtable() {} + /** - * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in - * place after retrieving the entry via {@link D1#get}. The key is captured at construction and - * stored alongside its precomputed hash (via {@link FlatHashtable.Entry}). - * - * @param the key type + * Optional structure-free entry base carrying only a cached {@code hash} — an + * optimization, not plumbing (open addressing needs no {@code next}), so extending it is + * never required: bring any entry type and supply a {@link HashStrategy} yourself instead. Caller + * contract: {@code hash} must equal the table's {@link MatchingStrategy#hashKey} for this entry's + * key (the raw hash — the table applies its own spread), so the entry lands where {@link + * #get} looks. */ - public abstract static class Entry extends FlatHashtable.Entry { - final K key; - - protected Entry(@Nullable K key) { - super(hash(key)); - this.key = key; - } - - @Nullable - public final K key() { - return key; - } - - public boolean matches(@Nullable Object key) { - return Objects.equals(this.key, key); - } - - /** - * Returns the lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so they - * don't collide with a real key that hashes to 0; real-key collisions are resolved by {@link - * #matches(Object)}. - */ - public static long hash(@Nullable Object key) { - return (key == null) ? Long.MIN_VALUE : key.hashCode(); - } - } + public abstract static class Entry { + public final long hash; - private final boolean growable; - private final float loadFactor; - private TEntry[] table; - private int size; - private int limit; // grow trigger when growable; hard cap when fixed - - private D1(TEntry[] table, float loadFactor, boolean growable, int limit) { - this.growable = growable; - this.loadFactor = loadFactor; - this.table = table; - this.size = 0; - this.limit = limit; + protected Entry(long hash) { + this.hash = hash; + } } /** - * A bounded {@link D1} holding up to {@code maxCapacity} entries at the {@link - * #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}). + * Single-key, {@code HashMap}-style convenience over the {@linkplain FlatHashtable static core}: + * {@link #get} / {@link #getOrCreate} / {@link #insert} / {@link #forEach} without writing a + * {@link MatchingStrategy}. Reach for it when you want something quick that beats {@code + * HashMap} — the entry carries its own value fields, so updating an existing value is + * allocation-free (look up once, then write the returned entry). + * + *

Fixed or growable, chosen at construction. {@link #createFixed} keeps the raw core's + * bounded posture — the table holds up to {@code maxCapacity} entries, then {@link #getOrCreate} + * caps and returns {@code null} (the caller supplies the overflow default). {@link + * #createGrowable} trades that for {@code HashMap}-like ergonomics — its {@code initialCapacity} + * is a sizing hint, not a cap, the table doubles when it fills past its load factor, and {@code + * getOrCreate} never returns {@code null}. The distinct factory names make the choice explicit at + * the call site (there's no ambiguous {@code (Class, int)} constructor); {@code Capacity} always + * counts entries — contrast the chained {@code Hashtable.D1}, whose factory counts + * buckets. + * + *

Entry-centric, not strategy-based. Supply a {@link D1.Entry} subclass carrying the + * key and value fields; key equality is {@link Object#equals} by default (override {@link + * Entry#matches(Object)} for e.g. reference equality on interned keys). The strategy-based core + * is untouched and remains the expert path. + * + *

No {@code remove}. The open-addressed core has no tombstones (an empty slot + * terminates a probe), so deletion isn't supported — this layer is for accumulate / interner / + * counter workloads, not churn. + * + *

Not thread-safe. + * + * @param the key type + * @param the user's {@link D1.Entry D1.Entry<K>} subclass */ - @Nonnull - public static > D1 createFixed( - @Nonnull Class type, int maxCapacity) { - return createFixed(type, maxCapacity, DEFAULT_LOAD_FACTOR); - } + public static final class D1> { + /** + * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in + * place after retrieving the entry via {@link D1#get}. The key is captured at construction and + * stored alongside its precomputed hash (via {@link FlatHashtable.Entry}). + * + * @param the key type + */ + public abstract static class Entry extends FlatHashtable.Entry { + final K key; + + protected Entry(@Nullable K key) { + super(hash(key)); + this.key = key; + } + + @Nullable + public final K key() { + return key; + } + + public boolean matches(@Nullable Object key) { + return Objects.equals(this.key, key); + } + + /** + * Returns the lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so they + * don't collide with a real key that hashes to 0; real-key collisions are resolved by {@link + * #matches(Object)}. + */ + public static long hash(@Nullable Object key) { + return (key == null) ? Long.MIN_VALUE : key.hashCode(); + } + } - @Nonnull - public static > D1 createFixed( - @Nonnull Class type, int maxCapacity, float loadFactor) { - return new D1<>(create(type, maxCapacity, loadFactor), loadFactor, false, maxCapacity); - } + private final boolean growable; + private final float loadFactor; + private TEntry[] table; + private int size; + private int limit; // grow trigger when growable; hard cap when fixed + + private D1(TEntry[] table, float loadFactor, boolean growable, int limit) { + this.growable = growable; + this.loadFactor = loadFactor; + this.table = table; + this.size = 0; + this.limit = limit; + } - /** - * A growable {@link D1} sized initially for {@code initialCapacity} entries at the {@link - * #DEFAULT_LOAD_FACTOR}; doubles on demand, so it never caps. - */ - @Nonnull - public static > D1 createGrowable( - @Nonnull Class type, int initialCapacity) { - return createGrowable(type, initialCapacity, DEFAULT_LOAD_FACTOR); - } + /** + * A bounded {@link D1} holding up to {@code maxCapacity} entries at the {@link + * #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}). + */ + @Nonnull + public static > D1 createFixed(@Nonnull Class type, int maxCapacity) { + return createFixed(type, maxCapacity, DEFAULT_LOAD_FACTOR); + } - @Nonnull - public static > D1 createGrowable( - @Nonnull Class type, int initialCapacity, float loadFactor) { - E[] table = create(type, initialCapacity, loadFactor); - return new D1<>(table, loadFactor, true, (int) (table.length * loadFactor)); - } + @Nonnull + public static > D1 createFixed( + @Nonnull Class type, int maxCapacity, float loadFactor) { + return new D1<>(create(type, maxCapacity, loadFactor), loadFactor, false, maxCapacity); + } - public int size() { - return size; - } + /** + * A growable {@link D1} sized initially for {@code initialCapacity} entries at the {@link + * #DEFAULT_LOAD_FACTOR}; doubles on demand, so it never caps. + */ + @Nonnull + public static > D1 createGrowable(@Nonnull Class type, int initialCapacity) { + return createGrowable(type, initialCapacity, DEFAULT_LOAD_FACTOR); + } - /** Existing entry for {@code key}, or {@code null}. Read-only — never creates. */ - @Nullable - public TEntry get(@Nullable K key) { - final long keyHash = Entry.hash(key); - final TEntry[] table = this.table; - final int mask = table.length - 1; - final int start = home(keyHash, mask); - int i = start; - for (; ; ) { - final TEntry e = table[i]; - if (e == null) { - return null; // empty slot terminates the probe (no tombstones) + @Nonnull + public static > D1 createGrowable( + @Nonnull Class type, int initialCapacity, float loadFactor) { + E[] table = create(type, initialCapacity, loadFactor); + return new D1<>(table, loadFactor, true, (int) (table.length * loadFactor)); + } + + public int size() { + return size; + } + + /** Existing entry for {@code key}, or {@code null}. Read-only — never creates. */ + @Nullable + public TEntry get(@Nullable K key) { + final long keyHash = Entry.hash(key); + final TEntry[] table = this.table; + final int mask = table.length - 1; + final int start = home(keyHash, mask); + int i = start; + for (; ; ) { + final TEntry e = table[i]; + if (e == null) { + return null; // empty slot terminates the probe (no tombstones) + } + if (e.hash == keyHash && e.matches(key)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; // wrapped ⇒ absent (can't happen once growth keeps a free slot) + } + } + } + + /** + * Existing entry for {@code key}, or a freshly {@link CreateStrategy#create created} + inserted + * one. A growable table never returns {@code null}; a fixed one returns {@code null} when full + * and {@code key} is absent (the caller supplies the overflow default). A hit is always + * returned even at capacity — the cap blocks only creation, not lookup. + */ + @StrategyConsumer + @Nullable + public TEntry getOrCreate(@Nullable K key, @Nonnull CreateStrategy createStrat) { + final TEntry existing = get(key); + if (existing != null) { + return existing; + } + if (size >= limit) { + if (!growable) { + return null; // fixed table full ⇒ caller supplies the overflow default + } + grow(); + } + final TEntry created = createStrat.create(key); + FlatHashtable.insert(table, created); // fits: cap/growth keeps a free slot + size++; + return created; + } + + /** + * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. + * Comparison-free and caller-responsible (same contract as {@link FlatHashtable#insert}): the + * caller must ensure {@code entry}'s key is absent, else it lands shadowed. + */ + public boolean insert(@Nonnull TEntry entry) { + if (size >= limit) { + if (!growable) { + return false; // fixed table full + } + grow(); + } + FlatHashtable.insert(table, entry); + size++; + return true; } - if (e.hash == keyHash && e.matches(key)) { - return e; + + private void grow() { + table = resize(table); + limit = (int) (table.length * loadFactor); + } + + public void clear() { + Arrays.fill(table, null); + size = 0; } - i = (i + 1) & mask; - if (i == start) { - return null; // wrapped ⇒ absent (can't happen once growth keeps a free slot) + + public void forEach(@Nonnull Consumer consumer) { + FlatHashtable.forEach(table, consumer); + } + + /** + * Context-passing {@link #forEach(Consumer)}: pair a non-capturing {@link BiConsumer} with + * side-band {@code context} to avoid a per-call closure. + */ + public void forEach(C context, @Nonnull BiConsumer consumer) { + FlatHashtable.forEach(table, context, consumer); } - } } /** - * Existing entry for {@code key}, or a freshly {@link CreateStrategy#create created} + inserted - * one. A growable table never returns {@code null}; a fixed one returns {@code null} when full - * and {@code key} is absent (the caller supplies the overflow default). A hit is always - * returned even at capacity — the cap blocks only creation, not lookup. + * Two-key (composite-key) analogue of {@link D1}. Both key parts pass directly through {@link + * #get} / {@link #getOrCreate}, so a lookup allocates no {@code Pair} — the win over {@code + * HashMap}. Same fixed-or-growable ({@link #createFixed} / {@link #createGrowable}), + * entry-centric, no-{@code remove}, not-thread-safe contract as {@link D1}. + * + * @param first key type + * @param second key type + * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass */ - @StrategyConsumer - @Nullable - public TEntry getOrCreate(@Nullable K key, @Nonnull CreateStrategy createStrat) { - final TEntry existing = get(key); - if (existing != null) { - return existing; - } - if (size >= limit) { - if (!growable) { - return null; // fixed table full ⇒ caller supplies the overflow default + public static final class D2> { + /** + * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in + * place. Both key parts are captured at construction alongside their combined hash. + * + * @param first key type + * @param second key type + */ + public abstract static class Entry extends FlatHashtable.Entry { + final K1 key1; + final K2 key2; + + protected Entry(@Nullable K1 key1, @Nullable K2 key2) { + super(hash(key1, key2)); + this.key1 = key1; + this.key2 = key2; + } + + @Nullable + public final K1 key1() { + return key1; + } + + @Nullable + public final K2 key2() { + return key2; + } + + public boolean matches(@Nullable K1 key1, @Nullable K2 key2) { + return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); + } + + /** + * Returns the combined lookup hash for both key parts via {@link + * LongHashingUtils#hash(Object, Object)}. Null parts contribute {@code 0} (not a sentinel, + * unlike {@link D1.Entry}); {@link #matches(Object, Object)} resolves any collision. + */ + public static long hash(@Nullable Object key1, @Nullable Object key2) { + return LongHashingUtils.hash(key1, key2); + } + } + + private final boolean growable; + private final float loadFactor; + private TEntry[] table; + private int size; + private int limit; // grow trigger when growable; hard cap when fixed + + private D2(TEntry[] table, float loadFactor, boolean growable, int limit) { + this.growable = growable; + this.loadFactor = loadFactor; + this.table = table; + this.size = 0; + this.limit = limit; + } + + /** + * A bounded {@link D2} holding up to {@code maxCapacity} entries at the {@link + * #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}). + */ + @Nonnull + public static > D2 createFixed( + @Nonnull Class type, int maxCapacity) { + return createFixed(type, maxCapacity, DEFAULT_LOAD_FACTOR); + } + + @Nonnull + public static > D2 createFixed( + @Nonnull Class type, int maxCapacity, float loadFactor) { + return new D2<>(create(type, maxCapacity, loadFactor), loadFactor, false, maxCapacity); + } + + /** + * A growable {@link D2} sized initially for {@code initialCapacity} entries at the {@link + * #DEFAULT_LOAD_FACTOR}; doubles on demand, so it never caps. + */ + @Nonnull + public static > D2 createGrowable( + @Nonnull Class type, int initialCapacity) { + return createGrowable(type, initialCapacity, DEFAULT_LOAD_FACTOR); + } + + @Nonnull + public static > D2 createGrowable( + @Nonnull Class type, int initialCapacity, float loadFactor) { + E[] table = create(type, initialCapacity, loadFactor); + return new D2<>(table, loadFactor, true, (int) (table.length * loadFactor)); + } + + public int size() { + return size; + } + + /** Existing entry for {@code (key1, key2)}, or {@code null}. Read-only — never creates. */ + @Nullable + public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { + final long keyHash = Entry.hash(key1, key2); + final TEntry[] table = this.table; + final int mask = table.length - 1; + final int start = home(keyHash, mask); + int i = start; + for (; ; ) { + final TEntry e = table[i]; + if (e == null) { + return null; + } + if (e.hash == keyHash && e.matches(key1, key2)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; + } + } + } + + /** + * Two-key analogue of {@link D1#getOrCreate}: growable never returns {@code null}; fixed + * returns {@code null} when full and {@code (key1, key2)} is absent. + */ + @StrategyConsumer + @Nullable + public TEntry getOrCreate( + @Nullable K1 key1, @Nullable K2 key2, @Nonnull CreateStrategy2 createStrat) { + final TEntry existing = get(key1, key2); + if (existing != null) { + return existing; + } + if (size >= limit) { + if (!growable) { + return null; // fixed table full ⇒ caller supplies the overflow default + } + grow(); + } + final TEntry created = createStrat.create(key1, key2); + FlatHashtable.insert(table, created); + size++; + return created; + } + + /** + * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. + * Comparison-free and caller-responsible: the caller must ensure {@code (key1, key2)} is + * absent. + */ + public boolean insert(@Nonnull TEntry entry) { + if (size >= limit) { + if (!growable) { + return false; // fixed table full + } + grow(); + } + FlatHashtable.insert(table, entry); + size++; + return true; + } + + private void grow() { + table = resize(table); + limit = (int) (table.length * loadFactor); + } + + public void clear() { + Arrays.fill(table, null); + size = 0; + } + + public void forEach(@Nonnull Consumer consumer) { + FlatHashtable.forEach(table, consumer); + } + + public void forEach(C context, @Nonnull BiConsumer consumer) { + FlatHashtable.forEach(table, context, consumer); } - grow(); - } - final TEntry created = createStrat.create(key); - FlatHashtable.insert(table, created); // fits: cap/growth keeps a free slot - size++; - return created; } /** - * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. - * Comparison-free and caller-responsible (same contract as {@link FlatHashtable#insert}): the - * caller must ensure {@code entry}'s key is absent, else it lands shadowed. + * Two-key creation strategy for {@link D2#getOrCreate}: mint a new entry for {@code (key1, + * key2)}. Like {@link CreateStrategy}, supply a {@code static final} constant or a + * non-capturing lambda (e.g. {@code MyEntry::new}) so it stays a single monomorphic, + * allocation-free instance. + * + * @param stored entry to create + * @param first key type + * @param second key type */ - public boolean insert(@Nonnull TEntry entry) { - if (size >= limit) { - if (!growable) { - return false; // fixed table full - } - grow(); - } - FlatHashtable.insert(table, entry); - size++; - return true; + @Strategy + @FunctionalInterface + public interface CreateStrategy2 { + @Nonnull + E create(@Nullable K1 key1, @Nullable K2 key2); } - private void grow() { - table = resize(table); - limit = (int) (table.length * loadFactor); + /** + * Entry-side strategy: the hash of a stored {@code entry} — its home is {@link #home}{@code + * (hashOf(entry))}. Used by {@link #insert} / {@link #iterator} / {@link #resize}, which have an + * entry rather than a lookup key. Must be consistent with {@link MatchingStrategy#hashKey} (see + * the class contract). Supply a {@code static final} constant or a non-capturing lambda so it + * stays a single monomorphic instance (see {@link Strategy}); for {@link Entry}-based tables use + * the strategy-free overloads, which read {@link Entry#hash} directly. + * + * @param stored entry + */ + @Strategy + @FunctionalInterface + public interface HashStrategy { + long hashOf(@Nonnull E entry); } - public void clear() { - Arrays.fill(table, null); - size = 0; + /** + * Key-side strategy: whether a stored {@code entry} is the one for a lookup {@code key} ({@link + * #matches}), and how to hash that key ({@link #hashKey}). {@code hashKey} defaults to {@code + * key.hashCode()} — override it only when the key's identity needs different hashing (e.g. + * case-insensitive), and then keep it consistent with the table's {@link HashStrategy#hashOf}. + * Used by {@link #get} / {@link #getOrCreate}. + * + *

A {@link FunctionalInterface} ({@code matches} is the sole abstract method), so the common + * case can be a non-capturing lambda; a strategy that also customizes hashing is a named class + * that overrides {@code hashKey}. + * + * @param stored entry + * @param lookup key + */ + @Strategy + @FunctionalInterface + public interface MatchingStrategy { + /** Whether the stored {@code entry} is the one for {@code key}. */ + boolean matches(@Nonnull E entry, K key); + + /** + * Hash of {@code key}; defaults to {@code key.hashCode()} (the table applies its own spread). + * This default NPEs on a {@code null} key — unlike {@link D1.Entry}, which maps a {@code null} + * key to a dedicated hash sentinel, the raw core takes no position on {@code null} keys; a + * strategy that must support them overrides {@code hashKey} (and {@code matches}) to handle + * {@code null} itself. + */ + default long hashKey(K key) { + return key.hashCode(); + } } - public void forEach(@Nonnull Consumer consumer) { - FlatHashtable.forEach(table, consumer); - } + /** + * The whole policy for a table you both look up in and insert into: a {@link MatchingStrategy} + * (key side) and a {@link HashStrategy} (entry side) in one. Extend as a stateless final + * class held in a concrete-typed {@code static final} singleton so the JIT specializes each call + * site (see {@link Strategy}); an abstract class (not an interface) so a specialization + * miss falls back to {@code invokevirtual} rather than the costlier megamorphic {@code + * invokeinterface}. + * + * @param stored entry + * @param lookup key + */ + @Strategy + public abstract static class EntryStrategy implements HashStrategy, MatchingStrategy {} /** - * Context-passing {@link #forEach(Consumer)}: pair a non-capturing {@link BiConsumer} with - * side-band {@code context} to avoid a per-call closure. + * {@link EntryStrategy} for {@code String} keys compared case-insensitively: seals {@link + * #hashKey} to {@link Strings#caseInsensitiveHashCode} (consistent with {@link + * String#equalsIgnoreCase}, which callers use in {@link #matches}). Callers still supply {@link + * #matches} (typically {@code key.equalsIgnoreCase(entry.key)}) and {@link #hashOf} (the same + * case-insensitive hash of the entry's key, or the cached {@link Entry#hash}). + * + * @param stored entry — self-contained (carries its own key) */ - public void forEach(C context, @Nonnull BiConsumer consumer) { - FlatHashtable.forEach(table, context, consumer); + public abstract static class CaseInsensitiveStringStrategy extends EntryStrategy { + @Override + public final long hashKey(String key) { + return Strings.caseInsensitiveHashCode(key); // raw; the table spreads before masking + } } - } - - /** - * Two-key (composite-key) analogue of {@link D1}. Both key parts pass directly through {@link - * #get} / {@link #getOrCreate}, so a lookup allocates no {@code Pair} — the win over {@code - * HashMap}. Same fixed-or-growable ({@link #createFixed} / {@link #createGrowable}), - * entry-centric, no-{@code remove}, not-thread-safe contract as {@link D1}. - * - * @param first key type - * @param second key type - * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass - */ - public static final class D2> { + /** - * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in - * place. Both key parts are captured at construction alongside their combined hash. + * Creation strategy: mint a new entry for {@code key} (called once, on insert). A {@link + * FunctionalInterface} — supply a {@code static final} constant or a non-capturing lambda + * (e.g. {@code MyEntry::new}) so it stays a single monomorphic, allocation-free instance; a + * capturing lambda silently re-allocates per call and can de-monomorphize the site (see {@link + * Strategy}). Bespoke rather than {@link java.util.function.Function} so it carries the {@link + * Strategy} contract and reads as {@code create} at the call site. * - * @param first key type - * @param second key type + * @param stored entry to create + * @param lookup key */ - public abstract static class Entry extends FlatHashtable.Entry { - final K1 key1; - final K2 key2; - - protected Entry(@Nullable K1 key1, @Nullable K2 key2) { - super(hash(key1, key2)); - this.key1 = key1; - this.key2 = key2; - } - - @Nullable - public final K1 key1() { - return key1; - } - - @Nullable - public final K2 key2() { - return key2; - } - - public boolean matches(@Nullable K1 key1, @Nullable K2 key2) { - return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); - } - - /** - * Returns the combined lookup hash for both key parts via {@link - * LongHashingUtils#hash(Object, Object)}. Null parts contribute {@code 0} (not a sentinel, - * unlike {@link D1.Entry}); {@link #matches(Object, Object)} resolves any collision. - */ - public static long hash(@Nullable Object key1, @Nullable Object key2) { - return LongHashingUtils.hash(key1, key2); - } + @Strategy + @FunctionalInterface + public interface CreateStrategy { + @Nonnull + E create(@Nullable K key); } - private final boolean growable; - private final float loadFactor; - private TEntry[] table; - private int size; - private int limit; // grow trigger when growable; hard cap when fixed - - private D2(TEntry[] table, float loadFactor, boolean growable, int limit) { - this.growable = growable; - this.loadFactor = loadFactor; - this.table = table; - this.size = 0; - this.limit = limit; - } + /** + * Balanced default load factor — target fill {@code <= 0.5} ({@code >= 2x} capacity). Linear + * probing then costs ~1.5 probes on a hit, ~2.5 on a miss (Knuth); the general-purpose sweet + * spot. + */ + public static final float DEFAULT_LOAD_FACTOR = 0.5f; /** - * A bounded {@link D2} holding up to {@code maxCapacity} entries at the {@link - * #DEFAULT_LOAD_FACTOR}, then capping ({@link #getOrCreate} returns {@code null}). + * Sparse load factor — target fill {@code <= 0.25} ({@code >= 4x} capacity): ~1.2 probes on a + * hit, ~1.4 on a miss. For miss-heavy hot paths (membership checks) where the extra empty slots + * are cheap and shaving the (quadratic-in-load) miss cost is worth the memory. Measure before + * preferring it to {@link #DEFAULT_LOAD_FACTOR}. There is deliberately no higher-than-default + * constant — open addressing degrades sharply past 0.5 (~8.5 probes/miss at 0.75). */ - @Nonnull - public static > D2 createFixed( - @Nonnull Class type, int maxCapacity) { - return createFixed(type, maxCapacity, DEFAULT_LOAD_FACTOR); - } + public static final float LOW_LOAD_FACTOR = 0.25f; - @Nonnull - public static > D2 createFixed( - @Nonnull Class type, int maxCapacity, float loadFactor) { - return new D2<>(create(type, maxCapacity, loadFactor), loadFactor, false, maxCapacity); + /** Power-of-two capacity for a cardinality budget at the {@link #DEFAULT_LOAD_FACTOR}. */ + public static int capacityFor(int cardinalityLimit) { + return capacityFor(cardinalityLimit, DEFAULT_LOAD_FACTOR); } /** - * A growable {@link D2} sized initially for {@code initialCapacity} entries at the {@link - * #DEFAULT_LOAD_FACTOR}; doubles on demand, so it never caps. + * Power-of-two capacity for a cardinality budget at {@code loadFactor}: the smallest power of two + * {@code >= ceil(cardinalityLimit / loadFactor)}. Because it rounds up to a power of two, the + * achieved fill is often below {@code loadFactor} (never above) — you always get at least the + * headroom you asked for. */ - @Nonnull - public static > D2 createGrowable( - @Nonnull Class type, int initialCapacity) { - return createGrowable(type, initialCapacity, DEFAULT_LOAD_FACTOR); + public static int capacityFor(int cardinalityLimit, float loadFactor) { + if (cardinalityLimit <= 0) { + throw new IllegalArgumentException("cardinalityLimit must be positive: " + cardinalityLimit); + } + if (!(loadFactor > 0f && loadFactor < 1f)) { + throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); + } + int min = (int) Math.ceil(cardinalityLimit / (double) loadFactor); + int capacity = Integer.highestOneBit(min - 1) << 1; + if (capacity <= 0) { + throw new IllegalArgumentException("cardinalityLimit " + + cardinalityLimit + + " at loadFactor " + + loadFactor + + " requires a capacity larger than Integer.MAX_VALUE"); + } + return capacity; } + /** + * Allocates a correctly-typed table for a cardinality budget ({@link #capacityFor} slots). + * Passing {@code type} makes the array's runtime component type {@code E} rather than {@code + * Object[]} — typed reads, real array-store checks, and a monomorphic element type for the JIT. + * Callers can't {@code new E[]} themselves under erasure; this does the one reflective allocation + * at construction (off any hot path). Note: this {@code create} mints the backing array; {@link + * CreateStrategy#create} mints an entry — different types, no ambiguity at the call site. + */ + @SuppressWarnings("unchecked") @Nonnull - public static > D2 createGrowable( - @Nonnull Class type, int initialCapacity, float loadFactor) { - E[] table = create(type, initialCapacity, loadFactor); - return new D2<>(table, loadFactor, true, (int) (table.length * loadFactor)); + public static E[] create(@Nonnull Class type, int cardinalityLimit) { + return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit)); } - public int size() { - return size; + /** + * {@link #create(Class, int)} at an explicit {@code loadFactor} (see {@link #capacityFor(int, + * float)} and the {@link #DEFAULT_LOAD_FACTOR} / {@link #LOW_LOAD_FACTOR} constants). + */ + @SuppressWarnings("unchecked") + @Nonnull + public static E[] create(@Nonnull Class type, int cardinalityLimit, float loadFactor) { + return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit, loadFactor)); } - /** Existing entry for {@code (key1, key2)}, or {@code null}. Read-only — never creates. */ + /** + * Existing entry for {@code key}, or {@code null}. Read-only — never creates. Single probe on a + * hit; walks to the first empty slot (or all the way around) on a miss. + */ + @StrategyConsumer @Nullable - public TEntry get(@Nullable K1 key1, @Nullable K2 key2) { - final long keyHash = Entry.hash(key1, key2); - final TEntry[] table = this.table; - final int mask = table.length - 1; - final int start = home(keyHash, mask); - int i = start; - for (; ; ) { - final TEntry e = table[i]; - if (e == null) { - return null; + public static E get(@Nonnull E[] table, K key, @Nonnull MatchingStrategy matchStrat) { + final int mask = table.length - 1; + final int start = home(matchStrat.hashKey(key), mask); + int i = start; + for (; ; ) { + final E e = table[i]; + if (e == null) { + return null; // empty slot terminates the probe (no tombstones) + } + if (matchStrat.matches(e, key)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; // wrapped ⇒ full, absent + } } - if (e.hash == keyHash && e.matches(key1, key2)) { - return e; - } - i = (i + 1) & mask; - if (i == start) { - return null; - } - } } /** - * Two-key analogue of {@link D1#getOrCreate}: growable never returns {@code null}; fixed - * returns {@code null} when full and {@code (key1, key2)} is absent. + * Existing entry for {@code key}, or a freshly {@link CreateStrategy#create created} + inserted + * one. Returns {@code null} only if the table is full (no empty slot) — the caller supplies its + * overflow default. The insert is a single plain reference store: a concurrent clobber / + * double-create is acceptable only when the payload makes it benign (see class doc). */ @StrategyConsumer @Nullable - public TEntry getOrCreate( - @Nullable K1 key1, - @Nullable K2 key2, - @Nonnull CreateStrategy2 createStrat) { - final TEntry existing = get(key1, key2); - if (existing != null) { - return existing; - } - if (size >= limit) { - if (!growable) { - return null; // fixed table full ⇒ caller supplies the overflow default + public static E getOrCreate( + @Nonnull E[] table, + K key, + @Nonnull MatchingStrategy matchStrat, + @Nonnull CreateStrategy createStrat) { + final int mask = table.length - 1; + final int start = home(matchStrat.hashKey(key), mask); + int i = start; + for (; ; ) { + final E e = table[i]; + if (e == null) { + final E created = createStrat.create(key); + table[i] = created; // single-reference publish; benign clobber (see class doc) + return created; + } + if (matchStrat.matches(e, key)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; // wrapped ⇒ full + } } - grow(); - } - final TEntry created = createStrat.create(key1, key2); - FlatHashtable.insert(table, created); - size++; - return created; } /** - * Unconditionally adds {@code entry} ({@code true}), or {@code false} if a fixed table is full. - * Comparison-free and caller-responsible: the caller must ensure {@code (key1, key2)} is - * absent. + * Unconditionally adds {@code entry} at the first empty slot from its {@link Entry#hash home}; + * {@code false} if the table is full. Convenience over the {@link HashStrategy}-taking overload + * for {@link Entry}-based entries (the home comes from the entry, so no strategy is needed). + * + *

Comparison-free and caller-responsible. It does not check for an existing key, so the + * caller must ensure {@code entry}'s key is absent. A duplicate lands shadowed further + * along the probe run — unreachable by {@link #get}, wasting a slot, and (if the key is later + * removed) able to resurrect stale data. Reach for it only from the expert tier, with that + * contract in hand. */ - public boolean insert(@Nonnull TEntry entry) { - if (size >= limit) { - if (!growable) { - return false; // fixed table full - } - grow(); - } - FlatHashtable.insert(table, entry); - size++; - return true; + public static boolean insert(@Nonnull E[] table, @Nonnull E entry) { + return placeAt(table, entry, entry.hash); } - private void grow() { - table = resize(table); - limit = (int) (table.length * loadFactor); + /** + * {@link #insert(Entry[], Entry)} for any entry type: the home comes from {@link + * HashStrategy#hashOf}. Same comparison-free, caller-ensures-absence contract. + */ + @StrategyConsumer + public static boolean insert(@Nonnull E[] table, @Nonnull E entry, @Nonnull HashStrategy hashStrat) { + return placeAt(table, entry, hashStrat.hashOf(entry)); } - public void clear() { - Arrays.fill(table, null); - size = 0; + /** + * Shared placement core: probe from {@code hash}'s home to the first empty slot; false if full. + */ + private static boolean placeAt(E[] table, E entry, long hash) { + final int mask = table.length - 1; + final int start = home(hash, mask); + int i = start; + for (; ; ) { + if (table[i] == null) { + table[i] = entry; // single-reference publish (see class doc) + return true; + } + i = (i + 1) & mask; + if (i == start) { + return false; // wrapped ⇒ full + } + } } - public void forEach(@Nonnull Consumer consumer) { - FlatHashtable.forEach(table, consumer); + /** + * Placement slot for {@code hash} in a table of {@code mask + 1} slots. The table owns the + * spread: a golden-ratio (Fibonacci) multiply diffuses the hash across all bits — robust to weak + * or {@code int}-derived {@code hashCode}s and to full 64-bit composite hashes alike — then the + * low index bits are taken. So a strategy may return a plain {@code hashCode} without pre-mixing. + * Package-private so tests can predict slots. + */ + static int home(long hash, int mask) { + long z = hash * 0x9E3779B97F4A7C15L; // 2^64 / golden ratio; odd ⇒ a bijection (loses no bits) + z ^= z >>> 32; // fold the well-mixed high half down into the low bits the mask keeps + return (int) z & mask; } - public void forEach(C context, @Nonnull BiConsumer consumer) { - FlatHashtable.forEach(table, context, consumer); - } - } - - /** - * Two-key creation strategy for {@link D2#getOrCreate}: mint a new entry for {@code (key1, - * key2)}. Like {@link CreateStrategy}, supply a {@code static final} constant or a - * non-capturing lambda (e.g. {@code MyEntry::new}) so it stays a single monomorphic, - * allocation-free instance. - * - * @param stored entry to create - * @param first key type - * @param second key type - */ - @Strategy - @FunctionalInterface - public interface CreateStrategy2 { + /** + * Doubles capacity and rehashes every entry into a new table — call when {@link #insert} returns + * {@code false} and you want to grow rather than reject; the caller stores the returned array + * back. Convenience over the {@link HashStrategy}-taking overload for {@link Entry}-based entries + * (the home comes from {@link Entry#hash}). See {@link #resizingInsert(Entry[], Entry)} to do + * both in one call, and its note on growing over unbounded key domains. + */ @Nonnull - E create(@Nullable K1 key1, @Nullable K2 key2); - } - - /** - * Entry-side strategy: the hash of a stored {@code entry} — its home is {@link #home}{@code - * (hashOf(entry))}. Used by {@link #insert} / {@link #iterator} / {@link #resize}, which have an - * entry rather than a lookup key. Must be consistent with {@link MatchingStrategy#hashKey} (see - * the class contract). Supply a {@code static final} constant or a non-capturing lambda so it - * stays a single monomorphic instance (see {@link Strategy}); for {@link Entry}-based tables use - * the strategy-free overloads, which read {@link Entry#hash} directly. - * - * @param stored entry - */ - @Strategy - @FunctionalInterface - public interface HashStrategy { - long hashOf(@Nonnull E entry); - } - - /** - * Key-side strategy: whether a stored {@code entry} is the one for a lookup {@code key} ({@link - * #matches}), and how to hash that key ({@link #hashKey}). {@code hashKey} defaults to {@code - * key.hashCode()} — override it only when the key's identity needs different hashing (e.g. - * case-insensitive), and then keep it consistent with the table's {@link HashStrategy#hashOf}. - * Used by {@link #get} / {@link #getOrCreate}. - * - *

A {@link FunctionalInterface} ({@code matches} is the sole abstract method), so the common - * case can be a non-capturing lambda; a strategy that also customizes hashing is a named class - * that overrides {@code hashKey}. - * - * @param stored entry - * @param lookup key - */ - @Strategy - @FunctionalInterface - public interface MatchingStrategy { - /** Whether the stored {@code entry} is the one for {@code key}. */ - boolean matches(@Nonnull E entry, K key); + public static E[] resize(@Nonnull E[] table) { + E[] grown = allocateGrown(table); + for (final E e : table) { + if (e != null) { + placeAt(grown, e, e.hash); + } + } + return grown; + } /** - * Hash of {@code key}; defaults to {@code key.hashCode()} (the table applies its own spread). - * This default NPEs on a {@code null} key — unlike {@link D1.Entry}, which maps a {@code null} - * key to a dedicated hash sentinel, the raw core takes no position on {@code null} keys; a - * strategy that must support them overrides {@code hashKey} (and {@code matches}) to handle - * {@code null} itself. + * {@link #resize(Entry[])} for any entry type: each entry's home comes from {@link + * HashStrategy#hashOf}. Not a {@link StrategyConsumer} — the rehash is a cold, one-off traversal, + * not a hot specialization site. */ - default long hashKey(K key) { - return key.hashCode(); - } - } - - /** - * The whole policy for a table you both look up in and insert into: a {@link MatchingStrategy} - * (key side) and a {@link HashStrategy} (entry side) in one. Extend as a stateless final - * class held in a concrete-typed {@code static final} singleton so the JIT specializes each call - * site (see {@link Strategy}); an abstract class (not an interface) so a specialization - * miss falls back to {@code invokevirtual} rather than the costlier megamorphic {@code - * invokeinterface}. - * - * @param stored entry - * @param lookup key - */ - @Strategy - public abstract static class EntryStrategy - implements HashStrategy, MatchingStrategy {} - - /** - * {@link EntryStrategy} for {@code String} keys compared case-insensitively: seals {@link - * #hashKey} to {@link Strings#caseInsensitiveHashCode} (consistent with {@link - * String#equalsIgnoreCase}, which callers use in {@link #matches}). Callers still supply {@link - * #matches} (typically {@code key.equalsIgnoreCase(entry.key)}) and {@link #hashOf} (the same - * case-insensitive hash of the entry's key, or the cached {@link Entry#hash}). - * - * @param stored entry — self-contained (carries its own key) - */ - public abstract static class CaseInsensitiveStringStrategy extends EntryStrategy { - @Override - public final long hashKey(String key) { - return Strings.caseInsensitiveHashCode(key); // raw; the table spreads before masking - } - } - - /** - * Creation strategy: mint a new entry for {@code key} (called once, on insert). A {@link - * FunctionalInterface} — supply a {@code static final} constant or a non-capturing lambda - * (e.g. {@code MyEntry::new}) so it stays a single monomorphic, allocation-free instance; a - * capturing lambda silently re-allocates per call and can de-monomorphize the site (see {@link - * Strategy}). Bespoke rather than {@link java.util.function.Function} so it carries the {@link - * Strategy} contract and reads as {@code create} at the call site. - * - * @param stored entry to create - * @param lookup key - */ - @Strategy - @FunctionalInterface - public interface CreateStrategy { @Nonnull - E create(@Nullable K key); - } - - /** - * Balanced default load factor — target fill {@code <= 0.5} ({@code >= 2x} capacity). Linear - * probing then costs ~1.5 probes on a hit, ~2.5 on a miss (Knuth); the general-purpose sweet - * spot. - */ - public static final float DEFAULT_LOAD_FACTOR = 0.5f; - - /** - * Sparse load factor — target fill {@code <= 0.25} ({@code >= 4x} capacity): ~1.2 probes on a - * hit, ~1.4 on a miss. For miss-heavy hot paths (membership checks) where the extra empty slots - * are cheap and shaving the (quadratic-in-load) miss cost is worth the memory. Measure before - * preferring it to {@link #DEFAULT_LOAD_FACTOR}. There is deliberately no higher-than-default - * constant — open addressing degrades sharply past 0.5 (~8.5 probes/miss at 0.75). - */ - public static final float LOW_LOAD_FACTOR = 0.25f; - - /** Power-of-two capacity for a cardinality budget at the {@link #DEFAULT_LOAD_FACTOR}. */ - public static int capacityFor(int cardinalityLimit) { - return capacityFor(cardinalityLimit, DEFAULT_LOAD_FACTOR); - } - - /** - * Power-of-two capacity for a cardinality budget at {@code loadFactor}: the smallest power of two - * {@code >= ceil(cardinalityLimit / loadFactor)}. Because it rounds up to a power of two, the - * achieved fill is often below {@code loadFactor} (never above) — you always get at least the - * headroom you asked for. - */ - public static int capacityFor(int cardinalityLimit, float loadFactor) { - if (cardinalityLimit <= 0) { - throw new IllegalArgumentException("cardinalityLimit must be positive: " + cardinalityLimit); - } - if (!(loadFactor > 0f && loadFactor < 1f)) { - throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); - } - int min = (int) Math.ceil(cardinalityLimit / (double) loadFactor); - int capacity = Integer.highestOneBit(min - 1) << 1; - if (capacity <= 0) { - throw new IllegalArgumentException( - "cardinalityLimit " - + cardinalityLimit - + " at loadFactor " - + loadFactor - + " requires a capacity larger than Integer.MAX_VALUE"); - } - return capacity; - } - - /** - * Allocates a correctly-typed table for a cardinality budget ({@link #capacityFor} slots). - * Passing {@code type} makes the array's runtime component type {@code E} rather than {@code - * Object[]} — typed reads, real array-store checks, and a monomorphic element type for the JIT. - * Callers can't {@code new E[]} themselves under erasure; this does the one reflective allocation - * at construction (off any hot path). Note: this {@code create} mints the backing array; {@link - * CreateStrategy#create} mints an entry — different types, no ambiguity at the call site. - */ - @SuppressWarnings("unchecked") - @Nonnull - public static E[] create(@Nonnull Class type, int cardinalityLimit) { - return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit)); - } - - /** - * {@link #create(Class, int)} at an explicit {@code loadFactor} (see {@link #capacityFor(int, - * float)} and the {@link #DEFAULT_LOAD_FACTOR} / {@link #LOW_LOAD_FACTOR} constants). - */ - @SuppressWarnings("unchecked") - @Nonnull - public static E[] create(@Nonnull Class type, int cardinalityLimit, float loadFactor) { - return (E[]) Array.newInstance(type, capacityFor(cardinalityLimit, loadFactor)); - } - - /** - * Existing entry for {@code key}, or {@code null}. Read-only — never creates. Single probe on a - * hit; walks to the first empty slot (or all the way around) on a miss. - */ - @StrategyConsumer - @Nullable - public static E get( - @Nonnull E[] table, K key, @Nonnull MatchingStrategy matchStrat) { - final int mask = table.length - 1; - final int start = home(matchStrat.hashKey(key), mask); - int i = start; - for (; ; ) { - final E e = table[i]; - if (e == null) { - return null; // empty slot terminates the probe (no tombstones) - } - if (matchStrat.matches(e, key)) { - return e; - } - i = (i + 1) & mask; - if (i == start) { - return null; // wrapped ⇒ full, absent - } - } - } - - /** - * Existing entry for {@code key}, or a freshly {@link CreateStrategy#create created} + inserted - * one. Returns {@code null} only if the table is full (no empty slot) — the caller supplies its - * overflow default. The insert is a single plain reference store: a concurrent clobber / - * double-create is acceptable only when the payload makes it benign (see class doc). - */ - @StrategyConsumer - @Nullable - public static E getOrCreate( - @Nonnull E[] table, - K key, - @Nonnull MatchingStrategy matchStrat, - @Nonnull CreateStrategy createStrat) { - final int mask = table.length - 1; - final int start = home(matchStrat.hashKey(key), mask); - int i = start; - for (; ; ) { - final E e = table[i]; - if (e == null) { - final E created = createStrat.create(key); - table[i] = created; // single-reference publish; benign clobber (see class doc) - return created; - } - if (matchStrat.matches(e, key)) { - return e; - } - i = (i + 1) & mask; - if (i == start) { - return null; // wrapped ⇒ full - } - } - } - - /** - * Unconditionally adds {@code entry} at the first empty slot from its {@link Entry#hash home}; - * {@code false} if the table is full. Convenience over the {@link HashStrategy}-taking overload - * for {@link Entry}-based entries (the home comes from the entry, so no strategy is needed). - * - *

Comparison-free and caller-responsible. It does not check for an existing key, so the - * caller must ensure {@code entry}'s key is absent. A duplicate lands shadowed further - * along the probe run — unreachable by {@link #get}, wasting a slot, and (if the key is later - * removed) able to resurrect stale data. Reach for it only from the expert tier, with that - * contract in hand. - */ - public static boolean insert(@Nonnull E[] table, @Nonnull E entry) { - return placeAt(table, entry, entry.hash); - } - - /** - * {@link #insert(Entry[], Entry)} for any entry type: the home comes from {@link - * HashStrategy#hashOf}. Same comparison-free, caller-ensures-absence contract. - */ - @StrategyConsumer - public static boolean insert( - @Nonnull E[] table, @Nonnull E entry, @Nonnull HashStrategy hashStrat) { - return placeAt(table, entry, hashStrat.hashOf(entry)); - } - - /** - * Shared placement core: probe from {@code hash}'s home to the first empty slot; false if full. - */ - private static boolean placeAt(E[] table, E entry, long hash) { - final int mask = table.length - 1; - final int start = home(hash, mask); - int i = start; - for (; ; ) { - if (table[i] == null) { - table[i] = entry; // single-reference publish (see class doc) - return true; - } - i = (i + 1) & mask; - if (i == start) { - return false; // wrapped ⇒ full - } + public static E[] resize(@Nonnull E[] table, @Nonnull HashStrategy hashStrat) { + E[] grown = allocateGrown(table); + for (final E e : table) { + if (e != null) { + placeAt(grown, e, hashStrat.hashOf(e)); + } + } + return grown; } - } - - /** - * Placement slot for {@code hash} in a table of {@code mask + 1} slots. The table owns the - * spread: a golden-ratio (Fibonacci) multiply diffuses the hash across all bits — robust to weak - * or {@code int}-derived {@code hashCode}s and to full 64-bit composite hashes alike — then the - * low index bits are taken. So a strategy may return a plain {@code hashCode} without pre-mixing. - * Package-private so tests can predict slots. - */ - static int home(long hash, int mask) { - long z = hash * 0x9E3779B97F4A7C15L; // 2^64 / golden ratio; odd ⇒ a bijection (loses no bits) - z ^= z >>> 32; // fold the well-mixed high half down into the low bits the mask keeps - return (int) z & mask; - } - - /** - * Doubles capacity and rehashes every entry into a new table — call when {@link #insert} returns - * {@code false} and you want to grow rather than reject; the caller stores the returned array - * back. Convenience over the {@link HashStrategy}-taking overload for {@link Entry}-based entries - * (the home comes from {@link Entry#hash}). See {@link #resizingInsert(Entry[], Entry)} to do - * both in one call, and its note on growing over unbounded key domains. - */ - @Nonnull - public static E[] resize(@Nonnull E[] table) { - E[] grown = allocateGrown(table); - for (final E e : table) { - if (e != null) { - placeAt(grown, e, e.hash); - } + + /** + * A new, empty table of twice the capacity, of the same runtime component type as {@code table}. + */ + @SuppressWarnings("unchecked") + private static E[] allocateGrown(E[] table) { + return (E[]) Array.newInstance(table.getClass().getComponentType(), grownLength(table.length)); } - return grown; - } - - /** - * {@link #resize(Entry[])} for any entry type: each entry's home comes from {@link - * HashStrategy#hashOf}. Not a {@link StrategyConsumer} — the rehash is a cold, one-off traversal, - * not a hot specialization site. - */ - @Nonnull - public static E[] resize(@Nonnull E[] table, @Nonnull HashStrategy hashStrat) { - E[] grown = allocateGrown(table); - for (final E e : table) { - if (e != null) { - placeAt(grown, e, hashStrat.hashOf(e)); - } + + /** + * {@code currentLength << 1}, guarded against overflow. {@code currentLength << 1} overflows to a + * negative value at {@code currentLength == 1 << 30}; without this check the failure surfaces as + * an opaque {@code NegativeArraySizeException} from {@code Array.newInstance} in {@link + * #allocateGrown}. Same overflow class as {@link #capacityFor}'s guard -- this is the growth-time + * counterpart. + */ + static int grownLength(int currentLength) { + int grownLength = currentLength << 1; + if (grownLength <= 0) { + throw new IllegalStateException( + "cannot grow a table of length " + currentLength + " past Integer.MAX_VALUE"); + } + return grownLength; } - return grown; - } - - /** - * A new, empty table of twice the capacity, of the same runtime component type as {@code table}. - */ - @SuppressWarnings("unchecked") - private static E[] allocateGrown(E[] table) { - return (E[]) Array.newInstance(table.getClass().getComponentType(), grownLength(table.length)); - } - - /** - * {@code currentLength << 1}, guarded against overflow. {@code currentLength << 1} overflows to a - * negative value at {@code currentLength == 1 << 30}; without this check the failure surfaces as - * an opaque {@code NegativeArraySizeException} from {@code Array.newInstance} in {@link - * #allocateGrown}. Same overflow class as {@link #capacityFor}'s guard -- this is the growth-time - * counterpart. - */ - static int grownLength(int currentLength) { - int grownLength = currentLength << 1; - if (grownLength <= 0) { - throw new IllegalStateException( - "cannot grow a table of length " + currentLength + " past Integer.MAX_VALUE"); + + /** + * {@link #insert(Entry[], Entry) insert} that grows on demand: adds {@code entry}, {@link + * #resize(Entry[]) resizing} first if the table is full, and returns the table to store back — + * the same array if it fit, a new larger one if it grew: + * + *

{@code
+     * table = FlatHashtable.resizingInsert(table, entry); // always reassign
+     * }
+ * + * Same comparison-free, caller-ensures-absence contract as {@link #insert}. + * + *

Grows unboundedly. Unlike {@code insert}'s {@code false}, this hides the full signal, + * so it is the easiest place to leak memory: use it only for a genuinely bounded key domain, + * never over externally-controlled cardinality. + * + *

Grows at 100% fill, not at a load factor. The raw core carries no live-size counter + * (see the class contract), so it has no cheap way to know the current fill short of a full table + * scan; growing only when {@link #insert} reports "full" is the only trigger available without + * one. {@link #DEFAULT_LOAD_FACTOR} and {@link #LOW_LOAD_FACTOR}'s Knuth figures size a + * fixed table (via {@link #capacityFor} / {@link #create}) up front for its expected + * cardinality; they are not an insert-time growth trigger here. A caller that sizes up front — + * the expected usage — never sees the difference; one that instead leans on {@code + * resizingInsert} to grow an under-sized table from scratch runs fill up through 0.75, 0.9, and + * 1.0 before doubling, past where those figures stop applying. A caller that already tracks live + * size (as {@link D1} / {@link D2} do) can grow at a load factor of its own choosing instead. + */ + @Nonnull + public static E[] resizingInsert(@Nonnull E[] table, @Nonnull E entry) { + E[] t = table; + while (!insert(t, entry)) { + t = resize(t); // one doubling always suffices; the loop is belt-and-braces + } + return t; } - return grownLength; - } - - /** - * {@link #insert(Entry[], Entry) insert} that grows on demand: adds {@code entry}, {@link - * #resize(Entry[]) resizing} first if the table is full, and returns the table to store back — - * the same array if it fit, a new larger one if it grew: - * - *

{@code
-   * table = FlatHashtable.resizingInsert(table, entry); // always reassign
-   * }
- * - * Same comparison-free, caller-ensures-absence contract as {@link #insert}. - * - *

Grows unboundedly. Unlike {@code insert}'s {@code false}, this hides the full signal, - * so it is the easiest place to leak memory: use it only for a genuinely bounded key domain, - * never over externally-controlled cardinality. - * - *

Grows at 100% fill, not at a load factor. The raw core carries no live-size counter - * (see the class contract), so it has no cheap way to know the current fill short of a full table - * scan; growing only when {@link #insert} reports "full" is the only trigger available without - * one. {@link #DEFAULT_LOAD_FACTOR} and {@link #LOW_LOAD_FACTOR}'s Knuth figures size a - * fixed table (via {@link #capacityFor} / {@link #create}) up front for its expected - * cardinality; they are not an insert-time growth trigger here. A caller that sizes up front — - * the expected usage — never sees the difference; one that instead leans on {@code - * resizingInsert} to grow an under-sized table from scratch runs fill up through 0.75, 0.9, and - * 1.0 before doubling, past where those figures stop applying. A caller that already tracks live - * size (as {@link D1} / {@link D2} do) can grow at a load factor of its own choosing instead. - */ - @Nonnull - public static E[] resizingInsert(@Nonnull E[] table, @Nonnull E entry) { - E[] t = table; - while (!insert(t, entry)) { - t = resize(t); // one doubling always suffices; the loop is belt-and-braces + + /** + * {@link #resizingInsert(Entry[], Entry)} for any entry type (home via {@link + * HashStrategy#hashOf}). Same grows-unboundedly caution and same probe-to-full growth trigger. + */ + @StrategyConsumer + @Nonnull + public static E[] resizingInsert(@Nonnull E[] table, @Nonnull E entry, @Nonnull HashStrategy hashStrat) { + E[] t = table; + while (!insert(t, entry, hashStrat)) { + t = resize(t, hashStrat); + } + return t; } - return t; - } - - /** - * {@link #resizingInsert(Entry[], Entry)} for any entry type (home via {@link - * HashStrategy#hashOf}). Same grows-unboundedly caution and same probe-to-full growth trigger. - */ - @StrategyConsumer - @Nonnull - public static E[] resizingInsert( - @Nonnull E[] table, @Nonnull E entry, @Nonnull HashStrategy hashStrat) { - E[] t = table; - while (!insert(t, entry, hashStrat)) { - t = resize(t, hashStrat); + + /** Applies {@code consumer} to every entry in {@code table} (skipping empty slots); any order. */ + public static void forEach(@Nonnull E[] table, @Nonnull Consumer consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(e); + } + } } - return t; - } - - /** Applies {@code consumer} to every entry in {@code table} (skipping empty slots); any order. */ - public static void forEach(@Nonnull E[] table, @Nonnull Consumer consumer) { - for (final E e : table) { - if (e != null) { - consumer.accept(e); - } + + /** + * Context-passing {@link #forEach(Object[], Consumer)}: pair a non-capturing {@link BiConsumer} + * (typically a {@code static final}) with side-band {@code context} to avoid a per-call closure. + */ + public static void forEach( + @Nonnull E[] table, C context, @Nonnull BiConsumer consumer) { + for (final E e : table) { + if (e != null) { + consumer.accept(context, e); + } + } } - } - - /** - * Context-passing {@link #forEach(Object[], Consumer)}: pair a non-capturing {@link BiConsumer} - * (typically a {@code static final}) with side-band {@code context} to avoid a per-call closure. - */ - public static void forEach( - @Nonnull E[] table, C context, @Nonnull BiConsumer consumer) { - for (final E e : table) { - if (e != null) { - consumer.accept(context, e); - } + + /** + * Read-only iterator over the entries sharing {@code hash} — walks the probe run from {@code + * hash}'s home and yields each entry whose {@link HashStrategy#hashOf} equals {@code hash}, + * stopping at the first empty slot (the FlatHashtable analogue of walking a chained bucket). + * + *

This general overload holds the strategy in a field, so {@code hashOf} is called virtually + * (not inlined). For {@link Entry}-based tables prefer {@link #iterator(Entry[], long)}, which + * specializes the traversal so {@code hashOf} inlines. (Still pass a {@code static final} + * strategy to avoid a per-call allocation.) + */ + @Nonnull + public static Iterator iterator(@Nonnull E[] table, long hash, @Nonnull HashStrategy hashStrat) { + return new StrategyHashIterator<>(table, hash, hashStrat); } - } - - /** - * Read-only iterator over the entries sharing {@code hash} — walks the probe run from {@code - * hash}'s home and yields each entry whose {@link HashStrategy#hashOf} equals {@code hash}, - * stopping at the first empty slot (the FlatHashtable analogue of walking a chained bucket). - * - *

This general overload holds the strategy in a field, so {@code hashOf} is called virtually - * (not inlined). For {@link Entry}-based tables prefer {@link #iterator(Entry[], long)}, which - * specializes the traversal so {@code hashOf} inlines. (Still pass a {@code static final} - * strategy to avoid a per-call allocation.) - */ - @Nonnull - public static Iterator iterator( - @Nonnull E[] table, long hash, @Nonnull HashStrategy hashStrat) { - return new StrategyHashIterator<>(table, hash, hashStrat); - } - - /** - * {@link #iterator(Object[], long, HashStrategy)} for {@link Entry}-based tables: the filter hash - * comes from {@link Entry#hash}, so no strategy is needed. Returns a specialized iterator that - * feeds the {@link Entry}-hash strategy singleton into the shared traversal template as a - * constant, so {@code hashOf} devirtualizes to {@code entry.hash} and inlines — a monomorphic - * call site thus gets a devirtualized pull-based traversal while keeping the plain {@link - * Iterator} API and reusing the same core (see the {@link HashIterator} base). Same {@code - * Iterator} return type as the general overload; the call site specializes by its own - * monomorphism. - */ - @Nonnull - public static Iterator iterator(@Nonnull E[] table, long hash) { - return new EntryHashIterator<>(table, hash); - } - - /** - * Shared iterator core. The traversal lives in {@code final} template methods parameterized by - * the strategy ({@code advanceWith}/{@code nextWith}); each concrete subclass implements {@link - * #next} by handing in its strategy source — a field (general) or a {@code static final} constant - * (Entry). Feeding a constant into the {@code final} template is what lets the specialized - * subclass inline {@code hashOf} (the CacheHelper static-polymorphism move), so the two share all - * the mechanism yet differ only in whether the strategy call devirtualizes. - */ - private abstract static class HashIterator implements Iterator { - final E[] table; - final long hash; - final int start; - int i; - boolean done; - E lookahead; - - HashIterator(E[] table, long hash) { - this.table = table; - this.hash = hash; - this.start = home(hash, table.length - 1); - this.i = this.start; - // Priming advance() is left to the concrete ctor: it needs the subclass's strategy source, - // which isn't set until after super(). + + /** + * {@link #iterator(Object[], long, HashStrategy)} for {@link Entry}-based tables: the filter hash + * comes from {@link Entry#hash}, so no strategy is needed. Returns a specialized iterator that + * feeds the {@link Entry}-hash strategy singleton into the shared traversal template as a + * constant, so {@code hashOf} devirtualizes to {@code entry.hash} and inlines — a monomorphic + * call site thus gets a devirtualized pull-based traversal while keeping the plain {@link + * Iterator} API and reusing the same core (see the {@link HashIterator} base). Same {@code + * Iterator} return type as the general overload; the call site specializes by its own + * monomorphism. + */ + @Nonnull + public static Iterator iterator(@Nonnull E[] table, long hash) { + return new EntryHashIterator<>(table, hash); } /** - * Template traversal core, parameterized by the strategy. {@code final} so that a subclass - * passing a constant strategy inlines this and devirtualizes {@code hashOf}. + * Shared iterator core. The traversal lives in {@code final} template methods parameterized by + * the strategy ({@code advanceWith}/{@code nextWith}); each concrete subclass implements {@link + * #next} by handing in its strategy source — a field (general) or a {@code static final} constant + * (Entry). Feeding a constant into the {@code final} template is what lets the specialized + * subclass inline {@code hashOf} (the CacheHelper static-polymorphism move), so the two share all + * the mechanism yet differ only in whether the strategy call devirtualizes. */ - final void advanceWith(HashStrategy hashStrat) { - lookahead = null; - if (done) { - return; - } - final int mask = table.length - 1; - for (; ; ) { - final E e = table[i]; - if (e == null) { - done = true; // probe run ends at the first empty slot - return; + private abstract static class HashIterator implements Iterator { + final E[] table; + final long hash; + final int start; + int i; + boolean done; + E lookahead; + + HashIterator(E[] table, long hash) { + this.table = table; + this.hash = hash; + this.start = home(hash, table.length - 1); + this.i = this.start; + // Priming advance() is left to the concrete ctor: it needs the subclass's strategy source, + // which isn't set until after super(). } - final boolean match = hashStrat.hashOf(e) == hash; - i = (i + 1) & mask; - final boolean wrapped = (i == start); - if (match) { - lookahead = e; - done = wrapped; - return; + + /** + * Template traversal core, parameterized by the strategy. {@code final} so that a subclass + * passing a constant strategy inlines this and devirtualizes {@code hashOf}. + */ + final void advanceWith(HashStrategy hashStrat) { + lookahead = null; + if (done) { + return; + } + final int mask = table.length - 1; + for (; ; ) { + final E e = table[i]; + if (e == null) { + done = true; // probe run ends at the first empty slot + return; + } + final boolean match = hashStrat.hashOf(e) == hash; + i = (i + 1) & mask; + final boolean wrapped = (i == start); + if (match) { + lookahead = e; + done = wrapped; + return; + } + if (wrapped) { + done = true; // walked the whole table without an empty slot + return; + } + } } - if (wrapped) { - done = true; // walked the whole table without an empty slot - return; + + final E nextWith(HashStrategy hashStrat) { + final E e = lookahead; + if (e == null) { + throw new NoSuchElementException(); + } + advanceWith(hashStrat); + return e; } - } - } - final E nextWith(HashStrategy hashStrat) { - final E e = lookahead; - if (e == null) { - throw new NoSuchElementException(); - } - advanceWith(hashStrat); - return e; - } + @Override + public final boolean hasNext() { + return lookahead != null; + } - @Override - public final boolean hasNext() { - return lookahead != null; + // Abstract so each subclass injects its own strategy source into nextWith(); that binding is + // what lets the Entry variant inline hashOf while the general one stays virtual. + @Override + public abstract E next(); } - // Abstract so each subclass injects its own strategy source into nextWith(); that binding is - // what lets the Entry variant inline hashOf while the general one stays virtual. - @Override - public abstract E next(); - } - - /** General iterator: strategy held in a field, so {@code hashOf} stays a virtual call. */ - private static final class StrategyHashIterator extends HashIterator { - // Dynamic dispatch on purpose: general iterator fallback partner to EntryHashIterator - // (see Strategy's Checker contract). - private final HashStrategy hashStrat; - - StrategyHashIterator(E[] table, long hash, HashStrategy hashStrat) { - super(table, hash); - this.hashStrat = hashStrat; - advanceWith(hashStrat); // prime - } + /** General iterator: strategy held in a field, so {@code hashOf} stays a virtual call. */ + private static final class StrategyHashIterator extends HashIterator { + // Dynamic dispatch on purpose: general iterator fallback partner to EntryHashIterator + // (see Strategy's Checker contract). + private final HashStrategy hashStrat; - @Override - public E next() { - return nextWith(hashStrat); + StrategyHashIterator(E[] table, long hash, HashStrategy hashStrat) { + super(table, hash); + this.hashStrat = hashStrat; + advanceWith(hashStrat); // prime + } + + @Override + public E next() { + return nextWith(hashStrat); + } } - } - - /** - * Entry iterator: feeds the constant {@link #ENTRY_HASH} singleton, so {@code hashOf} inlines to - * {@code entry.hash}. - */ - private static final class EntryHashIterator extends HashIterator { - EntryHashIterator(E[] table, long hash) { - super(table, hash); - advanceWith(entryHash()); // prime with the constant + + /** + * Entry iterator: feeds the constant {@link #ENTRY_HASH} singleton, so {@code hashOf} inlines to + * {@code entry.hash}. + */ + private static final class EntryHashIterator extends HashIterator { + EntryHashIterator(E[] table, long hash) { + super(table, hash); + advanceWith(entryHash()); // prime with the constant + } + + @Override + public E next() { + return nextWith(entryHash()); + } } - @Override - public E next() { - return nextWith(entryHash()); + /** + * Stateless {@link HashStrategy} that reads the cached {@link Entry#hash} — the constant fed into + * the Entry iterator template so {@code hashOf} devirtualizes. + */ + private static final HashStrategy ENTRY_HASH = entry -> entry.hash; + + @SuppressWarnings("unchecked") + private static HashStrategy entryHash() { + return (HashStrategy) ENTRY_HASH; // safe: hashOf only reads Entry.hash, present on all E } - } - - /** - * Stateless {@link HashStrategy} that reads the cached {@link Entry#hash} — the constant fed into - * the Entry iterator template so {@code hashOf} devirtualizes. - */ - private static final HashStrategy ENTRY_HASH = entry -> entry.hash; - - @SuppressWarnings("unchecked") - private static HashStrategy entryHash() { - return (HashStrategy) ENTRY_HASH; // safe: hashOf only reads Entry.hash, present on all E - } } diff --git a/internal-api/src/main/java/datadog/trace/util/HashingUtils.java b/internal-api/src/main/java/datadog/trace/util/HashingUtils.java index 4d7c405828d..7ac77dbdcb9 100644 --- a/internal-api/src/main/java/datadog/trace/util/HashingUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/HashingUtils.java @@ -9,165 +9,165 @@ * hash that take many argument lengths to avoid var-args allocation. */ public final class HashingUtils { - private HashingUtils() {} - - public static final int hashCode(@Nullable Object obj) { - return obj != null ? obj.hashCode() : 0; - } - - public static final int hash(boolean value) { - return Boolean.hashCode(value); - } - - public static final int hash(char value) { - return Character.hashCode(value); - } - - public static final int hash(byte value) { - return Byte.hashCode(value); - } - - public static final int hash(short value) { - return Short.hashCode(value); - } - - public static final int hash(int value) { - return Integer.hashCode(value); - } - - public static final int hash(long value) { - return Long.hashCode(value); - } - - public static final int hash(float value) { - return Float.hashCode(value); - } - - public static final int hash(double value) { - return Double.hashCode(value); - } - - public static final int hash(Object obj) { - return obj != null ? obj.hashCode() : 0; - } - - public static final int hash(Object obj0, Object obj1) { - return hash(hash(obj0), hash(obj1)); - } - - public static final int hash(int hash0, int hash1) { - return 31 * hash0 + hash1; - } - - public static final int hash(Object obj0, Object obj1, Object obj2) { - return hash(hashCode(obj0), hashCode(obj1), hashCode(obj2)); - } - - public static final int hash(int hash0, int hash1, int hash2) { - // DQH - Micro-optimizing, 31 * 31 will constant fold - // Since there are multiple execution ports for load & store, - // this will make good use of the core. - return 31 * 31 * hash0 + 31 * hash1 + hash2; - } - - public static final int hash(Object obj0, Object obj1, Object obj2, Object obj3) { - return hash(hashCode(obj0), hashCode(obj1), hashCode(obj2), hashCode(obj3)); - } - - public static final int hash(int hash0, int hash1, int hash2, int hash3) { - // DQH - Micro-optimizing, 31 * 31 will constant fold - // Since there are multiple execution ports for load & store, - // this will make good use of the core. - return 31 * 31 * 31 * hash0 + 31 * 31 * hash1 + 31 * hash2 + hash3; - } - - public static final int hash(Object obj0, Object obj1, Object obj2, Object obj3, Object obj4) { - return hash(hashCode(obj0), hashCode(obj1), hashCode(obj2), hashCode(obj3), hashCode(obj4)); - } - - public static final int hash(int hash0, int hash1, int hash2, int hash3, int hash4) { - // DQH - Micro-optimizing, 31 * 31 will constant fold - // Since there are multiple execution ports for load & store, - // this will make good use of the core. - return 31 * 31 * 31 * 31 * hash0 + 31 * 31 * 31 * hash1 + 31 * 31 * hash2 + 31 * hash3 + hash4; - } - - @Deprecated - public static final int hash(int[] hashes) { - int result = 0; - for (int hash : hashes) { - result = addToHash(result, hash); - } - return result; - } - - public static final int addToHash(int hash, int value) { - return 31 * hash + value; - } - - public static final int addToHash(int hash, @Nullable Object obj) { - return addToHash(hash, hashCode(obj)); - } - - public static final int addToHash(int hash, boolean value) { - return addToHash(hash, Boolean.hashCode(value)); - } - - public static final int addToHash(int hash, char value) { - return addToHash(hash, Character.hashCode(value)); - } - - public static final int addToHash(int hash, byte value) { - return addToHash(hash, Byte.hashCode(value)); - } - - public static final int addToHash(int hash, short value) { - return addToHash(hash, Short.hashCode(value)); - } - - public static final int addToHash(int hash, long value) { - return addToHash(hash, Long.hashCode(value)); - } - - public static final int addToHash(int hash, float value) { - return addToHash(hash, Float.hashCode(value)); - } - - public static final int addToHash(int hash, double value) { - return addToHash(hash, Double.hashCode(value)); - } - - /** Folds {@code arr[0..len)}; the array must be non-null, but its elements may be null. */ - public static final int addToHash(int hash, @Nonnull Object[] arr, int len) { - for (int i = 0; i < len; i++) { - hash = addToHash(hash, arr[i]); - } - return hash; - } - - public static final int addToHash(int hash, @Nonnull Object[] arr) { - return addToHash(hash, arr, arr.length); - } - - public static final int hash(Iterable objs) { - int result = 0; - for (Object obj : objs) { - result = addToHash(result, obj); - } - return result; - } - - /** - * Calling this var-arg version can result in large amounts of allocation (see HashingBenchmark) - * Rather than calliing this method, add another override of hash that handles a larger number of - * arguments or use calls to addToHash. - */ - @Deprecated - public static final int hash(Object[] objs) { - int result = 0; - for (Object obj : objs) { - result = addToHash(result, obj); - } - return result; - } + private HashingUtils() {} + + public static final int hashCode(@Nullable Object obj) { + return obj != null ? obj.hashCode() : 0; + } + + public static final int hash(boolean value) { + return Boolean.hashCode(value); + } + + public static final int hash(char value) { + return Character.hashCode(value); + } + + public static final int hash(byte value) { + return Byte.hashCode(value); + } + + public static final int hash(short value) { + return Short.hashCode(value); + } + + public static final int hash(int value) { + return Integer.hashCode(value); + } + + public static final int hash(long value) { + return Long.hashCode(value); + } + + public static final int hash(float value) { + return Float.hashCode(value); + } + + public static final int hash(double value) { + return Double.hashCode(value); + } + + public static final int hash(Object obj) { + return obj != null ? obj.hashCode() : 0; + } + + public static final int hash(Object obj0, Object obj1) { + return hash(hash(obj0), hash(obj1)); + } + + public static final int hash(int hash0, int hash1) { + return 31 * hash0 + hash1; + } + + public static final int hash(Object obj0, Object obj1, Object obj2) { + return hash(hashCode(obj0), hashCode(obj1), hashCode(obj2)); + } + + public static final int hash(int hash0, int hash1, int hash2) { + // DQH - Micro-optimizing, 31 * 31 will constant fold + // Since there are multiple execution ports for load & store, + // this will make good use of the core. + return 31 * 31 * hash0 + 31 * hash1 + hash2; + } + + public static final int hash(Object obj0, Object obj1, Object obj2, Object obj3) { + return hash(hashCode(obj0), hashCode(obj1), hashCode(obj2), hashCode(obj3)); + } + + public static final int hash(int hash0, int hash1, int hash2, int hash3) { + // DQH - Micro-optimizing, 31 * 31 will constant fold + // Since there are multiple execution ports for load & store, + // this will make good use of the core. + return 31 * 31 * 31 * hash0 + 31 * 31 * hash1 + 31 * hash2 + hash3; + } + + public static final int hash(Object obj0, Object obj1, Object obj2, Object obj3, Object obj4) { + return hash(hashCode(obj0), hashCode(obj1), hashCode(obj2), hashCode(obj3), hashCode(obj4)); + } + + public static final int hash(int hash0, int hash1, int hash2, int hash3, int hash4) { + // DQH - Micro-optimizing, 31 * 31 will constant fold + // Since there are multiple execution ports for load & store, + // this will make good use of the core. + return 31 * 31 * 31 * 31 * hash0 + 31 * 31 * 31 * hash1 + 31 * 31 * hash2 + 31 * hash3 + hash4; + } + + @Deprecated + public static final int hash(int[] hashes) { + int result = 0; + for (int hash : hashes) { + result = addToHash(result, hash); + } + return result; + } + + public static final int addToHash(int hash, int value) { + return 31 * hash + value; + } + + public static final int addToHash(int hash, @Nullable Object obj) { + return addToHash(hash, hashCode(obj)); + } + + public static final int addToHash(int hash, boolean value) { + return addToHash(hash, Boolean.hashCode(value)); + } + + public static final int addToHash(int hash, char value) { + return addToHash(hash, Character.hashCode(value)); + } + + public static final int addToHash(int hash, byte value) { + return addToHash(hash, Byte.hashCode(value)); + } + + public static final int addToHash(int hash, short value) { + return addToHash(hash, Short.hashCode(value)); + } + + public static final int addToHash(int hash, long value) { + return addToHash(hash, Long.hashCode(value)); + } + + public static final int addToHash(int hash, float value) { + return addToHash(hash, Float.hashCode(value)); + } + + public static final int addToHash(int hash, double value) { + return addToHash(hash, Double.hashCode(value)); + } + + /** Folds {@code arr[0..len)}; the array must be non-null, but its elements may be null. */ + public static final int addToHash(int hash, @Nonnull Object[] arr, int len) { + for (int i = 0; i < len; i++) { + hash = addToHash(hash, arr[i]); + } + return hash; + } + + public static final int addToHash(int hash, @Nonnull Object[] arr) { + return addToHash(hash, arr, arr.length); + } + + public static final int hash(Iterable objs) { + int result = 0; + for (Object obj : objs) { + result = addToHash(result, obj); + } + return result; + } + + /** + * Calling this var-arg version can result in large amounts of allocation (see HashingBenchmark) + * Rather than calliing this method, add another override of hash that handles a larger number of + * arguments or use calls to addToHash. + */ + @Deprecated + public static final int hash(Object[] objs) { + int result = 0; + for (Object obj : objs) { + result = addToHash(result, obj); + } + return result; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/Hashtable.java b/internal-api/src/main/java/datadog/trace/util/Hashtable.java index a2fbfc62ad1..29cd9d8dd90 100644 --- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java +++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java @@ -29,893 +29,873 @@ * {@link D1}, {@link D2}, and (for higher-arity callers) {@link Support}-driven custom tables. */ public final class Hashtable { - private Hashtable() {} - - /** - * Internal base class for entries. Stores the precomputed 64-bit keyHash and the chain-next - * pointer used to link colliding entries within a single bucket. - * - *

Subclasses add the actual key field(s) and a {@code matches(...)} method tailored to their - * key arity. See {@link D1.Entry} and {@link D2.Entry}; for higher arities, client code can - * subclass this directly and use {@link Support} to drive the table mechanics. - */ - public abstract static class Entry { - public final long keyHash; - private Entry next = null; - - protected Entry(long keyHash) { - this.keyHash = keyHash; - } + private Hashtable() {} - public final void setNext(TEntry next) { - this.next = next; - } + /** + * Internal base class for entries. Stores the precomputed 64-bit keyHash and the chain-next + * pointer used to link colliding entries within a single bucket. + * + *

Subclasses add the actual key field(s) and a {@code matches(...)} method tailored to their + * key arity. See {@link D1.Entry} and {@link D2.Entry}; for higher arities, client code can + * subclass this directly and use {@link Support} to drive the table mechanics. + */ + public abstract static class Entry { + public final long keyHash; + private Entry next = null; - @SuppressWarnings("unchecked") - public final TEntry next() { - return (TEntry) this.next; + protected Entry(long keyHash) { + this.keyHash = keyHash; + } + + public final void setNext(TEntry next) { + this.next = next; + } + + @SuppressWarnings("unchecked") + public final TEntry next() { + return (TEntry) this.next; + } } - } - - /** - * Single-key open hash table with chaining. - * - *

The user supplies an {@link D1.Entry} subclass that carries the key and whatever value - * fields they want to mutate in place, then instantiates this class over that entry type. The - * main advantage over {@code HashMap} is that mutating an existing entry's value fields - * requires no allocation: call {@link #get} once and write directly to the returned entry's - * fields. For counter-style workloads this can be several times faster than {@code HashMap} and produces effectively zero GC pressure. - * - *

Capacity is fixed at construction. The table does not resize, so the caller is responsible - * for choosing a capacity appropriate to the working set. Actual bucket-array length is rounded - * up to the next power of two. - * - *

Null keys are permitted; they collapse to a single bucket via the sentinel hash {@link - * Long#MIN_VALUE} defined in {@link D1.Entry#hash}. - * - *

Not thread-safe. Concurrent access (including mixing reads with writes) requires - * external synchronization. - * - * @param the key type - * @param the user's {@link D1.Entry D1.Entry<K>} subclass - */ - public static final class D1> { + /** - * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in - * place after retrieving the entry via {@link D1#get}. + * Single-key open hash table with chaining. * - *

The key is captured at construction and stored alongside its precomputed 64-bit hash. - * {@link #matches(Object)} uses {@link Objects#equals} by default; override if a different - * equality semantics is needed (e.g. reference equality for interned keys). + *

The user supplies an {@link D1.Entry} subclass that carries the key and whatever value + * fields they want to mutate in place, then instantiates this class over that entry type. The + * main advantage over {@code HashMap} is that mutating an existing entry's value fields + * requires no allocation: call {@link #get} once and write directly to the returned entry's + * fields. For counter-style workloads this can be several times faster than {@code HashMap} and produces effectively zero GC pressure. + * + *

Capacity is fixed at construction. The table does not resize, so the caller is responsible + * for choosing a capacity appropriate to the working set. Actual bucket-array length is rounded + * up to the next power of two. + * + *

Null keys are permitted; they collapse to a single bucket via the sentinel hash {@link + * Long#MIN_VALUE} defined in {@link D1.Entry#hash}. + * + *

Not thread-safe. Concurrent access (including mixing reads with writes) requires + * external synchronization. * * @param the key type + * @param the user's {@link D1.Entry D1.Entry<K>} subclass */ - public abstract static class Entry extends Hashtable.Entry { - final K key; - - protected Entry(K key) { - super(hash(key)); - this.key = key; - } - - /** The key this entry was created with. */ - public K key() { - return this.key; - } - - public boolean matches(Object key) { - return Objects.equals(this.key, key); - } - - /** - * Returns the 64-bit lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so - * that they don't collide with a real key that hashes to 0 (e.g. {@code - * Integer.hashCode(0)}). The {@code Long.MIN_VALUE} sentinel is safe against any {@code - * int}-valued {@code hashCode()} since those widen to a long in the range {@code - * [Integer.MIN_VALUE, Integer.MAX_VALUE]}; real-key collisions in chains are resolved by - * {@link #matches(Object)}. - */ - public static long hash(Object key) { - return (key == null) ? Long.MIN_VALUE : key.hashCode(); - } - } + public static final class D1> { + /** + * Abstract base for {@link D1} entries. Subclass to add value fields you wish to mutate in + * place after retrieving the entry via {@link D1#get}. + * + *

The key is captured at construction and stored alongside its precomputed 64-bit hash. + * {@link #matches(Object)} uses {@link Objects#equals} by default; override if a different + * equality semantics is needed (e.g. reference equality for interned keys). + * + * @param the key type + */ + public abstract static class Entry extends Hashtable.Entry { + final K key; + + protected Entry(K key) { + super(hash(key)); + this.key = key; + } + + /** The key this entry was created with. */ + public K key() { + return this.key; + } + + public boolean matches(Object key) { + return Objects.equals(this.key, key); + } + + /** + * Returns the 64-bit lookup hash for {@code key}. Null keys map to {@link Long#MIN_VALUE} so + * that they don't collide with a real key that hashes to 0 (e.g. {@code + * Integer.hashCode(0)}). The {@code Long.MIN_VALUE} sentinel is safe against any {@code + * int}-valued {@code hashCode()} since those widen to a long in the range {@code + * [Integer.MIN_VALUE, Integer.MAX_VALUE]}; real-key collisions in chains are resolved by + * {@link #matches(Object)}. + */ + public static long hash(Object key) { + return (key == null) ? Long.MIN_VALUE : key.hashCode(); + } + } - // Package-private so iterator tests in the same package can drive Support.bucketIterator and - // friends directly against the table's bucket array. - final Hashtable.Entry[] buckets; - private int size; + // Package-private so iterator tests in the same package can drive Support.bucketIterator and + // friends directly against the table's bucket array. + final Hashtable.Entry[] buckets; + private int size; - public D1(int capacity) { - this.buckets = Support.create(capacity); - this.size = 0; - } + public D1(int capacity) { + this.buckets = Support.create(capacity); + this.size = 0; + } - public int size() { - return this.size; - } + public int size() { + return this.size; + } - public TEntry get(K key) { - long keyHash = D1.Entry.hash(key); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - return te; + public TEntry get(K key) { + long keyHash = D1.Entry.hash(key); + for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key)) { + return te; + } + } + return null; } - } - return null; - } - public TEntry remove(K key) { - long keyHash = D1.Entry.hash(key); + public TEntry remove(K key) { + long keyHash = D1.Entry.hash(key); - for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, keyHash); - iter.hasNext(); ) { - TEntry curEntry = iter.next(); + for (MutatingBucketIterator iter = Support.mutatingBucketIterator(this.buckets, keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); - if (curEntry.matches(key)) { - iter.remove(); - this.size -= 1; - return curEntry; + if (curEntry.matches(key)) { + iter.remove(); + this.size -= 1; + return curEntry; + } + } + + return null; } - } - return null; - } + public void insert(TEntry newEntry) { + Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + this.size += 1; + } - public void insert(TEntry newEntry) { - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - } + public TEntry insertOrReplace(TEntry newEntry) { + for (MutatingBucketIterator iter = Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); - public TEntry insertOrReplace(TEntry newEntry) { - for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); - iter.hasNext(); ) { - TEntry curEntry = iter.next(); + if (curEntry.matches(newEntry.key)) { + iter.replace(newEntry); + return curEntry; + } + } - if (curEntry.matches(newEntry.key)) { - iter.replace(newEntry); - return curEntry; + Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + this.size += 1; + return null; } - } - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - return null; - } + /** + * Returns the entry for {@code key}, building one via {@code creator} if absent. Computes the + * hash once and reuses it for both the lookup and (on miss) the insert -- avoids the + * double-hash that "{@code get}; if null then {@code insert}" would incur. + * + *

The {@code creator} is expected to build an entry whose {@code keyHash} equals {@link + * Entry#hash(Object) D1.Entry.hash(key)} -- typically by passing {@code key} to a constructor + * that calls {@code super(key)}. A mismatched hash will leave the new entry inserted at a + * bucket that future {@link #get} calls won't probe. + */ + public TEntry getOrCreate(K key, Function creator) { + long keyHash = D1.Entry.hash(key); + for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key)) { + return te; + } + } + TEntry newEntry = creator.apply(key); + Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + this.size += 1; + return newEntry; + } - /** - * Returns the entry for {@code key}, building one via {@code creator} if absent. Computes the - * hash once and reuses it for both the lookup and (on miss) the insert -- avoids the - * double-hash that "{@code get}; if null then {@code insert}" would incur. - * - *

The {@code creator} is expected to build an entry whose {@code keyHash} equals {@link - * Entry#hash(Object) D1.Entry.hash(key)} -- typically by passing {@code key} to a constructor - * that calls {@code super(key)}. A mismatched hash will leave the new entry inserted at a - * bucket that future {@link #get} calls won't probe. - */ - public TEntry getOrCreate(K key, Function creator) { - long keyHash = D1.Entry.hash(key); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key)) { - return te; - } - } - TEntry newEntry = creator.apply(key); - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - return newEntry; - } + public void clear() { + Support.clear(this.buckets); + this.size = 0; + } - public void clear() { - Support.clear(this.buckets); - this.size = 0; - } + public void forEach(Consumer consumer) { + Support.forEach(this.buckets, consumer); + } - public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); + /** + * Context-passing forEach. Useful for callers that want to avoid a capturing-lambda allocation + * -- pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus whatever + * side-band state it needs as {@code context}. + */ + public void forEach(T context, BiConsumer consumer) { + Support.forEach(this.buckets, context, consumer); + } } /** - * Context-passing forEach. Useful for callers that want to avoid a capturing-lambda allocation - * -- pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus whatever - * side-band state it needs as {@code context}. - */ - public void forEach(T context, BiConsumer consumer) { - Support.forEach(this.buckets, context, consumer); - } - } - - /** - * Two-key (composite-key) hash table with chaining. - * - *

The user supplies a {@link D2.Entry} subclass carrying both key parts and any value fields. - * Compared to {@code HashMap} this avoids the per-lookup {@code Pair} (or record) - * allocation: both key parts are passed directly through {@link #get}, {@link #remove}, {@link - * #insert}, and {@link #insertOrReplace}. Combined with in-place value mutation, this makes - * {@code D2} substantially less GC-intensive than the equivalent {@code HashMap} for - * counter-style workloads. - * - *

Capacity is fixed at construction; the table does not resize. Actual bucket-array length is - * rounded up to the next power of two. - * - *

Key parts are combined into a 64-bit hash via {@link LongHashingUtils}; see {@link - * D2.Entry#hash(Object, Object)}. - * - *

Not thread-safe. - * - * @param first key type - * @param second key type - * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass - */ - public static final class D2> { - /** - * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in - * place. + * Two-key (composite-key) hash table with chaining. + * + *

The user supplies a {@link D2.Entry} subclass carrying both key parts and any value fields. + * Compared to {@code HashMap} this avoids the per-lookup {@code Pair} (or record) + * allocation: both key parts are passed directly through {@link #get}, {@link #remove}, {@link + * #insert}, and {@link #insertOrReplace}. Combined with in-place value mutation, this makes + * {@code D2} substantially less GC-intensive than the equivalent {@code HashMap} for + * counter-style workloads. + * + *

Capacity is fixed at construction; the table does not resize. Actual bucket-array length is + * rounded up to the next power of two. + * + *

Key parts are combined into a 64-bit hash via {@link LongHashingUtils}; see {@link + * D2.Entry#hash(Object, Object)}. * - *

Both key parts are captured at construction and stored alongside their combined 64-bit - * hash. {@link #matches(Object, Object)} uses {@link Objects#equals} pairwise on the two parts. + *

Not thread-safe. * * @param first key type * @param second key type + * @param the user's {@link D2.Entry D2.Entry<K1, K2>} subclass */ - public abstract static class Entry extends Hashtable.Entry { - final K1 key1; - final K2 key2; - - protected Entry(K1 key1, K2 key2) { - super(hash(key1, key2)); - this.key1 = key1; - this.key2 = key2; - } - - /** The first key part this entry was created with. */ - public K1 key1() { - return this.key1; - } - - /** The second key part this entry was created with. */ - public K2 key2() { - return this.key2; - } - - public boolean matches(K1 key1, K2 key2) { - return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); - } - - /** - * Returns the 64-bit lookup hash combining both key parts via {@link - * LongHashingUtils#hash(Object, Object)}. Null parts contribute {@code 0} (not a sentinel, - * unlike {@link D1.Entry#hash(Object)}): the combined hash can collide with real-key - * combinations whose chained hash equals {@code hash(0, 0) = 0} or similar values. {@link - * #matches(Object, Object)} resolves any such collision. - */ - public static long hash(Object key1, Object key2) { - return LongHashingUtils.hash(key1, key2); - } - } + public static final class D2> { + /** + * Abstract base for {@link D2} entries. Subclass to add value fields you wish to mutate in + * place. + * + *

Both key parts are captured at construction and stored alongside their combined 64-bit + * hash. {@link #matches(Object, Object)} uses {@link Objects#equals} pairwise on the two parts. + * + * @param first key type + * @param second key type + */ + public abstract static class Entry extends Hashtable.Entry { + final K1 key1; + final K2 key2; + + protected Entry(K1 key1, K2 key2) { + super(hash(key1, key2)); + this.key1 = key1; + this.key2 = key2; + } + + /** The first key part this entry was created with. */ + public K1 key1() { + return this.key1; + } + + /** The second key part this entry was created with. */ + public K2 key2() { + return this.key2; + } + + public boolean matches(K1 key1, K2 key2) { + return Objects.equals(this.key1, key1) && Objects.equals(this.key2, key2); + } + + /** + * Returns the 64-bit lookup hash combining both key parts via {@link + * LongHashingUtils#hash(Object, Object)}. Null parts contribute {@code 0} (not a sentinel, + * unlike {@link D1.Entry#hash(Object)}): the combined hash can collide with real-key + * combinations whose chained hash equals {@code hash(0, 0) = 0} or similar values. {@link + * #matches(Object, Object)} resolves any such collision. + */ + public static long hash(Object key1, Object key2) { + return LongHashingUtils.hash(key1, key2); + } + } - // Package-private to match D1.buckets -- available for iterator tests in the same package. - final Hashtable.Entry[] buckets; - private int size; + // Package-private to match D1.buckets -- available for iterator tests in the same package. + final Hashtable.Entry[] buckets; + private int size; - public D2(int capacity) { - this.buckets = Support.create(capacity); - this.size = 0; - } + public D2(int capacity) { + this.buckets = Support.create(capacity); + this.size = 0; + } - public int size() { - return this.size; - } + public int size() { + return this.size; + } - public TEntry get(K1 key1, K2 key2) { - long keyHash = D2.Entry.hash(key1, key2); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - return te; + public TEntry get(K1 key1, K2 key2) { + long keyHash = D2.Entry.hash(key1, key2); + for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key1, key2)) { + return te; + } + } + return null; } - } - return null; - } - public TEntry remove(K1 key1, K2 key2) { - long keyHash = D2.Entry.hash(key1, key2); + public TEntry remove(K1 key1, K2 key2) { + long keyHash = D2.Entry.hash(key1, key2); + + for (MutatingBucketIterator iter = Support.mutatingBucketIterator(this.buckets, keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); - for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, keyHash); - iter.hasNext(); ) { - TEntry curEntry = iter.next(); + if (curEntry.matches(key1, key2)) { + iter.remove(); + this.size -= 1; + return curEntry; + } + } - if (curEntry.matches(key1, key2)) { - iter.remove(); - this.size -= 1; - return curEntry; + return null; } - } - return null; - } + public void insert(TEntry newEntry) { + Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + this.size += 1; + } - public void insert(TEntry newEntry) { - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - } + public TEntry insertOrReplace(TEntry newEntry) { + for (MutatingBucketIterator iter = Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); + iter.hasNext(); ) { + TEntry curEntry = iter.next(); - public TEntry insertOrReplace(TEntry newEntry) { - for (MutatingBucketIterator iter = - Support.mutatingBucketIterator(this.buckets, newEntry.keyHash); - iter.hasNext(); ) { - TEntry curEntry = iter.next(); + if (curEntry.matches(newEntry.key1, newEntry.key2)) { + iter.replace(newEntry); + return curEntry; + } + } - if (curEntry.matches(newEntry.key1, newEntry.key2)) { - iter.replace(newEntry); - return curEntry; + Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + this.size += 1; + return null; } - } - - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - return null; - } - /** - * Two-key analogue of {@link D1#getOrCreate}. Computes the combined hash once and reuses it for - * both lookup and (on miss) insert. The {@code creator} is expected to build an entry whose - * {@code keyHash} equals {@link Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. - */ - public TEntry getOrCreate( - K1 key1, K2 key2, BiFunction creator) { - long keyHash = D2.Entry.hash(key1, key2); - for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { - if (te.keyHash == keyHash && te.matches(key1, key2)) { - return te; - } - } - TEntry newEntry = creator.apply(key1, key2); - Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); - this.size += 1; - return newEntry; - } + /** + * Two-key analogue of {@link D1#getOrCreate}. Computes the combined hash once and reuses it for + * both lookup and (on miss) insert. The {@code creator} is expected to build an entry whose + * {@code keyHash} equals {@link Entry#hash(Object, Object) D2.Entry.hash(key1, key2)}. + */ + public TEntry getOrCreate(K1 key1, K2 key2, BiFunction creator) { + long keyHash = D2.Entry.hash(key1, key2); + for (TEntry te = Support.bucket(this.buckets, keyHash); te != null; te = te.next()) { + if (te.keyHash == keyHash && te.matches(key1, key2)) { + return te; + } + } + TEntry newEntry = creator.apply(key1, key2); + Support.insertHeadEntry(this.buckets, newEntry.keyHash, newEntry); + this.size += 1; + return newEntry; + } - public void clear() { - Support.clear(this.buckets); - this.size = 0; - } + public void clear() { + Support.clear(this.buckets); + this.size = 0; + } - public void forEach(Consumer consumer) { - Support.forEach(this.buckets, consumer); - } + public void forEach(Consumer consumer) { + Support.forEach(this.buckets, consumer); + } - /** - * Context-passing forEach. Useful for callers that want to avoid a capturing-lambda allocation - * -- pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus whatever - * side-band state it needs as {@code context}. - */ - public void forEach(T context, BiConsumer consumer) { - Support.forEach(this.buckets, context, consumer); - } - } - - /** - * Building blocks for hash-table operations. - * - *

Used by {@link D1} and {@link D2}, and available to callers that want to assemble their own - * higher-arity table (3+ key parts) without re-implementing the bucket-array mechanics. The - * typical recipe: - * - *

    - *
  • Subclass {@link Hashtable.Entry} directly, adding the key fields and a {@code - * matches(...)} method of your chosen arity. - *
  • Allocate a backing array with {@link #create(int)} or {@link #create(int, float)} (the - * latter scales for a target load factor; see {@link #MAX_RATIO}). - *
  • Use {@link #bucketIndex(Object[], long)} for the bucket lookup, {@link - * #bucketIterator(Hashtable.Entry[], long)} for read-only chain walks, and {@link - * #mutatingBucketIterator(Hashtable.Entry[], long)} when you also need {@code remove} / - * {@code replace}. - *
  • Use {@link #insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)} to splice a new - * entry as the head of a bucket chain. - *
  • Iterate every entry with {@link #forEach(Hashtable.Entry[], Consumer)} or its - * context-passing sibling. For full-table sweeps with {@code remove}, use {@link - * #mutatingTableIterator(Hashtable.Entry[])}. - *
  • Clear with {@link #clear(Hashtable.Entry[])}. - *
- * - *

All bucket arrays produced by {@code create} have a power-of-two length, so {@link - * #bucketIndex(Object[], long)} can use a bit mask. - */ - public static final class Support { - /** - * Allocates a bucket array sized to hold {@code requestedSize} entries. Returned length is - * {@code requestedSize} rounded up to the next power of two (capped at {@link #MAX_BUCKETS}). - */ - public static final Hashtable.Entry[] create(int requestedSize) { - return new Entry[sizeFor(requestedSize)]; + /** + * Context-passing forEach. Useful for callers that want to avoid a capturing-lambda allocation + * -- pass a non-capturing {@link BiConsumer} (typically a {@code static final}) plus whatever + * side-band state it needs as {@code context}. + */ + public void forEach(T context, BiConsumer consumer) { + Support.forEach(this.buckets, context, consumer); + } } /** - * Variant of {@link #create(int)} that scales the requested working-set size before sizing the - * bucket array. Pair with {@link #MAX_RATIO} to leave headroom over the working set for a - * desired load factor; the canonical call is {@code create(n, MAX_RATIO)}. + * Building blocks for hash-table operations. + * + *

Used by {@link D1} and {@link D2}, and available to callers that want to assemble their own + * higher-arity table (3+ key parts) without re-implementing the bucket-array mechanics. The + * typical recipe: * - *

The scaled size is truncated to {@code int} before going through {@link #sizeFor(int)}. - * Truncation rather than {@code ceil} is intentional: {@code sizeFor} rounds up to the next - * power of two anyway, so the fractional part would only matter when float fuzz pushes the - * result across a power-of-two boundary -- {@code ceil} would then double the array size for no - * reason (e.g. {@code 12 * 4/3 = 16.0...0005f -> ceil 17 -> sizeFor 32}). + *

    + *
  • Subclass {@link Hashtable.Entry} directly, adding the key fields and a {@code + * matches(...)} method of your chosen arity. + *
  • Allocate a backing array with {@link #create(int)} or {@link #create(int, float)} (the + * latter scales for a target load factor; see {@link #MAX_RATIO}). + *
  • Use {@link #bucketIndex(Object[], long)} for the bucket lookup, {@link + * #bucketIterator(Hashtable.Entry[], long)} for read-only chain walks, and {@link + * #mutatingBucketIterator(Hashtable.Entry[], long)} when you also need {@code remove} / + * {@code replace}. + *
  • Use {@link #insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)} to splice a new + * entry as the head of a bucket chain. + *
  • Iterate every entry with {@link #forEach(Hashtable.Entry[], Consumer)} or its + * context-passing sibling. For full-table sweeps with {@code remove}, use {@link + * #mutatingTableIterator(Hashtable.Entry[])}. + *
  • Clear with {@link #clear(Hashtable.Entry[])}. + *
+ * + *

All bucket arrays produced by {@code create} have a power-of-two length, so {@link + * #bucketIndex(Object[], long)} can use a bit mask. */ - public static final Hashtable.Entry[] create(int requestedSize, float scale) { - return new Entry[sizeFor((int) (requestedSize * scale))]; - } + public static final class Support { + /** + * Allocates a bucket array sized to hold {@code requestedSize} entries. Returned length is + * {@code requestedSize} rounded up to the next power of two (capped at {@link #MAX_BUCKETS}). + */ + public static final Hashtable.Entry[] create(int requestedSize) { + return new Entry[sizeFor(requestedSize)]; + } - /** Upper bound on the bucket array length returned by {@link #sizeFor(int)}. */ - static final int MAX_BUCKETS = 1 << 30; + /** + * Variant of {@link #create(int)} that scales the requested working-set size before sizing the + * bucket array. Pair with {@link #MAX_RATIO} to leave headroom over the working set for a + * desired load factor; the canonical call is {@code create(n, MAX_RATIO)}. + * + *

The scaled size is truncated to {@code int} before going through {@link #sizeFor(int)}. + * Truncation rather than {@code ceil} is intentional: {@code sizeFor} rounds up to the next + * power of two anyway, so the fractional part would only matter when float fuzz pushes the + * result across a power-of-two boundary -- {@code ceil} would then double the array size for no + * reason (e.g. {@code 12 * 4/3 = 16.0...0005f -> ceil 17 -> sizeFor 32}). + */ + public static final Hashtable.Entry[] create(int requestedSize, float scale) { + return new Entry[sizeFor((int) (requestedSize * scale))]; + } - /** - * Inverse of a 75% load factor. Callers that size their bucket array from a target working-set - * size {@code n} should pass {@code create(n, MAX_RATIO)} to leave ~25% headroom in the array. - */ - public static final float MAX_RATIO = 4.0f / 3.0f; + /** Upper bound on the bucket array length returned by {@link #sizeFor(int)}. */ + static final int MAX_BUCKETS = 1 << 30; + + /** + * Inverse of a 75% load factor. Callers that size their bucket array from a target working-set + * size {@code n} should pass {@code create(n, MAX_RATIO)} to leave ~25% headroom in the array. + */ + public static final float MAX_RATIO = 4.0f / 3.0f; + + /** + * Rounds {@code requestedSize} up to the next power of two, capped at {@link #MAX_BUCKETS}. + * Throws {@link IllegalArgumentException} for negative inputs or inputs above the cap. Returns + * the bucket-array length to allocate. + */ + static final int sizeFor(int requestedSize) { + if (requestedSize < 0) { + throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); + } + if (requestedSize > MAX_BUCKETS) { + throw new IllegalArgumentException( + "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); + } + if (requestedSize <= 1) { + return 1; + } + return Integer.highestOneBit(requestedSize - 1) << 1; + } - /** - * Rounds {@code requestedSize} up to the next power of two, capped at {@link #MAX_BUCKETS}. - * Throws {@link IllegalArgumentException} for negative inputs or inputs above the cap. Returns - * the bucket-array length to allocate. - */ - static final int sizeFor(int requestedSize) { - if (requestedSize < 0) { - throw new IllegalArgumentException("requestedSize must be non-negative: " + requestedSize); - } - if (requestedSize > MAX_BUCKETS) { - throw new IllegalArgumentException( - "requestedSize exceeds maximum bucket count (" + MAX_BUCKETS + "): " + requestedSize); - } - if (requestedSize <= 1) { - return 1; - } - return Integer.highestOneBit(requestedSize - 1) << 1; - } + public static final void clear(Hashtable.Entry[] buckets) { + Arrays.fill(buckets, null); + } - public static final void clear(Hashtable.Entry[] buckets) { - Arrays.fill(buckets, null); - } + public static final BucketIterator bucketIterator( + Hashtable.Entry[] buckets, long keyHash) { + return new BucketIterator(buckets, keyHash); + } - public static final BucketIterator bucketIterator( - Hashtable.Entry[] buckets, long keyHash) { - return new BucketIterator(buckets, keyHash); - } + public static final MutatingBucketIterator mutatingBucketIterator( + Hashtable.Entry[] buckets, long keyHash) { + return new MutatingBucketIterator(buckets, keyHash); + } - public static final - MutatingBucketIterator mutatingBucketIterator( - Hashtable.Entry[] buckets, long keyHash) { - return new MutatingBucketIterator(buckets, keyHash); - } + /** + * Returns a {@link MutatingTableIterator} over every entry in {@code buckets}. Useful for + * sweeps -- eviction, expunge -- that aren't keyed to a specific hash. + */ + public static final MutatingTableIterator mutatingTableIterator( + Hashtable.Entry[] buckets) { + return new MutatingTableIterator(buckets, 0, buckets.length); + } - /** - * Returns a {@link MutatingTableIterator} over every entry in {@code buckets}. Useful for - * sweeps -- eviction, expunge -- that aren't keyed to a specific hash. - */ - public static final - MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) { - return new MutatingTableIterator(buckets, 0, buckets.length); - } + /** + * Variant of {@link #mutatingTableIterator(Hashtable.Entry[])} that walks only the half-open + * bucket range {@code [startBucket, endBucket)}. Useful for resumable sweeps -- e.g. cursor- + * based eviction in {@code AggregateTable} -- where one call drives {@code [cursor, length)} + * and a wrap-around call drives {@code [0, cursor)}. The iterator does not wrap around + * within a single instance; callers compose two iterators when wrap-around is desired. An empty + * range ({@code startBucket == endBucket}) produces an immediately exhausted iterator. + * + * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}. + * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}. + */ + public static final MutatingTableIterator mutatingTableIterator( + Hashtable.Entry[] buckets, int startBucket, int endBucket) { + return new MutatingTableIterator(buckets, startBucket, endBucket); + } - /** - * Variant of {@link #mutatingTableIterator(Hashtable.Entry[])} that walks only the half-open - * bucket range {@code [startBucket, endBucket)}. Useful for resumable sweeps -- e.g. cursor- - * based eviction in {@code AggregateTable} -- where one call drives {@code [cursor, length)} - * and a wrap-around call drives {@code [0, cursor)}. The iterator does not wrap around - * within a single instance; callers compose two iterators when wrap-around is desired. An empty - * range ({@code startBucket == endBucket}) produces an immediately exhausted iterator. - * - * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}. - * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}. - */ - public static final - MutatingTableIterator mutatingTableIterator( - Hashtable.Entry[] buckets, int startBucket, int endBucket) { - return new MutatingTableIterator(buckets, startBucket, endBucket); - } + public static final int bucketIndex(Object[] buckets, long keyHash) { + return (int) (keyHash & buckets.length - 1); + } - public static final int bucketIndex(Object[] buckets, long keyHash) { - return (int) (keyHash & buckets.length - 1); - } + /** + * Splices {@code entry} in as the new head of the chain at {@code bucketIndex}. Caller is + * responsible for size accounting -- this method only touches the chain pointers. + */ + public static final void insertHeadEntry(Hashtable.Entry[] buckets, int bucketIndex, Hashtable.Entry entry) { + entry.setNext(buckets[bucketIndex]); + buckets[bucketIndex] = entry; + } - /** - * Splices {@code entry} in as the new head of the chain at {@code bucketIndex}. Caller is - * responsible for size accounting -- this method only touches the chain pointers. - */ - public static final void insertHeadEntry( - Hashtable.Entry[] buckets, int bucketIndex, Hashtable.Entry entry) { - entry.setNext(buckets[bucketIndex]); - buckets[bucketIndex] = entry; - } + /** + * Convenience overload of {@link #insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)} + * that derives the bucket index from {@code keyHash}. Use this when the caller has the hash but + * not the index; if the index has already been computed for another reason, prefer the + * int-taking overload to avoid the redundant mask. + */ + public static final void insertHeadEntry(Hashtable.Entry[] buckets, long keyHash, Hashtable.Entry entry) { + insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); + } - /** - * Convenience overload of {@link #insertHeadEntry(Hashtable.Entry[], int, Hashtable.Entry)} - * that derives the bucket index from {@code keyHash}. Use this when the caller has the hash but - * not the index; if the index has already been computed for another reason, prefer the - * int-taking overload to avoid the redundant mask. - */ - public static final void insertHeadEntry( - Hashtable.Entry[] buckets, long keyHash, Hashtable.Entry entry) { - insertHeadEntry(buckets, bucketIndex(buckets, keyHash), entry); - } + /** + * Returns the head entry of the bucket that {@code keyHash} maps to, cast to the caller's + * concrete entry type. The unchecked cast lives here so the chain-walk loop at the call site + * doesn't need to thread a raw {@link Entry} variable through. + */ + @SuppressWarnings("unchecked") + public static final TEntry bucket(Hashtable.Entry[] buckets, long keyHash) { + return (TEntry) buckets[bucketIndex(buckets, keyHash)]; + } - /** - * Returns the head entry of the bucket that {@code keyHash} maps to, cast to the caller's - * concrete entry type. The unchecked cast lives here so the chain-walk loop at the call site - * doesn't need to thread a raw {@link Entry} variable through. - */ - @SuppressWarnings("unchecked") - public static final TEntry bucket( - Hashtable.Entry[] buckets, long keyHash) { - return (TEntry) buckets[bucketIndex(buckets, keyHash)]; - } + /** + * Walks every entry in {@code buckets} and invokes {@code consumer} on it. The unchecked cast + * to {@code TEntry} lives here (mirroring {@link Entry#next()}) so callers don't have to + * sprinkle it across their own forEach loops. + */ + @SuppressWarnings("unchecked") + public static final void forEach( + Hashtable.Entry[] buckets, Consumer consumer) { + for (int i = 0; i < buckets.length; i++) { + for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { + consumer.accept((TEntry) e); + } + } + } - /** - * Walks every entry in {@code buckets} and invokes {@code consumer} on it. The unchecked cast - * to {@code TEntry} lives here (mirroring {@link Entry#next()}) so callers don't have to - * sprinkle it across their own forEach loops. - */ - @SuppressWarnings("unchecked") - public static final void forEach( - Hashtable.Entry[] buckets, Consumer consumer) { - for (int i = 0; i < buckets.length; i++) { - for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { - consumer.accept((TEntry) e); - } - } + /** + * Context-passing variant of {@link #forEach(Hashtable.Entry[], Consumer)}. Pair a + * non-capturing {@link BiConsumer} (typically a {@code static final}) with side-band state + * passed as {@code context} to avoid a fresh-Consumer allocation each call. + */ + @SuppressWarnings("unchecked") + public static final void forEach( + Hashtable.Entry[] buckets, T context, BiConsumer consumer) { + for (int i = 0; i < buckets.length; i++) { + for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { + consumer.accept(context, (TEntry) e); + } + } + } } /** - * Context-passing variant of {@link #forEach(Hashtable.Entry[], Consumer)}. Pair a - * non-capturing {@link BiConsumer} (typically a {@code static final}) with side-band state - * passed as {@code context} to avoid a fresh-Consumer allocation each call. + * Read-only iterator over entries in a single bucket whose {@code keyHash} matches a specific + * search hash. Cheaper than {@link MutatingBucketIterator} because it does not track the + * previous-node pointers required for splicing — use it when you only need to walk the chain. + * + *

For {@code remove} or {@code replace} operations, use {@link MutatingBucketIterator} + * instead. + * + *

The chain-walk work to find the next-match entry happens in {@link #next()} (and in the + * constructor for the first match); {@link #hasNext()} is an O(1) field read. */ - @SuppressWarnings("unchecked") - public static final void forEach( - Hashtable.Entry[] buckets, T context, BiConsumer consumer) { - for (int i = 0; i < buckets.length; i++) { - for (Hashtable.Entry e = buckets[i]; e != null; e = e.next()) { - consumer.accept(context, (TEntry) e); - } - } - } - } - - /** - * Read-only iterator over entries in a single bucket whose {@code keyHash} matches a specific - * search hash. Cheaper than {@link MutatingBucketIterator} because it does not track the - * previous-node pointers required for splicing — use it when you only need to walk the chain. - * - *

For {@code remove} or {@code replace} operations, use {@link MutatingBucketIterator} - * instead. - * - *

The chain-walk work to find the next-match entry happens in {@link #next()} (and in the - * constructor for the first match); {@link #hasNext()} is an O(1) field read. - */ - public static final class BucketIterator implements Iterator { - private final long keyHash; - private Hashtable.Entry nextEntry; - - BucketIterator(Hashtable.Entry[] buckets, long keyHash) { - this.keyHash = keyHash; - Hashtable.Entry cur = buckets[Support.bucketIndex(buckets, keyHash)]; - while (cur != null && cur.keyHash != keyHash) { - cur = cur.next(); - } - this.nextEntry = cur; - } - - @Override - public boolean hasNext() { - return this.nextEntry != null; - } - - @Override - @SuppressWarnings("unchecked") - public TEntry next() { - Hashtable.Entry cur = this.nextEntry; - if (cur == null) { - throw new NoSuchElementException("no next!"); - } - - Hashtable.Entry advance = cur.next(); - while (advance != null && advance.keyHash != keyHash) { - advance = advance.next(); - } - this.nextEntry = advance; - - return (TEntry) cur; - } - } - - /** - * Mutating iterator over entries in a single bucket whose {@code keyHash} matches a specific - * search hash. Supports {@link #remove()} and {@link #replace} to splice the chain in place. - * - *

Carries previous-node pointers for the current entry and the next-match entry so that {@code - * remove} and {@code replace} can fix up the chain in O(1) without re-walking from the bucket - * head. After {@code remove} or {@code replace}, iteration may continue with another {@link - * #next()}. - * - *

The chain-walk work to find the next-match entry happens in {@link #next()} (and in the - * constructor for the first match); {@link #hasNext()} is an O(1) field read. - */ - public static final class MutatingBucketIterator - implements Iterator { - private final long keyHash; - - private final Hashtable.Entry[] buckets; - - /** The entry prior to the last entry returned by next Used for mutating operations */ - private Hashtable.Entry curPrevEntry; - - /** The entry that was last returned by next */ - private Hashtable.Entry curEntry; - - /** The entry prior to the next entry */ - private Hashtable.Entry nextPrevEntry; - - /** The next entry to be returned by next */ - private Hashtable.Entry nextEntry; - - MutatingBucketIterator(Hashtable.Entry[] buckets, long keyHash) { - this.buckets = buckets; - this.keyHash = keyHash; - - int bucketIndex = Support.bucketIndex(buckets, keyHash); - Hashtable.Entry headEntry = this.buckets[bucketIndex]; - if (headEntry == null) { - this.nextEntry = null; - this.nextPrevEntry = null; - - this.curEntry = null; - this.curPrevEntry = null; - } else { - Hashtable.Entry prev, cur; - for (prev = null, cur = headEntry; cur != null; prev = cur, cur = cur.next()) { - if (cur.keyHash == keyHash) { - break; - } - } - this.nextPrevEntry = prev; - this.nextEntry = cur; - - this.curEntry = null; - this.curPrevEntry = null; - } - } - - @Override - public boolean hasNext() { - return (this.nextEntry != null); - } - - @Override - @SuppressWarnings("unchecked") - public TEntry next() { - Hashtable.Entry curEntry = this.nextEntry; - if (curEntry == null) { - throw new NoSuchElementException("no next!"); - } - - this.curEntry = curEntry; - this.curPrevEntry = this.nextPrevEntry; - - Hashtable.Entry prev, cur; - for (prev = this.nextEntry, cur = this.nextEntry.next(); - cur != null; - prev = cur, cur = prev.next()) { - if (cur.keyHash == keyHash) { - break; - } - } - this.nextPrevEntry = prev; - this.nextEntry = cur; - - return (TEntry) curEntry; - } - - @Override - public void remove() { - Hashtable.Entry oldCurEntry = this.curEntry; - if (oldCurEntry == null) { - throw new IllegalStateException(); - } - - Hashtable.Entry oldNext = oldCurEntry.next(); - this.setPrevNext(oldNext); - // Detach the removed entry from the chain so stale references can't traverse back into - // the live chain and so a now-unreachable tail can be reclaimed by GC. - oldCurEntry.setNext(null); - - // If the next match was directly after oldCurEntry, its predecessor is now - // curPrevEntry (oldCurEntry was just unlinked from the chain). - if (this.nextPrevEntry == oldCurEntry) { - this.nextPrevEntry = this.curPrevEntry; - } - this.curEntry = null; - } + public static final class BucketIterator implements Iterator { + private final long keyHash; + private Hashtable.Entry nextEntry; + + BucketIterator(Hashtable.Entry[] buckets, long keyHash) { + this.keyHash = keyHash; + Hashtable.Entry cur = buckets[Support.bucketIndex(buckets, keyHash)]; + while (cur != null && cur.keyHash != keyHash) { + cur = cur.next(); + } + this.nextEntry = cur; + } - public void replace(TEntry replacementEntry) { - Hashtable.Entry oldCurEntry = this.curEntry; - if (oldCurEntry == null) { - throw new IllegalStateException(); - } - - Hashtable.Entry oldNext = oldCurEntry.next(); - replacementEntry.setNext(oldNext); - this.setPrevNext(replacementEntry); - // Detach the replaced entry from the chain; the replacement now owns the chain slot. - oldCurEntry.setNext(null); - - // If the next match was directly after oldCurEntry, its predecessor is now - // the replacement entry (which took oldCurEntry's chain slot). - if (this.nextPrevEntry == oldCurEntry) { - this.nextPrevEntry = replacementEntry; - } - this.curEntry = replacementEntry; - } + @Override + public boolean hasNext() { + return this.nextEntry != null; + } - void setPrevNext(Hashtable.Entry nextEntry) { - if (this.curPrevEntry == null) { - Hashtable.Entry[] buckets = this.buckets; - buckets[Support.bucketIndex(buckets, this.keyHash)] = nextEntry; - } else { - this.curPrevEntry.setNext(nextEntry); - } + @Override + @SuppressWarnings("unchecked") + public TEntry next() { + Hashtable.Entry cur = this.nextEntry; + if (cur == null) { + throw new NoSuchElementException("no next!"); + } + + Hashtable.Entry advance = cur.next(); + while (advance != null && advance.keyHash != keyHash) { + advance = advance.next(); + } + this.nextEntry = advance; + + return (TEntry) cur; + } } - } - - /** - * Mutating iterator over every entry in a bucket array, regardless of hash. Supports {@link - * #remove()} to unlink the entry last returned by {@link #next()}. - * - *

Walks buckets in array order; within a bucket, walks the chain head-to-tail. After {@code - * remove}, iteration may continue with another {@link #next()}. - * - *

Use this for sweeps -- eviction, expunge, full-table cleanup -- that aren't keyed to a - * specific hash. For per-bucket walks keyed to a search hash, use {@link MutatingBucketIterator}. - */ - public static final class MutatingTableIterator - implements Iterator { - private final Hashtable.Entry[] buckets; - - /** Exclusive upper bound for bucket indices visited by this iterator. */ - private final int endBucket; /** - * Index of the bucket holding {@link #nextEntry} (or holding {@link #curEntry} after remove). + * Mutating iterator over entries in a single bucket whose {@code keyHash} matches a specific + * search hash. Supports {@link #remove()} and {@link #replace} to splice the chain in place. + * + *

Carries previous-node pointers for the current entry and the next-match entry so that {@code + * remove} and {@code replace} can fix up the chain in O(1) without re-walking from the bucket + * head. After {@code remove} or {@code replace}, iteration may continue with another {@link + * #next()}. + * + *

The chain-walk work to find the next-match entry happens in {@link #next()} (and in the + * constructor for the first match); {@link #hasNext()} is an O(1) field read. */ - private int nextBucketIndex; + public static final class MutatingBucketIterator implements Iterator { + private final long keyHash; + + private final Hashtable.Entry[] buckets; + + /** The entry prior to the last entry returned by next Used for mutating operations */ + private Hashtable.Entry curPrevEntry; + + /** The entry that was last returned by next */ + private Hashtable.Entry curEntry; + + /** The entry prior to the next entry */ + private Hashtable.Entry nextPrevEntry; + + /** The next entry to be returned by next */ + private Hashtable.Entry nextEntry; + + MutatingBucketIterator(Hashtable.Entry[] buckets, long keyHash) { + this.buckets = buckets; + this.keyHash = keyHash; + + int bucketIndex = Support.bucketIndex(buckets, keyHash); + Hashtable.Entry headEntry = this.buckets[bucketIndex]; + if (headEntry == null) { + this.nextEntry = null; + this.nextPrevEntry = null; + + this.curEntry = null; + this.curPrevEntry = null; + } else { + Hashtable.Entry prev, cur; + for (prev = null, cur = headEntry; cur != null; prev = cur, cur = cur.next()) { + if (cur.keyHash == keyHash) { + break; + } + } + this.nextPrevEntry = prev; + this.nextEntry = cur; + + this.curEntry = null; + this.curPrevEntry = null; + } + } - /** - * Predecessor of {@link #nextEntry}, or {@code null} when {@code nextEntry} is the bucket head. - */ - private Hashtable.Entry nextPrevEntry; + @Override + public boolean hasNext() { + return (this.nextEntry != null); + } - /** Next entry to be returned by {@link #next()}, or {@code null} if iteration is exhausted. */ - private Hashtable.Entry nextEntry; + @Override + @SuppressWarnings("unchecked") + public TEntry next() { + Hashtable.Entry curEntry = this.nextEntry; + if (curEntry == null) { + throw new NoSuchElementException("no next!"); + } + + this.curEntry = curEntry; + this.curPrevEntry = this.nextPrevEntry; + + Hashtable.Entry prev, cur; + for (prev = this.nextEntry, cur = this.nextEntry.next(); cur != null; prev = cur, cur = prev.next()) { + if (cur.keyHash == keyHash) { + break; + } + } + this.nextPrevEntry = prev; + this.nextEntry = cur; + + return (TEntry) curEntry; + } - /** - * Bucket index that held the entry last returned by {@code next}; {@code -1} after {@code - * remove}. - */ - private int curBucketIndex = -1; + @Override + public void remove() { + Hashtable.Entry oldCurEntry = this.curEntry; + if (oldCurEntry == null) { + throw new IllegalStateException(); + } + + Hashtable.Entry oldNext = oldCurEntry.next(); + this.setPrevNext(oldNext); + // Detach the removed entry from the chain so stale references can't traverse back into + // the live chain and so a now-unreachable tail can be reclaimed by GC. + oldCurEntry.setNext(null); + + // If the next match was directly after oldCurEntry, its predecessor is now + // curPrevEntry (oldCurEntry was just unlinked from the chain). + if (this.nextPrevEntry == oldCurEntry) { + this.nextPrevEntry = this.curPrevEntry; + } + this.curEntry = null; + } - /** - * Predecessor of the entry last returned by {@code next}, or {@code null} if it was the bucket - * head. - */ - private Hashtable.Entry curPrevEntry; + public void replace(TEntry replacementEntry) { + Hashtable.Entry oldCurEntry = this.curEntry; + if (oldCurEntry == null) { + throw new IllegalStateException(); + } + + Hashtable.Entry oldNext = oldCurEntry.next(); + replacementEntry.setNext(oldNext); + this.setPrevNext(replacementEntry); + // Detach the replaced entry from the chain; the replacement now owns the chain slot. + oldCurEntry.setNext(null); + + // If the next match was directly after oldCurEntry, its predecessor is now + // the replacement entry (which took oldCurEntry's chain slot). + if (this.nextPrevEntry == oldCurEntry) { + this.nextPrevEntry = replacementEntry; + } + this.curEntry = replacementEntry; + } - /** - * Entry last returned by {@code next}; {@code null} before any call and after {@code remove}. - */ - private Hashtable.Entry curEntry; - - MutatingTableIterator(Hashtable.Entry[] buckets, int startBucket, int endBucket) { - this.buckets = buckets; - if (startBucket < 0 || startBucket > buckets.length) { - throw new IndexOutOfBoundsException( - "startBucket " + startBucket + " out of range [0, " + buckets.length + "]"); - } - if (endBucket < startBucket || endBucket > buckets.length) { - throw new IndexOutOfBoundsException( - "endBucket " - + endBucket - + " out of range [" - + startBucket - + ", " - + buckets.length - + "]"); - } - this.endBucket = endBucket; - seekFromBucket(startBucket); + void setPrevNext(Hashtable.Entry nextEntry) { + if (this.curPrevEntry == null) { + Hashtable.Entry[] buckets = this.buckets; + buckets[Support.bucketIndex(buckets, this.keyHash)] = nextEntry; + } else { + this.curPrevEntry.setNext(nextEntry); + } + } } /** - * Bucket index of the entry last returned by {@link #next()}, or {@code -1} if {@code next} has - * not yet been called or the most recent call was {@link #remove()}. Useful for callers driving - * a cursor — e.g. resumable eviction sweeps that want to remember where the last successful - * removal landed. + * Mutating iterator over every entry in a bucket array, regardless of hash. Supports {@link + * #remove()} to unlink the entry last returned by {@link #next()}. + * + *

Walks buckets in array order; within a bucket, walks the chain head-to-tail. After {@code + * remove}, iteration may continue with another {@link #next()}. + * + *

Use this for sweeps -- eviction, expunge, full-table cleanup -- that aren't keyed to a + * specific hash. For per-bucket walks keyed to a search hash, use {@link MutatingBucketIterator}. */ - public int currentBucket() { - return this.curBucketIndex; - } + public static final class MutatingTableIterator implements Iterator { + private final Hashtable.Entry[] buckets; + + /** Exclusive upper bound for bucket indices visited by this iterator. */ + private final int endBucket; + + /** + * Index of the bucket holding {@link #nextEntry} (or holding {@link #curEntry} after remove). + */ + private int nextBucketIndex; + + /** + * Predecessor of {@link #nextEntry}, or {@code null} when {@code nextEntry} is the bucket head. + */ + private Hashtable.Entry nextPrevEntry; + + /** Next entry to be returned by {@link #next()}, or {@code null} if iteration is exhausted. */ + private Hashtable.Entry nextEntry; + + /** + * Bucket index that held the entry last returned by {@code next}; {@code -1} after {@code + * remove}. + */ + private int curBucketIndex = -1; + + /** + * Predecessor of the entry last returned by {@code next}, or {@code null} if it was the bucket + * head. + */ + private Hashtable.Entry curPrevEntry; + + /** + * Entry last returned by {@code next}; {@code null} before any call and after {@code remove}. + */ + private Hashtable.Entry curEntry; + + MutatingTableIterator(Hashtable.Entry[] buckets, int startBucket, int endBucket) { + this.buckets = buckets; + if (startBucket < 0 || startBucket > buckets.length) { + throw new IndexOutOfBoundsException( + "startBucket " + startBucket + " out of range [0, " + buckets.length + "]"); + } + if (endBucket < startBucket || endBucket > buckets.length) { + throw new IndexOutOfBoundsException( + "endBucket " + endBucket + " out of range [" + startBucket + ", " + buckets.length + "]"); + } + this.endBucket = endBucket; + seekFromBucket(startBucket); + } - @Override - public boolean hasNext() { - return this.nextEntry != null; - } + /** + * Bucket index of the entry last returned by {@link #next()}, or {@code -1} if {@code next} has + * not yet been called or the most recent call was {@link #remove()}. Useful for callers driving + * a cursor — e.g. resumable eviction sweeps that want to remember where the last successful + * removal landed. + */ + public int currentBucket() { + return this.curBucketIndex; + } - @Override - @SuppressWarnings("unchecked") - public TEntry next() { - Hashtable.Entry e = this.nextEntry; - if (e == null) { - throw new NoSuchElementException("no next!"); - } - - this.curEntry = e; - this.curPrevEntry = this.nextPrevEntry; - this.curBucketIndex = this.nextBucketIndex; - - Hashtable.Entry n = e.next(); - if (n != null) { - this.nextPrevEntry = e; - this.nextEntry = n; - } else { - // walked off the end of this bucket; pick up at the next non-empty bucket - seekFromBucket(this.nextBucketIndex + 1); - } - return (TEntry) e; - } + @Override + public boolean hasNext() { + return this.nextEntry != null; + } - @Override - public void remove() { - Hashtable.Entry oldCurEntry = this.curEntry; - if (oldCurEntry == null) { - throw new IllegalStateException(); - } - - Hashtable.Entry oldNext = oldCurEntry.next(); - if (this.curPrevEntry == null) { - this.buckets[this.curBucketIndex] = oldNext; - } else { - this.curPrevEntry.setNext(oldNext); - } - // Detach the removed entry from the chain so stale references can't traverse back into - // the live chain and so a now-unreachable tail can be reclaimed by GC. - oldCurEntry.setNext(null); - - // If the next entry was the immediate chain successor of oldCurEntry, its predecessor is - // now what came before oldCurEntry (oldCurEntry was just unlinked). - if (this.nextPrevEntry == oldCurEntry) { - this.nextPrevEntry = this.curPrevEntry; - } - this.curEntry = null; - } + @Override + @SuppressWarnings("unchecked") + public TEntry next() { + Hashtable.Entry e = this.nextEntry; + if (e == null) { + throw new NoSuchElementException("no next!"); + } + + this.curEntry = e; + this.curPrevEntry = this.nextPrevEntry; + this.curBucketIndex = this.nextBucketIndex; + + Hashtable.Entry n = e.next(); + if (n != null) { + this.nextPrevEntry = e; + this.nextEntry = n; + } else { + // walked off the end of this bucket; pick up at the next non-empty bucket + seekFromBucket(this.nextBucketIndex + 1); + } + return (TEntry) e; + } - /** - * Advance {@code nextBucketIndex} / {@code nextEntry} to the first non-empty bucket {@code >= - * from} within {@code [0, endBucket)}. - */ - private void seekFromBucket(int from) { - Hashtable.Entry[] thisBuckets = this.buckets; - for (int i = from; i < this.endBucket; i++) { - Hashtable.Entry head = thisBuckets[i]; - if (head != null) { - this.nextBucketIndex = i; - this.nextPrevEntry = null; - this.nextEntry = head; - return; - } - } - this.nextEntry = null; - this.nextPrevEntry = null; + @Override + public void remove() { + Hashtable.Entry oldCurEntry = this.curEntry; + if (oldCurEntry == null) { + throw new IllegalStateException(); + } + + Hashtable.Entry oldNext = oldCurEntry.next(); + if (this.curPrevEntry == null) { + this.buckets[this.curBucketIndex] = oldNext; + } else { + this.curPrevEntry.setNext(oldNext); + } + // Detach the removed entry from the chain so stale references can't traverse back into + // the live chain and so a now-unreachable tail can be reclaimed by GC. + oldCurEntry.setNext(null); + + // If the next entry was the immediate chain successor of oldCurEntry, its predecessor is + // now what came before oldCurEntry (oldCurEntry was just unlinked). + if (this.nextPrevEntry == oldCurEntry) { + this.nextPrevEntry = this.curPrevEntry; + } + this.curEntry = null; + } + + /** + * Advance {@code nextBucketIndex} / {@code nextEntry} to the first non-empty bucket {@code >= + * from} within {@code [0, endBucket)}. + */ + private void seekFromBucket(int from) { + Hashtable.Entry[] thisBuckets = this.buckets; + for (int i = from; i < this.endBucket; i++) { + Hashtable.Entry head = thisBuckets[i]; + if (head != null) { + this.nextBucketIndex = i; + this.nextPrevEntry = null; + this.nextEntry = head; + return; + } + } + this.nextEntry = null; + this.nextPrevEntry = null; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/LongHashingUtils.java b/internal-api/src/main/java/datadog/trace/util/LongHashingUtils.java index 87d0482104e..1d347b922ee 100644 --- a/internal-api/src/main/java/datadog/trace/util/LongHashingUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/LongHashingUtils.java @@ -9,169 +9,165 @@ * hash that take many argument lengths to avoid var-args allocation. */ public final class LongHashingUtils { - private LongHashingUtils() {} - - public static final long hash(Object obj) { - return obj == null ? Long.MIN_VALUE : obj.hashCode(); - } - - public static final long hash(boolean value) { - return Boolean.hashCode(value); - } - - public static final long hash(char value) { - return Character.hashCode(value); - } - - public static final long hash(byte value) { - return Byte.hashCode(value); - } - - public static final long hash(short value) { - return Short.hashCode(value); - } - - public static final long hash(int value) { - return Integer.hashCode(value); - } - - public static final long hash(long value) { - return value; - } - - public static final long hash(float value) { - return Float.hashCode(value); - } - - public static final long hash(double value) { - return Double.doubleToRawLongBits(value); - } - - public static final long hash(Object obj0, Object obj1) { - return hash(intHash(obj0), intHash(obj1)); - } - - static final long hash(int hash0, int hash1) { - return 31L * hash0 + hash1; - } - - private static final int intHash(@Nullable Object obj) { - return obj == null ? 0 : obj.hashCode(); - } - - public static final long hash(Object obj0, Object obj1, Object obj2) { - return hash(intHash(obj0), intHash(obj1), intHash(obj2)); - } - - static final long hash(int hash0, int hash1, int hash2) { - // DQH - Micro-optimizing, 31L * 31L will constant fold - // Since there are multiple execution ports for load & store, - // this will make good use of the core. - return 31L * 31L * hash0 + 31L * hash1 + hash2; - } - - public static final long hash(Object obj0, Object obj1, Object obj2, Object obj3) { - return hash(intHash(obj0), intHash(obj1), intHash(obj2), intHash(obj3)); - } - - static final long hash(int hash0, int hash1, int hash2, int hash3) { - // DQH - Micro-optimizing, 31L * 31L will constant fold - // Since there are multiple execution ports for load & store, - // this will make good use of the core. - return 31L * 31L * 31L * hash0 + 31L * 31L * hash1 + 31L * hash2 + hash3; - } - - public static final long hash(Object obj0, Object obj1, Object obj2, Object obj3, Object obj4) { - return hash(intHash(obj0), intHash(obj1), intHash(obj2), intHash(obj3), intHash(obj4)); - } - - static final long hash(int hash0, int hash1, int hash2, int hash3, int hash4) { - // DQH - Micro-optimizing, 31L * 31L will constant fold - // Since there are multiple execution ports for load & store, - // this will make good use of the core. - return 31L * 31L * 31L * 31L * hash0 - + 31L * 31L * 31L * hash1 - + 31L * 31L * hash2 - + 31L * hash3 - + hash4; - } - - @Deprecated - public static final long hash(int[] hashes) { - long result = 0; - for (int hash : hashes) { - result = addToHash(result, hash); - } - return result; - } - - public static final long addToHash(long hash, int value) { - return 31L * hash + value; - } - - public static final long addToHash(long hash, @Nullable Object obj) { - return addToHash(hash, intHash(obj)); - } - - public static final long addToHash(long hash, boolean value) { - return addToHash(hash, Boolean.hashCode(value)); - } - - public static final long addToHash(long hash, char value) { - return addToHash(hash, Character.hashCode(value)); - } - - public static final long addToHash(long hash, byte value) { - return addToHash(hash, Byte.hashCode(value)); - } - - public static final long addToHash(long hash, short value) { - return addToHash(hash, Short.hashCode(value)); - } - - public static final long addToHash(long hash, long value) { - return addToHash(hash, Long.hashCode(value)); - } - - public static final long addToHash(long hash, float value) { - return addToHash(hash, Float.hashCode(value)); - } - - public static final long addToHash(long hash, double value) { - return addToHash(hash, Double.hashCode(value)); - } - - /** Folds {@code arr[0..len)}; the array must be non-null, but its elements may be null. */ - public static final long addToHash(long hash, @Nonnull Object[] arr, int len) { - for (int i = 0; i < len; i++) { - hash = addToHash(hash, arr[i]); - } - return hash; - } - - public static final long addToHash(long hash, @Nonnull Object[] arr) { - return addToHash(hash, arr, arr.length); - } - - public static final long hash(Iterable objs) { - long result = 0; - for (Object obj : objs) { - result = addToHash(result, obj); - } - return result; - } - - /** - * Calling this var-arg version can result in large amounts of allocation (see HashingBenchmark) - * Rather than calliing this method, add another override of hash that handles a larger number of - * arguments or use calls to addToHash. - */ - @Deprecated - public static final long hash(Object[] objs) { - long result = 0; - for (Object obj : objs) { - result = addToHash(result, obj); - } - return result; - } + private LongHashingUtils() {} + + public static final long hash(Object obj) { + return obj == null ? Long.MIN_VALUE : obj.hashCode(); + } + + public static final long hash(boolean value) { + return Boolean.hashCode(value); + } + + public static final long hash(char value) { + return Character.hashCode(value); + } + + public static final long hash(byte value) { + return Byte.hashCode(value); + } + + public static final long hash(short value) { + return Short.hashCode(value); + } + + public static final long hash(int value) { + return Integer.hashCode(value); + } + + public static final long hash(long value) { + return value; + } + + public static final long hash(float value) { + return Float.hashCode(value); + } + + public static final long hash(double value) { + return Double.doubleToRawLongBits(value); + } + + public static final long hash(Object obj0, Object obj1) { + return hash(intHash(obj0), intHash(obj1)); + } + + static final long hash(int hash0, int hash1) { + return 31L * hash0 + hash1; + } + + private static final int intHash(@Nullable Object obj) { + return obj == null ? 0 : obj.hashCode(); + } + + public static final long hash(Object obj0, Object obj1, Object obj2) { + return hash(intHash(obj0), intHash(obj1), intHash(obj2)); + } + + static final long hash(int hash0, int hash1, int hash2) { + // DQH - Micro-optimizing, 31L * 31L will constant fold + // Since there are multiple execution ports for load & store, + // this will make good use of the core. + return 31L * 31L * hash0 + 31L * hash1 + hash2; + } + + public static final long hash(Object obj0, Object obj1, Object obj2, Object obj3) { + return hash(intHash(obj0), intHash(obj1), intHash(obj2), intHash(obj3)); + } + + static final long hash(int hash0, int hash1, int hash2, int hash3) { + // DQH - Micro-optimizing, 31L * 31L will constant fold + // Since there are multiple execution ports for load & store, + // this will make good use of the core. + return 31L * 31L * 31L * hash0 + 31L * 31L * hash1 + 31L * hash2 + hash3; + } + + public static final long hash(Object obj0, Object obj1, Object obj2, Object obj3, Object obj4) { + return hash(intHash(obj0), intHash(obj1), intHash(obj2), intHash(obj3), intHash(obj4)); + } + + static final long hash(int hash0, int hash1, int hash2, int hash3, int hash4) { + // DQH - Micro-optimizing, 31L * 31L will constant fold + // Since there are multiple execution ports for load & store, + // this will make good use of the core. + return 31L * 31L * 31L * 31L * hash0 + 31L * 31L * 31L * hash1 + 31L * 31L * hash2 + 31L * hash3 + hash4; + } + + @Deprecated + public static final long hash(int[] hashes) { + long result = 0; + for (int hash : hashes) { + result = addToHash(result, hash); + } + return result; + } + + public static final long addToHash(long hash, int value) { + return 31L * hash + value; + } + + public static final long addToHash(long hash, @Nullable Object obj) { + return addToHash(hash, intHash(obj)); + } + + public static final long addToHash(long hash, boolean value) { + return addToHash(hash, Boolean.hashCode(value)); + } + + public static final long addToHash(long hash, char value) { + return addToHash(hash, Character.hashCode(value)); + } + + public static final long addToHash(long hash, byte value) { + return addToHash(hash, Byte.hashCode(value)); + } + + public static final long addToHash(long hash, short value) { + return addToHash(hash, Short.hashCode(value)); + } + + public static final long addToHash(long hash, long value) { + return addToHash(hash, Long.hashCode(value)); + } + + public static final long addToHash(long hash, float value) { + return addToHash(hash, Float.hashCode(value)); + } + + public static final long addToHash(long hash, double value) { + return addToHash(hash, Double.hashCode(value)); + } + + /** Folds {@code arr[0..len)}; the array must be non-null, but its elements may be null. */ + public static final long addToHash(long hash, @Nonnull Object[] arr, int len) { + for (int i = 0; i < len; i++) { + hash = addToHash(hash, arr[i]); + } + return hash; + } + + public static final long addToHash(long hash, @Nonnull Object[] arr) { + return addToHash(hash, arr, arr.length); + } + + public static final long hash(Iterable objs) { + long result = 0; + for (Object obj : objs) { + result = addToHash(result, obj); + } + return result; + } + + /** + * Calling this var-arg version can result in large amounts of allocation (see HashingBenchmark) + * Rather than calliing this method, add another override of hash that handles a larger number of + * arguments or use calls to addToHash. + */ + @Deprecated + public static final long hash(Object[] objs) { + long result = 0; + for (Object obj : objs) { + result = addToHash(result, obj); + } + return result; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/Maybe.java b/internal-api/src/main/java/datadog/trace/util/Maybe.java index 4aec3fe290e..690f1c07729 100644 --- a/internal-api/src/main/java/datadog/trace/util/Maybe.java +++ b/internal-api/src/main/java/datadog/trace/util/Maybe.java @@ -43,108 +43,109 @@ */ @NoEscape public final class Maybe { - @Nullable private final T value; + @Nullable + private final T value; - private Maybe(@Nullable T value) { - this.value = value; - } + private Maybe(@Nullable T value) { + this.value = value; + } - @Nonnull - public static Maybe of(@Nullable T value) { - return new Maybe<>(value); - } + @Nonnull + public static Maybe of(@Nullable T value) { + return new Maybe<>(value); + } - /** - * Convenience form for the common shape {@code Maybe.of(receiver.someNullableMethod(args))}: - * {@code Maybe.of(receiver, r -> r.someNullableMethod(args))}. Useful when {@code receiver} would - * otherwise have to be re-evaluated or named twice at the call site. - * - *

Unlike the single-arg {@link #of}, {@code fn} here is typically a capturing lambda - * -- it closes over whatever local arguments the caller's method has in scope, so a fresh lambda - * instance is created on every invocation (capturing lambdas are never cached the way a - * non-capturing lambda's singleton instance commonly is) -- which makes it a second heap-object - * candidate distinct from the {@code Maybe} itself. That freshly-allocated capturing lambda still - * scalar-replaces as reliably as a plain delegating method call does, for the shape actually - * measured (JDK 8/11/17/25): a monomorphic receiver and a {@code fn} that is applied exactly once - * and does not itself escape (e.g. by being stored or passed further). If {@code fn} itself - * captures something that must be freshly allocated per call (e.g. a non-singleton creator), that - * allocation is real regardless of what happens to the lambda wrapping it. - */ - @Nonnull - public static Maybe of(R receiver, @Nonnull Function fn) { - return new Maybe<>(fn.apply(receiver)); - } + /** + * Convenience form for the common shape {@code Maybe.of(receiver.someNullableMethod(args))}: + * {@code Maybe.of(receiver, r -> r.someNullableMethod(args))}. Useful when {@code receiver} would + * otherwise have to be re-evaluated or named twice at the call site. + * + *

Unlike the single-arg {@link #of}, {@code fn} here is typically a capturing lambda + * -- it closes over whatever local arguments the caller's method has in scope, so a fresh lambda + * instance is created on every invocation (capturing lambdas are never cached the way a + * non-capturing lambda's singleton instance commonly is) -- which makes it a second heap-object + * candidate distinct from the {@code Maybe} itself. That freshly-allocated capturing lambda still + * scalar-replaces as reliably as a plain delegating method call does, for the shape actually + * measured (JDK 8/11/17/25): a monomorphic receiver and a {@code fn} that is applied exactly once + * and does not itself escape (e.g. by being stored or passed further). If {@code fn} itself + * captures something that must be freshly allocated per call (e.g. a non-singleton creator), that + * allocation is real regardless of what happens to the lambda wrapping it. + */ + @Nonnull + public static Maybe of(R receiver, @Nonnull Function fn) { + return new Maybe<>(fn.apply(receiver)); + } - public boolean isPresent() { - return value != null; - } + public boolean isPresent() { + return value != null; + } - /** - * Raw accessor -- named to make the null case unmissable at the call site, rather than {@code - * orElse}/{@code get}, neither of which says so on its own. - */ - @Nullable - public T getOrNull() { - return value; - } + /** + * Raw accessor -- named to make the null case unmissable at the call site, rather than {@code + * orElse}/{@code get}, neither of which says so on its own. + */ + @Nullable + public T getOrNull() { + return value; + } - /** - * Primary intended usage: a guard in front of mutation, e.g. {@code - * table.tryGetOrCreateAsTry(key, FooEntry::new).update(FooEntry::inc)}. No-op if the operation - * was refused (table full) rather than throwing or requiring the caller to branch on {@link - * #isPresent()} first. - */ - public void update(Consumer mutator) { - if (value != null) { - mutator.accept(value); + /** + * Primary intended usage: a guard in front of mutation, e.g. {@code + * table.tryGetOrCreateAsTry(key, FooEntry::new).update(FooEntry::inc)}. No-op if the operation + * was refused (table full) rather than throwing or requiring the caller to branch on {@link + * #isPresent()} first. + */ + public void update(Consumer mutator) { + if (value != null) { + mutator.accept(value); + } } - } - /** - * Generic-context form of {@link #update(Consumer)}, for callers that already have a reusable, - * non-capturing {@code BiConsumer} (typically a {@code static final}) plus whatever context it - * needs -- {@code (value, context)} to stay consistent with the primitive-context overloads - * below, at the cost of departing from {@code Hashtable#forEach}'s {@code (context, entry)} - * convention. - */ - public void update(C context, BiConsumer mutator) { - if (value != null) { - mutator.accept(value, context); + /** + * Generic-context form of {@link #update(Consumer)}, for callers that already have a reusable, + * non-capturing {@code BiConsumer} (typically a {@code static final}) plus whatever context it + * needs -- {@code (value, context)} to stay consistent with the primitive-context overloads + * below, at the cost of departing from {@code Hashtable#forEach}'s {@code (context, entry)} + * convention. + */ + public void update(C context, BiConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } } - } - /** - * Primitive-context form of {@link #update(Consumer)}, for the common case where the mutation - * needs one caller-supplied number (e.g. a duration or count) and boxing it into a captured - * {@code Long}/generic-context object would be the actual per-call allocation. This exists so a - * table wrapping a fallible lookup in {@code Maybe} pays for this shape once, here, instead of - * once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s {@code - * ObjLongConsumer} overload for the caller-side problem this replaces. - * - *

Deliberately the only primitive-context overload. An {@code int}/{@code - * double}/{@code boolean} sibling was tried and reverted: Java's overload resolution can pick - * cleanly between a primitive overload and the generic {@link #update(Object, BiConsumer)} form - * for a reference-typed argument (boxing is only considered once no non-boxing candidate - * applies), but that guarantee does not extend to a second primitive overload -- {@code update(1, - * lambda)} is ambiguous between {@code int} and {@code long} even with no {@code double} overload - * in the picture, because {@link ObjIntConsumer} and {@link ObjLongConsumer} are unrelated - * interfaces and JLS 15.12.2.5's most-specific-method rule requires every parameter position to - * agree, not just the numeric one. Confirmed by direct compilation, not just JLS reading: an - * inline lambda call breaks as soon as a second primitive overload exists. A plain {@code int} - * argument still widens to {@code long} for free at this single overload -- callers are not - * required to have a {@code long} in hand. - */ - public void update(long context, ObjLongConsumer mutator) { - if (value != null) { - mutator.accept(value, context); + /** + * Primitive-context form of {@link #update(Consumer)}, for the common case where the mutation + * needs one caller-supplied number (e.g. a duration or count) and boxing it into a captured + * {@code Long}/generic-context object would be the actual per-call allocation. This exists so a + * table wrapping a fallible lookup in {@code Maybe} pays for this shape once, here, instead of + * once per mutator-flavor per table type -- see {@code Hashtable#tryGetOrUpdate}'s {@code + * ObjLongConsumer} overload for the caller-side problem this replaces. + * + *

Deliberately the only primitive-context overload. An {@code int}/{@code + * double}/{@code boolean} sibling was tried and reverted: Java's overload resolution can pick + * cleanly between a primitive overload and the generic {@link #update(Object, BiConsumer)} form + * for a reference-typed argument (boxing is only considered once no non-boxing candidate + * applies), but that guarantee does not extend to a second primitive overload -- {@code update(1, + * lambda)} is ambiguous between {@code int} and {@code long} even with no {@code double} overload + * in the picture, because {@link ObjIntConsumer} and {@link ObjLongConsumer} are unrelated + * interfaces and JLS 15.12.2.5's most-specific-method rule requires every parameter position to + * agree, not just the numeric one. Confirmed by direct compilation, not just JLS reading: an + * inline lambda call breaks as soon as a second primitive overload exists. A plain {@code int} + * argument still widens to {@code long} for free at this single overload -- callers are not + * required to have a {@code long} in hand. + */ + public void update(long context, ObjLongConsumer mutator) { + if (value != null) { + mutator.accept(value, context); + } } - } - public void ifPresentOrElse(Consumer action, Runnable emptyAction) { - if (value != null) { - action.accept(value); - } else { - emptyAction.run(); + public void ifPresentOrElse(Consumer action, Runnable emptyAction) { + if (value != null) { + action.accept(value); + } else { + emptyAction.run(); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/MethodHandles.java b/internal-api/src/main/java/datadog/trace/util/MethodHandles.java index bb0f756bf6f..07e7229960f 100644 --- a/internal-api/src/main/java/datadog/trace/util/MethodHandles.java +++ b/internal-api/src/main/java/datadog/trace/util/MethodHandles.java @@ -14,221 +14,202 @@ public class MethodHandles { - private static final Logger log = LoggerFactory.getLogger(MethodHandles.class); - - private final java.lang.invoke.MethodHandles.Lookup lookup = - java.lang.invoke.MethodHandles.lookup(); - private final ClassLoader classLoader; - - public MethodHandles(ClassLoader classLoader) { - this.classLoader = classLoader; - } - - public MethodHandle privateFieldGetter(String className, String fieldName) { - Class clazz = loadClass(className); - return clazz != null ? privateFieldGetter(clazz, fieldName) : null; - } - - public MethodHandle privateFieldGetter(Class clazz, String fieldName) { - return AccessController.doPrivileged( - (PrivilegedAction) - () -> { - try { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.class); + + private final java.lang.invoke.MethodHandles.Lookup lookup = java.lang.invoke.MethodHandles.lookup(); + private final ClassLoader classLoader; + + public MethodHandles(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + public MethodHandle privateFieldGetter(String className, String fieldName) { + Class clazz = loadClass(className); + return clazz != null ? privateFieldGetter(clazz, fieldName) : null; + } + + public MethodHandle privateFieldGetter(Class clazz, String fieldName) { + return AccessController.doPrivileged((PrivilegedAction) () -> { + try { try { - SecurityManager sm = System.getSecurityManager(); - if (sm != null) { - String packageName = clazz.getPackage().getName(); - sm.checkPackageAccess(packageName); - } + SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + String packageName = clazz.getPackage().getName(); + sm.checkPackageAccess(packageName); + } } catch (UnsupportedOperationException e) { - // ignore + // ignore } Field field = clazz.getDeclaredField(fieldName); field.setAccessible(true); return lookup.unreflectGetter(field); - } catch (Throwable t) { + } catch (Throwable t) { log.debug( - LogCollector.EXCLUDE_TELEMETRY, - "Could not get private field {} getter from class {}", - fieldName, - clazz.getName(), - t); + LogCollector.EXCLUDE_TELEMETRY, + "Could not get private field {} getter from class {}", + fieldName, + clazz.getName(), + t); return null; - } - }); - } - - public MethodHandle privateFieldSetter(String className, String fieldName) { - Class clazz = loadClass(className); - return clazz != null ? privateFieldSetter(clazz, fieldName) : null; - } - - public MethodHandle privateFieldSetter(Class clazz, String fieldName) { - return AccessController.doPrivileged( - (PrivilegedAction) - () -> { - try { + } + }); + } + + public MethodHandle privateFieldSetter(String className, String fieldName) { + Class clazz = loadClass(className); + return clazz != null ? privateFieldSetter(clazz, fieldName) : null; + } + + public MethodHandle privateFieldSetter(Class clazz, String fieldName) { + return AccessController.doPrivileged((PrivilegedAction) () -> { + try { try { - SecurityManager sm = System.getSecurityManager(); - if (sm != null) { - String packageName = clazz.getPackage().getName(); - sm.checkPackageAccess(packageName); - } + SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + String packageName = clazz.getPackage().getName(); + sm.checkPackageAccess(packageName); + } } catch (UnsupportedOperationException e) { - // ignore + // ignore } Field field = clazz.getDeclaredField(fieldName); field.setAccessible(true); return lookup.unreflectSetter(field); - } catch (Throwable t) { + } catch (Throwable t) { log.debug( - LogCollector.EXCLUDE_TELEMETRY, - "Could not get private field {} setter from class {}", - fieldName, - clazz.getName(), - t); + LogCollector.EXCLUDE_TELEMETRY, + "Could not get private field {} setter from class {}", + fieldName, + clazz.getName(), + t); return null; - } - }); - } - - public MethodHandle constructor(String className, Class... parameterTypes) { - Class clazz = loadClass(className); - return clazz != null ? constructor(clazz, parameterTypes) : null; - } - - public MethodHandle constructor(Class clazz, Class... parameterTypes) { - return AccessController.doPrivileged( - (PrivilegedAction) - () -> { - try { + } + }); + } + + public MethodHandle constructor(String className, Class... parameterTypes) { + Class clazz = loadClass(className); + return clazz != null ? constructor(clazz, parameterTypes) : null; + } + + public MethodHandle constructor(Class clazz, Class... parameterTypes) { + return AccessController.doPrivileged((PrivilegedAction) () -> { + try { try { - SecurityManager sm = System.getSecurityManager(); - if (sm != null) { - String packageName = clazz.getPackage().getName(); - sm.checkPackageAccess(packageName); - } + SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + String packageName = clazz.getPackage().getName(); + sm.checkPackageAccess(packageName); + } } catch (UnsupportedOperationException e) { - // ignore + // ignore } Constructor constructor = clazz.getDeclaredConstructor(parameterTypes); constructor.setAccessible(true); return lookup.unreflectConstructor(constructor); - } catch (Throwable t) { + } catch (Throwable t) { log.debug( - LogCollector.EXCLUDE_TELEMETRY, - "Could not get constructor accepting {} from class {}", - Arrays.toString(parameterTypes), - clazz.getName(), - t); + LogCollector.EXCLUDE_TELEMETRY, + "Could not get constructor accepting {} from class {}", + Arrays.toString(parameterTypes), + clazz.getName(), + t); return null; - } - }); - } - - public MethodHandle method(String className, String methodName, Class... parameterTypes) { - Class clazz = loadClass(className); - return clazz != null ? method(clazz, methodName, parameterTypes) : null; - } - - public MethodHandle method(Class clazz, String methodName, Class... parameterTypes) { - return AccessController.doPrivileged( - (PrivilegedAction) - () -> { - try { + } + }); + } + + public MethodHandle method(String className, String methodName, Class... parameterTypes) { + Class clazz = loadClass(className); + return clazz != null ? method(clazz, methodName, parameterTypes) : null; + } + + public MethodHandle method(Class clazz, String methodName, Class... parameterTypes) { + return AccessController.doPrivileged((PrivilegedAction) () -> { + try { try { - SecurityManager sm = System.getSecurityManager(); - if (sm != null) { - String packageName = clazz.getPackage().getName(); - sm.checkPackageAccess(packageName); - } + SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + String packageName = clazz.getPackage().getName(); + sm.checkPackageAccess(packageName); + } } catch (UnsupportedOperationException e) { - // ignore + // ignore } Method method = clazz.getDeclaredMethod(methodName, parameterTypes); method.setAccessible(true); return lookup.unreflect(method); - } catch (Throwable t) { + } catch (Throwable t) { log.debug( - LogCollector.EXCLUDE_TELEMETRY, - "Could not get method {} accepting {} from class {}", - methodName, - Arrays.toString(parameterTypes), - clazz, - t); + LogCollector.EXCLUDE_TELEMETRY, + "Could not get method {} accepting {} from class {}", + methodName, + Arrays.toString(parameterTypes), + clazz, + t); return null; - } - }); - } - - public MethodHandle method(Class clazz, Predicate filter) { - return AccessController.doPrivileged( - (PrivilegedAction) - () -> { - try { + } + }); + } + + public MethodHandle method(Class clazz, Predicate filter) { + return AccessController.doPrivileged((PrivilegedAction) () -> { + try { try { - SecurityManager sm = System.getSecurityManager(); - if (sm != null) { - String packageName = clazz.getPackage().getName(); - sm.checkPackageAccess(packageName); - } + SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + String packageName = clazz.getPackage().getName(); + sm.checkPackageAccess(packageName); + } } catch (UnsupportedOperationException e) { - // ignore + // ignore } Method[] methods = clazz.getDeclaredMethods(); for (Method method : methods) { - if (filter.test(method)) { - method.setAccessible(true); - return lookup.unreflect(method); - } + if (filter.test(method)) { + method.setAccessible(true); + return lookup.unreflect(method); + } } log.debug("Could not find desired method in class {}", clazz); return null; - } catch (Throwable t) { - log.debug( - LogCollector.EXCLUDE_TELEMETRY, - "Could not find desired method in class {}", - clazz, - t); + } catch (Throwable t) { + log.debug(LogCollector.EXCLUDE_TELEMETRY, "Could not find desired method in class {}", clazz, t); return null; - } - }); - } - - private Class loadClass(String className) { - try { - return classLoader.loadClass(className); - } catch (Throwable t) { - log.debug(LogCollector.EXCLUDE_TELEMETRY, "Could not load class {}", className, t); - return null; + } + }); } - } - @SuppressWarnings("unchecked") - public T invoke(MethodHandle handle, Object... arguments) { - if (handle == null) { - return null; + private Class loadClass(String className) { + try { + return classLoader.loadClass(className); + } catch (Throwable t) { + log.debug(LogCollector.EXCLUDE_TELEMETRY, "Could not load class {}", className, t); + return null; + } } - try { - return (T) handle.invokeWithArguments(arguments); - } catch (Throwable t) { - log.error( - "Error while invoking method handle {} with arguments {}", - handle, - Arrays.toString(arguments), - t); - return null; + + @SuppressWarnings("unchecked") + public T invoke(MethodHandle handle, Object... arguments) { + if (handle == null) { + return null; + } + try { + return (T) handle.invokeWithArguments(arguments); + } catch (Throwable t) { + log.error("Error while invoking method handle {} with arguments {}", handle, Arrays.toString(arguments), t); + return null; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/Numbers.java b/internal-api/src/main/java/datadog/trace/util/Numbers.java index 284aa4b733a..8cf96fc2764 100644 --- a/internal-api/src/main/java/datadog/trace/util/Numbers.java +++ b/internal-api/src/main/java/datadog/trace/util/Numbers.java @@ -10,123 +10,123 @@ */ public final class Numbers { - private Numbers() { - // Utility class - prevent instantiation - } - - /** - * Attempts to parse a string value as a number with fast-path validation to avoid throwing - * NumberFormatException for invalid inputs. - * - *

This method first validates that the string looks like a valid number before attempting to - * parse, significantly reducing overhead when processing non-numeric strings. The validation - * supports: - * - *

    - *
  • Integers: "42", "-100", "+999" - *
  • Decimals: "3.14", ".5", "5." - *
  • Scientific notation: "1.5e10", "-3.5E-7", "2e+3" - *
  • Whitespace: Trimmed before validation (e.g., " 42 " parses successfully) - *
- * - *

Performance characteristics: - * - *

    - *
  • Valid numeric strings: ~50-100 ns/op - *
  • Invalid strings: ~40-70 ns/op (vs ~1000+ ns with exception-based parsing) - *
  • Empty/null: ~5 ns/op - *
- * - * @param value the string to parse, may be null - * @return a Long for plain integers, Double for decimals or scientific notation, or null if the - * string is not a valid number - */ - @Nullable - public static Number parseNumber(@Nullable String value) { - if (value == null || value.isEmpty()) { - return null; + private Numbers() { + // Utility class - prevent instantiation } - // Trim whitespace to handle common cases like " 42 " or "\t100\n" - String trimmed = value.trim(); - if (trimmed.isEmpty()) { - return null; - } + /** + * Attempts to parse a string value as a number with fast-path validation to avoid throwing + * NumberFormatException for invalid inputs. + * + *

This method first validates that the string looks like a valid number before attempting to + * parse, significantly reducing overhead when processing non-numeric strings. The validation + * supports: + * + *

    + *
  • Integers: "42", "-100", "+999" + *
  • Decimals: "3.14", ".5", "5." + *
  • Scientific notation: "1.5e10", "-3.5E-7", "2e+3" + *
  • Whitespace: Trimmed before validation (e.g., " 42 " parses successfully) + *
+ * + *

Performance characteristics: + * + *

    + *
  • Valid numeric strings: ~50-100 ns/op + *
  • Invalid strings: ~40-70 ns/op (vs ~1000+ ns with exception-based parsing) + *
  • Empty/null: ~5 ns/op + *
+ * + * @param value the string to parse, may be null + * @return a Long for plain integers, Double for decimals or scientific notation, or null if the + * string is not a valid number + */ + @Nullable + public static Number parseNumber(@Nullable String value) { + if (value == null || value.isEmpty()) { + return null; + } - // Fast-path validation: check if the string looks like a valid number - // before attempting to parse, to avoid expensive NumberFormatException throws - // Supports: integers, decimals, and scientific notation (e.g., "1.5e10", "-3E-7") - boolean hasDecimal = false; - boolean hasExponent = false; - int startIdx = 0; - int length = trimmed.length(); + // Trim whitespace to handle common cases like " 42 " or "\t100\n" + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + return null; + } - // Handle optional leading sign - char firstChar = trimmed.charAt(0); - if (firstChar == '+' || firstChar == '-') { - startIdx = 1; - // Sign-only strings like "+" or "-" are not valid numbers - if (startIdx >= length) { - return null; - } - } + // Fast-path validation: check if the string looks like a valid number + // before attempting to parse, to avoid expensive NumberFormatException throws + // Supports: integers, decimals, and scientific notation (e.g., "1.5e10", "-3E-7") + boolean hasDecimal = false; + boolean hasExponent = false; + int startIdx = 0; + int length = trimmed.length(); - // Validate characters: digits, optional decimal point, optional exponent - boolean hasDigits = false; - for (int i = startIdx; i < length; i++) { - char c = trimmed.charAt(i); - if (c == '.') { - if (hasDecimal || hasExponent) { - // Multiple decimal points or decimal after exponent - invalid - return null; - } - hasDecimal = true; - } else if (c == 'e' || c == 'E') { - if (hasExponent || !hasDigits) { - // Multiple exponents or exponent without preceding digits - invalid - return null; + // Handle optional leading sign + char firstChar = trimmed.charAt(0); + if (firstChar == '+' || firstChar == '-') { + startIdx = 1; + // Sign-only strings like "+" or "-" are not valid numbers + if (startIdx >= length) { + return null; + } } - hasExponent = true; - // Next character can be optional sign (+/-) followed by digits - if (i + 1 < length) { - char next = trimmed.charAt(i + 1); - if (next == '+' || next == '-') { - i++; // Skip the sign after exponent - // Must have at least one digit after exponent sign - if (i + 1 >= length) { - return null; + + // Validate characters: digits, optional decimal point, optional exponent + boolean hasDigits = false; + for (int i = startIdx; i < length; i++) { + char c = trimmed.charAt(i); + if (c == '.') { + if (hasDecimal || hasExponent) { + // Multiple decimal points or decimal after exponent - invalid + return null; + } + hasDecimal = true; + } else if (c == 'e' || c == 'E') { + if (hasExponent || !hasDigits) { + // Multiple exponents or exponent without preceding digits - invalid + return null; + } + hasExponent = true; + // Next character can be optional sign (+/-) followed by digits + if (i + 1 < length) { + char next = trimmed.charAt(i + 1); + if (next == '+' || next == '-') { + i++; // Skip the sign after exponent + // Must have at least one digit after exponent sign + if (i + 1 >= length) { + return null; + } + } + } else { + // Exponent must be followed by digits + return null; + } + } else if (c >= '0' && c <= '9') { + hasDigits = true; + } else { + // Non-digit, non-decimal, non-exponent character - invalid + return null; } - } - } else { - // Exponent must be followed by digits - return null; } - } else if (c >= '0' && c <= '9') { - hasDigits = true; - } else { - // Non-digit, non-decimal, non-exponent character - invalid - return null; - } - } - // Must have at least one digit (reject strings like ".", "+.", "e10") - if (!hasDigits) { - return null; - } + // Must have at least one digit (reject strings like ".", "+.", "e10") + if (!hasDigits) { + return null; + } - // Now attempt parsing - the try-catch remains as a fallback for overflow cases - // (e.g., Long.MAX_VALUE+1) or other edge cases that passed pre-validation - try { - if (hasDecimal || hasExponent) { - // Parse as Double for decimals and scientific notation - return Double.parseDouble(trimmed); - } else { - // Parse as Long for plain integers - return Long.parseLong(trimmed); - } - } catch (NumberFormatException e) { - // Overflow or other parse failure despite pre-check (rare) - return null; + // Now attempt parsing - the try-catch remains as a fallback for overflow cases + // (e.g., Long.MAX_VALUE+1) or other edge cases that passed pre-validation + try { + if (hasDecimal || hasExponent) { + // Parse as Double for decimals and scientific notation + return Double.parseDouble(trimmed); + } else { + // Parse as Long for plain integers + return Long.parseLong(trimmed); + } + } catch (NumberFormatException e) { + // Overflow or other parse failure despite pre-check (rare) + return null; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/PidHelper.java b/internal-api/src/main/java/datadog/trace/util/PidHelper.java index 887dd3900e8..cf66c7e5754 100644 --- a/internal-api/src/main/java/datadog/trace/util/PidHelper.java +++ b/internal-api/src/main/java/datadog/trace/util/PidHelper.java @@ -23,150 +23,145 @@ /** Get PID in reasonably cross-platform way. */ @SuppressForbidden public final class PidHelper { - private static final Logger log = LoggerFactory.getLogger(PidHelper.class); + private static final Logger log = LoggerFactory.getLogger(PidHelper.class); - private static final String PID = findPid(); + private static final String PID = findPid(); - private static final long PID_AS_LONG = parsePid(); + private static final long PID_AS_LONG = parsePid(); - public static String getPid() { - return PID; - } - - /** Returns 0 if the PID is not a number. */ - public static long getPidAsLong() { - return PID_AS_LONG; - } + public static String getPid() { + return PID; + } - @SuppressWarnings("unchecked") - private static String findPid() { - String pid = ""; - if (JavaVirtualMachine.isJavaVersionAtLeast(9)) { - try { - pid = - ((Supplier) - Class.forName("datadog.trace.util.JDK9PidSupplier") - .getDeclaredConstructor() - .newInstance()) - .get() - .trim(); - } catch (Throwable e) { - log.debug("JDK9PidSupplier not available", e); - } + /** Returns 0 if the PID is not a number. */ + public static long getPidAsLong() { + return PID_AS_LONG; } - if (pid.isEmpty()) { - try { - // assumption: first part of runtime vmId is our process id - String vmId = ManagementFactory.getRuntimeMXBean().getName(); - int pidEnd = vmId.indexOf('@'); - if (pidEnd > 0) { - pid = vmId.substring(0, pidEnd).trim(); + + @SuppressWarnings("unchecked") + private static String findPid() { + String pid = ""; + if (JavaVirtualMachine.isJavaVersionAtLeast(9)) { + try { + pid = ((Supplier) Class.forName("datadog.trace.util.JDK9PidSupplier") + .getDeclaredConstructor() + .newInstance()) + .get() + .trim(); + } catch (Throwable e) { + log.debug("JDK9PidSupplier not available", e); + } } - } catch (Throwable e) { - log.debug("Process id not available", e); - } + if (pid.isEmpty()) { + try { + // assumption: first part of runtime vmId is our process id + String vmId = ManagementFactory.getRuntimeMXBean().getName(); + int pidEnd = vmId.indexOf('@'); + if (pidEnd > 0) { + pid = vmId.substring(0, pidEnd).trim(); + } + } catch (Throwable e) { + log.debug("Process id not available", e); + } + } + return pid; } - return pid; - } - private static String getTempDir() { - if (!JavaVirtualMachine.isJ9()) { - // See - // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppatha#remarks - // and - // the JDK OS-specific implementations of os::get_temp_directory(), i.e. - // https://github.com/openjdk/jdk/blob/f50bd0d9ec65a6b9596805d0131aaefc1bb913f3/src/hotspot/os/bsd/os_bsd.cpp#L886-L904 - if (OperatingSystem.isLinux()) { - return "/tmp"; - } else if (OperatingSystem.isWindows()) { - return Stream.of("TMP", "TEMP", "USERPROFILE") - .map(ConfigHelper::env) - .filter(Objects::nonNull) - .filter(((Predicate) String::isEmpty).negate()) - .findFirst() - .orElse("C:\\Windows"); - } else if (OperatingSystem.isMacOs()) { - return ConfigHelper.env("TMPDIR"); - } else { - return SystemProperties.get("java.io.tmpdir"); - } - } else { - try { - https: // github.com/eclipse-openj9/openj9/blob/196082df056a990756a5571bfac29585fbbfbb42/jcl/src/java.base/share/classes/openj9/internal/tools/attach/target/IPC.java#L351 - return (String) - Class.forName("openj9.internal.tools.attach.target.IPC") - .getDeclaredMethod("getTmpDir") - .invoke(null); - } catch (Throwable t) { - // Fall back to constants based on J9 source code, may not have perfect coverage - String tmpDir = SystemProperties.get("java.io.tmpdir"); - if (tmpDir != null && !tmpDir.isEmpty()) { - return tmpDir; - } else if (OperatingSystem.isWindows()) { - return "C:\\Documents"; + private static String getTempDir() { + if (!JavaVirtualMachine.isJ9()) { + // See + // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppatha#remarks + // and + // the JDK OS-specific implementations of os::get_temp_directory(), i.e. + // https://github.com/openjdk/jdk/blob/f50bd0d9ec65a6b9596805d0131aaefc1bb913f3/src/hotspot/os/bsd/os_bsd.cpp#L886-L904 + if (OperatingSystem.isLinux()) { + return "/tmp"; + } else if (OperatingSystem.isWindows()) { + return Stream.of("TMP", "TEMP", "USERPROFILE") + .map(ConfigHelper::env) + .filter(Objects::nonNull) + .filter(((Predicate) String::isEmpty).negate()) + .findFirst() + .orElse("C:\\Windows"); + } else if (OperatingSystem.isMacOs()) { + return ConfigHelper.env("TMPDIR"); + } else { + return SystemProperties.get("java.io.tmpdir"); + } } else { - return "/tmp"; + try { + https: // github.com/eclipse-openj9/openj9/blob/196082df056a990756a5571bfac29585fbbfbb42/jcl/src/java.base/share/classes/openj9/internal/tools/attach/target/IPC.java#L351 + return (String) Class.forName("openj9.internal.tools.attach.target.IPC") + .getDeclaredMethod("getTmpDir") + .invoke(null); + } catch (Throwable t) { + // Fall back to constants based on J9 source code, may not have perfect coverage + String tmpDir = SystemProperties.get("java.io.tmpdir"); + if (tmpDir != null && !tmpDir.isEmpty()) { + return tmpDir; + } else if (OperatingSystem.isWindows()) { + return "C:\\Documents"; + } else { + return "/tmp"; + } + } } - } } - } - private static Path getJavaProcessesDir() { - if (JavaVirtualMachine.isJ9()) { - // J9 uses a different temporary directory AND subdirectory for storing jps / attach-related - // info - // https://github.com/eclipse-openj9/openj9/blob/196082df056a990756a5571bfac29585fbbfbb42/jcl/src/java.base/share/classes/openj9/internal/tools/attach/target/CommonDirectory.java#L94 - return Paths.get(getTempDir(), ".com_ibm_tools_attach"); - } else { - // Emulating the hotspot way to enumerate the JVM processes using the perfdata file - // https://github.com/openjdk/jdk/blob/d7cb933b89839b692f5562aeeb92076cd25a99f6/src/hotspot/share/runtime/perfMemory.cpp#L244 - return Paths.get(getTempDir(), "hsperfdata_" + SystemProperties.get("user.name")); + private static Path getJavaProcessesDir() { + if (JavaVirtualMachine.isJ9()) { + // J9 uses a different temporary directory AND subdirectory for storing jps / attach-related + // info + // https://github.com/eclipse-openj9/openj9/blob/196082df056a990756a5571bfac29585fbbfbb42/jcl/src/java.base/share/classes/openj9/internal/tools/attach/target/CommonDirectory.java#L94 + return Paths.get(getTempDir(), ".com_ibm_tools_attach"); + } else { + // Emulating the hotspot way to enumerate the JVM processes using the perfdata file + // https://github.com/openjdk/jdk/blob/d7cb933b89839b692f5562aeeb92076cd25a99f6/src/hotspot/share/runtime/perfMemory.cpp#L244 + return Paths.get(getTempDir(), "hsperfdata_" + SystemProperties.get("user.name")); + } } - } - public static Set getJavaPids() { - try (Stream stream = Files.list(getJavaProcessesDir())) { - return stream - .map(Path::getFileName) - .map(Path::toString) - .filter( - (name) -> { - // On J9, additional metadata files are present alongside files named $PID. - // Additionally, the contents of the ps dir are files with process ID files for - // Hotspot, - // but they are directories for J9. - // This also makes sense as defensive programming. - if (name.isEmpty()) { - return false; - } - char c = name.charAt(0); - if (c < '0' || c > '9') { - // Short-circuit - let's not parse as long something that is definitely not a long - // number - return false; - } - long pid = -1; - try { - pid = Long.parseLong(name); - } catch (NumberFormatException ignored) { - } - return pid != -1; - }) - .collect(Collectors.toSet()); - } catch (IOException e) { - log.debug("Unable to obtain Java PIDs", e); + public static Set getJavaPids() { + try (Stream stream = Files.list(getJavaProcessesDir())) { + return stream.map(Path::getFileName) + .map(Path::toString) + .filter((name) -> { + // On J9, additional metadata files are present alongside files named $PID. + // Additionally, the contents of the ps dir are files with process ID files for + // Hotspot, + // but they are directories for J9. + // This also makes sense as defensive programming. + if (name.isEmpty()) { + return false; + } + char c = name.charAt(0); + if (c < '0' || c > '9') { + // Short-circuit - let's not parse as long something that is definitely not a long + // number + return false; + } + long pid = -1; + try { + pid = Long.parseLong(name); + } catch (NumberFormatException ignored) { + } + return pid != -1; + }) + .collect(Collectors.toSet()); + } catch (IOException e) { + log.debug("Unable to obtain Java PIDs", e); + } + return Collections.emptySet(); } - return Collections.emptySet(); - } - private static long parsePid() { - if (!PID.isEmpty()) { - try { - return Long.parseLong(PID); - } catch (NumberFormatException e) { - log.warn("Cannot parse PID {} as number. Default to 0", PID, e); - } + private static long parsePid() { + if (!PID.isEmpty()) { + try { + return Long.parseLong(PID); + } catch (NumberFormatException e) { + log.warn("Cannot parse PID {} as number. Default to 0", PID, e); + } + } + return 0L; } - return 0L; - } } diff --git a/internal-api/src/main/java/datadog/trace/util/ProcessSupervisor.java b/internal-api/src/main/java/datadog/trace/util/ProcessSupervisor.java index 006d921bec9..6c8f5ea4c35 100644 --- a/internal-api/src/main/java/datadog/trace/util/ProcessSupervisor.java +++ b/internal-api/src/main/java/datadog/trace/util/ProcessSupervisor.java @@ -17,138 +17,137 @@ /** Starts an external process and restarts the process if it dies */ public class ProcessSupervisor implements Closeable { - public enum Health { - NEVER_CHECKED, - READY_TO_START, - INTERRUPTED, - FAULTED, - HEALTHY - } - - @FunctionalInterface - public interface HealthCheck { - Health run(Health previousHealth) throws InterruptedException; - } - - public static final HealthCheck ALWAYS_READY = health -> READY_TO_START; - - private static final Logger log = LoggerFactory.getLogger(ProcessSupervisor.class); - - private static final long HEALTHY_DELAY_MILLIS = 10_000; - private static final long FAULTED_DELAY_MILLIS = 2_000; - private static final int MAX_FAULTS = 5; - - private final String imageName; - private final ProcessBuilder processBuilder; - private final HealthCheck healthCheck; - private final Thread supervisorThread; - - private long nextCheckMillis = 0; - private Health currentHealth = Health.NEVER_CHECKED; - private Process currentProcess; - private int faults; - - private volatile boolean stopping = false; - - /** - * @param imageName For logging purposes - * @param processBuilder Builder to create the process - */ - public ProcessSupervisor(String imageName, ProcessBuilder processBuilder) { - this(imageName, processBuilder, ALWAYS_READY); - } - - public ProcessSupervisor( - String imageName, ProcessBuilder processBuilder, HealthCheck healthCheck) { - this.imageName = imageName; - this.processBuilder = processBuilder; - this.healthCheck = healthCheck; - this.supervisorThread = AgentThreadFactory.newAgentThread(PROCESS_SUPERVISOR, this::mainLoop); - this.supervisorThread.start(); - } - - private void mainLoop() { - try { - while (!stopping) { - if (currentHealth == FAULTED && ++faults >= MAX_FAULTS) { - log.warn("Failed to start process [{}] after {} attempts", imageName, faults); - break; - } + public enum Health { + NEVER_CHECKED, + READY_TO_START, + INTERRUPTED, + FAULTED, + HEALTHY + } + + @FunctionalInterface + public interface HealthCheck { + Health run(Health previousHealth) throws InterruptedException; + } + + public static final HealthCheck ALWAYS_READY = health -> READY_TO_START; + + private static final Logger log = LoggerFactory.getLogger(ProcessSupervisor.class); + + private static final long HEALTHY_DELAY_MILLIS = 10_000; + private static final long FAULTED_DELAY_MILLIS = 2_000; + private static final int MAX_FAULTS = 5; + + private final String imageName; + private final ProcessBuilder processBuilder; + private final HealthCheck healthCheck; + private final Thread supervisorThread; + + private long nextCheckMillis = 0; + private Health currentHealth = Health.NEVER_CHECKED; + private Process currentProcess; + private int faults; + + private volatile boolean stopping = false; + + /** + * @param imageName For logging purposes + * @param processBuilder Builder to create the process + */ + public ProcessSupervisor(String imageName, ProcessBuilder processBuilder) { + this(imageName, processBuilder, ALWAYS_READY); + } + + public ProcessSupervisor(String imageName, ProcessBuilder processBuilder, HealthCheck healthCheck) { + this.imageName = imageName; + this.processBuilder = processBuilder; + this.healthCheck = healthCheck; + this.supervisorThread = AgentThreadFactory.newAgentThread(PROCESS_SUPERVISOR, this::mainLoop); + this.supervisorThread.start(); + } + + private void mainLoop() { try { - long delayMillis = nextCheckMillis - System.currentTimeMillis(); - if (delayMillis > 0) { - Thread.sleep(delayMillis); - } - currentHealth = healthCheck.run(currentHealth); - if (currentHealth == READY_TO_START) { - startProcessAndWait(); - } - } catch (InterruptedException e) { - currentHealth = INTERRUPTED; - } catch (Throwable e) { - log.warn("Exception starting process: [{}]", imageName, e); - currentHealth = FAULTED; + while (!stopping) { + if (currentHealth == FAULTED && ++faults >= MAX_FAULTS) { + log.warn("Failed to start process [{}] after {} attempts", imageName, faults); + break; + } + try { + long delayMillis = nextCheckMillis - System.currentTimeMillis(); + if (delayMillis > 0) { + Thread.sleep(delayMillis); + } + currentHealth = healthCheck.run(currentHealth); + if (currentHealth == READY_TO_START) { + startProcessAndWait(); + } + } catch (InterruptedException e) { + currentHealth = INTERRUPTED; + } catch (Throwable e) { + log.warn("Exception starting process: [{}]", imageName, e); + currentHealth = FAULTED; + } + scheduleNextHealthCheck(); + } + } finally { + stopProcess(); } - scheduleNextHealthCheck(); - } - } finally { - stopProcess(); } - } - - private void scheduleNextHealthCheck() { - long now = System.currentTimeMillis(); - if (currentHealth == HEALTHY) { - nextCheckMillis = now + HEALTHY_DELAY_MILLIS; - } else if (currentHealth == FAULTED) { - nextCheckMillis = now + FAULTED_DELAY_MILLIS; - } else { // interrupted - nextCheckMillis = Long.max(nextCheckMillis, now + 100); + + private void scheduleNextHealthCheck() { + long now = System.currentTimeMillis(); + if (currentHealth == HEALTHY) { + nextCheckMillis = now + HEALTHY_DELAY_MILLIS; + } else if (currentHealth == FAULTED) { + nextCheckMillis = now + FAULTED_DELAY_MILLIS; + } else { // interrupted + nextCheckMillis = Long.max(nextCheckMillis, now + 100); + } } - } - - private void startProcessAndWait() throws Exception { - if (currentProcess == null) { - log.debug("Starting process: [{}]", imageName); - try (TraceScope ignored = AgentTracer.get().muteTracing()) { - currentProcess = processBuilder.start(); - } - currentHealth = HEALTHY; - faults = 0; + + private void startProcessAndWait() throws Exception { + if (currentProcess == null) { + log.debug("Starting process: [{}]", imageName); + try (TraceScope ignored = AgentTracer.get().muteTracing()) { + currentProcess = processBuilder.start(); + } + currentHealth = HEALTHY; + faults = 0; + } + + // Block until the process exits + int code = currentProcess.waitFor(); + log.debug("Process [{}] has exited with code {}", imageName, code); + currentHealth = code == 0 ? INTERRUPTED : FAULTED; + + // Process is dead, no longer needs to be tracked + currentProcess = null; } - // Block until the process exits - int code = currentProcess.waitFor(); - log.debug("Process [{}] has exited with code {}", imageName, code); - currentHealth = code == 0 ? INTERRUPTED : FAULTED; - - // Process is dead, no longer needs to be tracked - currentProcess = null; - } - - private void stopProcess() { - if (currentProcess != null) { - log.debug("Stopping process: [{}]", imageName); - currentProcess.destroy(); - if (currentProcess.isAlive()) { - currentProcess.destroyForcibly(); - } - currentProcess = null; + private void stopProcess() { + if (currentProcess != null) { + log.debug("Stopping process: [{}]", imageName); + currentProcess.destroy(); + if (currentProcess.isAlive()) { + currentProcess.destroyForcibly(); + } + currentProcess = null; + } } - } - - @Override - public void close() { - stopping = true; - supervisorThread.interrupt(); - try { - supervisorThread.join(THREAD_JOIN_TIMOUT_MS); - } catch (Throwable ignored) { + + @Override + public void close() { + stopping = true; + supervisorThread.interrupt(); + try { + supervisorThread.join(THREAD_JOIN_TIMOUT_MS); + } catch (Throwable ignored) { + } } - } - @VisibleForTesting - Process getCurrentProcess() { - return currentProcess; - } + @VisibleForTesting + Process getCurrentProcess() { + return currentProcess; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/ProcessUtils.java b/internal-api/src/main/java/datadog/trace/util/ProcessUtils.java index 1a1e41c1f91..a876f2d4ed6 100644 --- a/internal-api/src/main/java/datadog/trace/util/ProcessUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/ProcessUtils.java @@ -10,42 +10,41 @@ public abstract class ProcessUtils { - private static final Logger log = LoggerFactory.getLogger(ProcessUtils.class); + private static final Logger log = LoggerFactory.getLogger(ProcessUtils.class); - /** - * Attempts to get path to the JVM that is used by the current process. - * - *

This is done on the best effort basis: depending on the Java version, vendor, current OS or - * other environment details this method might return either of the following: - * - *

    - *
  • Path to the {@code java} command that was used to start the process - *
  • Path to home dir of the JDK/JRE that the process uses - *
  • {@code null} if neither of the options above is available - *
- * - * @return Path to the {@code java} command, JDK/JRE home, or {@code null} if neither could be - * determined - */ - @Nullable - @SuppressForbidden - public static String getCurrentJvmPath() { - if (JavaVirtualMachine.isJavaVersionAtLeast(9)) { - try { - Supplier jdk9Supplier = - (Supplier) - Class.forName("datadog.trace.util.JDK9ExecutableSupplier") - .getDeclaredConstructor() - .newInstance(); - return jdk9Supplier.get(); - } catch (Throwable e) { - log.debug("Could not get process executable path using JDK9ExecutableSupplier", e); - } - } + /** + * Attempts to get path to the JVM that is used by the current process. + * + *

This is done on the best effort basis: depending on the Java version, vendor, current OS or + * other environment details this method might return either of the following: + * + *

    + *
  • Path to the {@code java} command that was used to start the process + *
  • Path to home dir of the JDK/JRE that the process uses + *
  • {@code null} if neither of the options above is available + *
+ * + * @return Path to the {@code java} command, JDK/JRE home, or {@code null} if neither could be + * determined + */ + @Nullable + @SuppressForbidden + public static String getCurrentJvmPath() { + if (JavaVirtualMachine.isJavaVersionAtLeast(9)) { + try { + Supplier jdk9Supplier = + (Supplier) Class.forName("datadog.trace.util.JDK9ExecutableSupplier") + .getDeclaredConstructor() + .newInstance(); + return jdk9Supplier.get(); + } catch (Throwable e) { + log.debug("Could not get process executable path using JDK9ExecutableSupplier", e); + } + } - // JDK/JRE home, does not include "bin/java" portion - return SystemProperties.get("java.home"); - } + // JDK/JRE home, does not include "bin/java" portion + return SystemProperties.get("java.home"); + } - private ProcessUtils() {} + private ProcessUtils() {} } diff --git a/internal-api/src/main/java/datadog/trace/util/PropagationUtils.java b/internal-api/src/main/java/datadog/trace/util/PropagationUtils.java index 19f08ac7778..1bcbafb16a0 100644 --- a/internal-api/src/main/java/datadog/trace/util/PropagationUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/PropagationUtils.java @@ -6,28 +6,26 @@ import java.util.LinkedHashSet; public class PropagationUtils { - private PropagationUtils() { - // avoid constructing instances of this class. - } + private PropagationUtils() { + // avoid constructing instances of this class. + } - /** The list of the known propagation headers. They must be lowercased. */ - public static final Collection KNOWN_PROPAGATION_HEADERS = - Collections.unmodifiableCollection( - new LinkedHashSet<>( - Arrays.asList( - // W3C headers - "traceparent", - "tracestate", - // DD headers - "x-datadog-trace-id", - "x-datadog-parent-id", - "x-datadog-sampling-priority", - "x-datadog-origin", - "x-datadog-tags", - // B3 single headers - "x-b3-traceid", - "x-b3-spanid", - "x-b3-sampled", - // B3 multi header - "b3"))); + /** The list of the known propagation headers. They must be lowercased. */ + public static final Collection KNOWN_PROPAGATION_HEADERS = + Collections.unmodifiableCollection(new LinkedHashSet<>(Arrays.asList( + // W3C headers + "traceparent", + "tracestate", + // DD headers + "x-datadog-trace-id", + "x-datadog-parent-id", + "x-datadog-sampling-priority", + "x-datadog-origin", + "x-datadog-tags", + // B3 single headers + "x-b3-traceid", + "x-b3-spanid", + "x-b3-sampled", + // B3 multi header + "b3"))); } diff --git a/internal-api/src/main/java/datadog/trace/util/RandomUtils.java b/internal-api/src/main/java/datadog/trace/util/RandomUtils.java index e8ccee710b1..3f9550603bc 100644 --- a/internal-api/src/main/java/datadog/trace/util/RandomUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/RandomUtils.java @@ -6,24 +6,24 @@ import java.util.concurrent.ThreadLocalRandom; public final class RandomUtils { - private RandomUtils() {} + private RandomUtils() {} - /** Returns a random UUID. */ - public static UUID randomUUID() { - Random rnd = ThreadLocalRandom.current(); - long msb = (rnd.nextLong() & 0xffff_ffff_ffff_0fffL) | 0x0000_0000_0000_4000L; - long lsb = (rnd.nextLong() & 0x3fff_ffff_ffff_ffffL) | 0x8000_0000_0000_0000L; - return new UUID(msb, lsb); - } + /** Returns a random UUID. */ + public static UUID randomUUID() { + Random rnd = ThreadLocalRandom.current(); + long msb = (rnd.nextLong() & 0xffff_ffff_ffff_0fffL) | 0x0000_0000_0000_4000L; + long lsb = (rnd.nextLong() & 0x3fff_ffff_ffff_ffffL) | 0x8000_0000_0000_0000L; + return new UUID(msb, lsb); + } - /** - * Returns a cryptographically strong random UUID. - * - *

Note on some systems this may have a side effect of initializing java.util.logging, so its - * use should be avoided during premain. - */ - @SuppressForbidden - public static UUID secureRandomUUID() { - return UUID.randomUUID(); - } + /** + * Returns a cryptographically strong random UUID. + * + *

Note on some systems this may have a side effect of initializing java.util.logging, so its + * use should be avoided during premain. + */ + @SuppressForbidden + public static UUID secureRandomUUID() { + return UUID.randomUUID(); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/SizeCheckedInputStream.java b/internal-api/src/main/java/datadog/trace/util/SizeCheckedInputStream.java index d9319d1fbf4..5be6cd71ac0 100644 --- a/internal-api/src/main/java/datadog/trace/util/SizeCheckedInputStream.java +++ b/internal-api/src/main/java/datadog/trace/util/SizeCheckedInputStream.java @@ -6,50 +6,50 @@ /** Wraps input stream to check a maximum allowed size */ public class SizeCheckedInputStream extends FilterInputStream { - private final long maxSize; - private long currentSize; - - public SizeCheckedInputStream(InputStream in, long maxSize) { - super(in); - this.maxSize = maxSize; - } - - @Override - public int read() throws IOException { - checkSize(1); - int v = super.read(); - if (v != -1) { - updateCurrentSize(1); + private final long maxSize; + private long currentSize; + + public SizeCheckedInputStream(InputStream in, long maxSize) { + super(in); + this.maxSize = maxSize; + } + + @Override + public int read() throws IOException { + checkSize(1); + int v = super.read(); + if (v != -1) { + updateCurrentSize(1); + } + return v; } - return v; - } - - @Override - public int read(byte[] b) throws IOException { - return read(b, 0, b.length); - } - - @Override - public int read(byte[] b, int off, int len) throws IOException { - if (maxSize == currentSize) { - throw new IOException("Reached maximum bytes for this stream: " + maxSize); + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); } - long safeLen = Math.min(len, maxSize - currentSize); - checkSize(safeLen); - return updateCurrentSize(super.read(b, off, (int) safeLen)); - } + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (maxSize == currentSize) { + throw new IOException("Reached maximum bytes for this stream: " + maxSize); + } + + long safeLen = Math.min(len, maxSize - currentSize); + checkSize(safeLen); + return updateCurrentSize(super.read(b, off, (int) safeLen)); + } - private int updateCurrentSize(int delta) { - if (delta > 0) { - currentSize += delta; + private int updateCurrentSize(int delta) { + if (delta > 0) { + currentSize += delta; + } + return delta; } - return delta; - } - private void checkSize(long neededBytes) throws IOException { - if (currentSize + neededBytes > maxSize) { - throw new IOException("Reached maximum bytes for this stream: " + maxSize); + private void checkSize(long neededBytes) throws IOException { + if (currentSize + neededBytes > maxSize) { + throw new IOException("Reached maximum bytes for this stream: " + maxSize); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/StringIndex.java b/internal-api/src/main/java/datadog/trace/util/StringIndex.java index d5f848b6f41..e4ff78ffec0 100644 --- a/internal-api/src/main/java/datadog/trace/util/StringIndex.java +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -49,342 +49,338 @@ * at any factor < 1, so no rehash is needed.) */ public final class StringIndex { - private final int[] hashes; - private final String[] names; - - private StringIndex(int[] hashes, String[] names) { - this.hashes = hashes; - this.names = names; - } - - /** - * Convenience instance — wraps the placed arrays. For the hot path prefer raw {@link - * EmbeddingSupport}. - */ - public static StringIndex of(String... names) { - Data data = EmbeddingSupport.create(names); - return new StringIndex(data.hashes, data.names); - } - - /** - * Slot of {@code name}, or -1. Delegates to {@link EmbeddingSupport} on the instance's arrays. - */ - public int indexOf(String name) { - return EmbeddingSupport.indexOf(this.hashes, this.names, name); - } - - public boolean contains(String name) { - return EmbeddingSupport.indexOf(this.hashes, this.names, name) >= 0; - } - - /** Table size — allocate parallel payload arrays of this length. */ - public int numSlots() { - return hashes.length; - } - - // --- value mapping: build a slot-aligned parallel array (off the hot path) --- - - /** - * Builds a slot-aligned {@code T[]} of values: {@code out[indexOf(name)] == fn.apply(name)} for - * every indexed name; other slots stay {@code null}. {@code type} is the array element type (Java - * can't allocate a generic array without it). Pair with {@link #lookup(Object[], String)}. - */ - public T[] mapValues(Class type, Function fn) { - return EmbeddingSupport.mapValues(this.names, type, fn); - } - - /** Slot-aligned {@code int[]} of values; absent slots stay 0. See {@link #mapValues}. */ - public int[] mapIntValues(ToIntFunction fn) { - return EmbeddingSupport.mapIntValues(this.names, fn); - } - - /** Slot-aligned {@code long[]} of values; absent slots stay 0. See {@link #mapValues}. */ - public long[] mapLongValues(ToLongFunction fn) { - return EmbeddingSupport.mapLongValues(this.names, fn); - } - - // --- lookup: resolve a key and read its parallel value in one call --- - - /** {@code data[indexOf(key)]}, or {@code null} when {@code key} is absent. */ - public T lookup(T[] data, String key) { - return EmbeddingSupport.lookup(this.hashes, this.names, data, key); - } - - /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ - public T lookupOrDefault(T[] data, String key, T defaultValue) { - return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); - } - - /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ - public int lookup(int[] data, String key) { - return EmbeddingSupport.lookup(this.hashes, this.names, data, key); - } - - /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ - public int lookupOrDefault(int[] data, String key, int defaultValue) { - return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); - } - - /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ - public long lookup(long[] data, String key) { - return EmbeddingSupport.lookup(this.hashes, this.names, data, key); - } - - /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ - public long lookupOrDefault(long[] data, String key, long defaultValue) { - return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); - } - - /** Build-time carrier. Pull the fields into your own (static final) fields; don't keep this. */ - public static final class Data { - public final int[] hashes; - public final String[] names; - - Data(int[] hashes, String[] names) { - this.hashes = hashes; - this.names = names; - } - } - - /** - * Static algorithm over raw arrays. Query helpers take raw arrays, never a Data or a StringIndex. - * - *

{@code
-   * private static final int[] METHOD_HASHES;
-   * private static final String[] METHOD_NAMES;
-   * private static final int[] METHOD_IDS;
-   *
-   * static {
-   *   Data data = EmbeddingSupport.create("GET", "POST", "PUT");
-   *   METHOD_HASHES = data.hashes;
-   *   METHOD_NAMES = data.names;
-   *   METHOD_IDS = EmbeddingSupport.mapIntValues(data.names, name -> nextMethodId());
-   * }
-   *
-   * ...
-   * int id = EmbeddingSupport.lookupOrDefault(
-   *     METHOD_HASHES, METHOD_NAMES, METHOD_IDS, incomingMethod, UNKNOWN_METHOD_ID);
-   * }
- */ - public static final class EmbeddingSupport { - private EmbeddingSupport() {} - - /** Spread of String.hashCode; 0 reserved as the empty sentinel. */ - public static int hash(String name) { - int h = name.hashCode(); // cached on String -> field load - return h == 0 ? 0xDD06 : h ^ (h >>> 16); + private final int[] hashes; + private final String[] names; + + private StringIndex(int[] hashes, String[] names) { + this.hashes = hashes; + this.names = names; } /** - * Balanced default load factor — target fill {@code <= 0.5} ({@code >= 2x} capacity). (Mirrors - * {@code FlatHashtable.DEFAULT_LOAD_FACTOR}; duplicated while the two are separate PRs, to be - * unified when the flat-collection family converges.) + * Convenience instance — wraps the placed arrays. For the hot path prefer raw {@link + * EmbeddingSupport}. */ - public static final float DEFAULT_LOAD_FACTOR = 0.5f; - - /** Sparse load factor — target fill {@code <= 0.25} ({@code >= 4x} capacity). */ - public static final float LOW_LOAD_FACTOR = 0.25f; - - /** Largest power-of-two capacity {@link #capacityFor} can return without overflowing. */ - public static final int MAX_CAPACITY = 1 << 30; - - /** Power-of-two capacity for {@code n} names at the {@link #DEFAULT_LOAD_FACTOR}. */ - public static int capacityFor(int n) { - return capacityFor(n, DEFAULT_LOAD_FACTOR); + public static StringIndex of(String... names) { + Data data = EmbeddingSupport.create(names); + return new StringIndex(data.hashes, data.names); } /** - * Power-of-two capacity for {@code n} names at {@code loadFactor}: the smallest power of two - * {@code >= ceil(n / loadFactor)} (so the achieved fill is {@code <= loadFactor}). {@code n == - * 0} yields a minimal 2-slot table (StringIndex allows the empty set, unlike FlatHashtable). - * - * @throws IllegalArgumentException if {@code n} is negative, {@code loadFactor} is not in - * {@code (0, 1)}, or the required capacity exceeds {@link #MAX_CAPACITY} — computing {@code - * ceil(n / loadFactor)} as a {@code double} first (rather than narrowing to {@code int} - * before the check) means a huge result saturates the check instead of silently wrapping to - * a negative array size. + * Slot of {@code name}, or -1. Delegates to {@link EmbeddingSupport} on the instance's arrays. */ - public static int capacityFor(int n, float loadFactor) { - if (n < 0) { - throw new IllegalArgumentException("n must be non-negative: " + n); - } - if (!(loadFactor > 0f && loadFactor < 1f)) { - throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); - } - if (n == 0) { - return 2; // empty set -> minimal table (one always-empty slot suffices, 2 keeps it pow2) - } - double min = Math.ceil(n / (double) loadFactor); - if (min > MAX_CAPACITY) { - throw new IllegalArgumentException( - "capacity for n=" - + n - + " at loadFactor=" - + loadFactor - + " exceeds maximum capacity (" - + MAX_CAPACITY - + ")"); - } - return Integer.highestOneBit((int) min - 1) << 1; + public int indexOf(String name) { + return EmbeddingSupport.indexOf(this.hashes, this.names, name); } - /** Build the placed table. Returns a Data carrier; pull its arrays into your own fields. */ - public static Data create(String... names) { - int size = capacityFor(names.length); - int[] hashes = new int[size]; - String[] placed = new String[size]; - for (String name : names) { - put(hashes, placed, name, hash(name)); - } - return new Data(hashes, placed); + public boolean contains(String name) { + return EmbeddingSupport.indexOf(this.hashes, this.names, name) >= 0; } - /** - * Slot-aligned {@code T[]} over placed {@code names}: {@code out[slot] = fn(name)} per name, - * {@code null} elsewhere. {@code type} is the array element type (generic-array allocation). - */ - @SuppressWarnings("unchecked") - public static T[] mapValues(String[] names, Class type, Function fn) { - T[] out = (T[]) Array.newInstance(type, names.length); - for (int slot = 0; slot < names.length; slot++) { - String name = names[slot]; - if (name != null) { - out[slot] = fn.apply(name); - } - } - return out; + /** Table size — allocate parallel payload arrays of this length. */ + public int numSlots() { + return hashes.length; } - /** - * Slot-aligned {@code int[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. - */ - public static int[] mapIntValues(String[] names, ToIntFunction fn) { - int[] out = new int[names.length]; - for (int slot = 0; slot < names.length; slot++) { - String name = names[slot]; - if (name != null) { - out[slot] = fn.applyAsInt(name); - } - } - return out; - } + // --- value mapping: build a slot-aligned parallel array (off the hot path) --- /** - * Slot-aligned {@code long[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. + * Builds a slot-aligned {@code T[]} of values: {@code out[indexOf(name)] == fn.apply(name)} for + * every indexed name; other slots stay {@code null}. {@code type} is the array element type (Java + * can't allocate a generic array without it). Pair with {@link #lookup(Object[], String)}. */ - public static long[] mapLongValues(String[] names, ToLongFunction fn) { - long[] out = new long[names.length]; - for (int slot = 0; slot < names.length; slot++) { - String name = names[slot]; - if (name != null) { - out[slot] = fn.applyAsLong(name); - } - } - return out; + public T[] mapValues(Class type, Function fn) { + return EmbeddingSupport.mapValues(this.names, type, fn); } - /** - * Build-time placement. Returns the slot. Package-private: only {@link #create} places names. - */ - static int put(int[] hashes, String[] names, String name, int h) { - final int mask = hashes.length - 1; - for (int probes = 0; probes <= mask; probes++) { - int i = (h + probes) & mask; - if (hashes[i] == 0) { - hashes[i] = h; - names[i] = name; - return i; - } - if (hashes[i] == h && names[i].equals(name)) { - return i; // already present - } - } - throw new IllegalStateException("table full"); // impossible at LF <= 0.5 + /** Slot-aligned {@code int[]} of values; absent slots stay 0. See {@link #mapValues}. */ + public int[] mapIntValues(ToIntFunction fn) { + return EmbeddingSupport.mapIntValues(this.names, fn); } - /** - * Probe; returns the slot or -1. Raw arrays — no Data, no instance. - * - * @param h {@code name}'s hash, precomputed by the caller (skips a recomputation when the - * caller already has it, e.g. hashing once to probe several tables). Must be {@link - * #hash(String)}'s exact output for {@code name} — a plain {@code name.hashCode()} (no - * spread) or any other value silently degrades probing or masks a real match as a miss. - * Prefer {@link #indexOf(int[], String[], String)} unless you already have the hash. - */ - public static int indexOf(int[] hashes, String[] names, String name, int h) { - final int mask = hashes.length - 1; - for (int probes = 0; probes <= mask; probes++) { - int i = (h + probes) & mask; - int sh = hashes[i]; - if (sh == 0) { - return -1; - } - if (sh == h && names[i].equals(name)) { - return i; - } - } - return -1; + /** Slot-aligned {@code long[]} of values; absent slots stay 0. See {@link #mapValues}. */ + public long[] mapLongValues(ToLongFunction fn) { + return EmbeddingSupport.mapLongValues(this.names, fn); } - /** - * Probe; returns the slot or -1. Computes {@code name}'s hash for you — prefer this overload - * unless you already have the hash on hand (e.g. from probing another table with it). - */ - public static int indexOf(int[] hashes, String[] names, String name) { - return indexOf(hashes, names, name, hash(name)); - } + // --- lookup: resolve a key and read its parallel value in one call --- - /** - * Mirrors {@link StringIndex#contains}. - * - * @return {@code true} when {@code name} is present in the index - */ - public static boolean contains(int[] hashes, String[] names, String name) { - return indexOf(hashes, names, name) >= 0; + /** {@code data[indexOf(key)]}, or {@code null} when {@code key} is absent. */ + public T lookup(T[] data, String key) { + return EmbeddingSupport.lookup(this.hashes, this.names, data, key); } - /** Number of slots — the length to size parallel payload arrays to. */ - public static int numSlots(int[] hashes) { - return hashes.length; + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public T lookupOrDefault(T[] data, String key, T defaultValue) { + return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); } - /** {@code data[indexOf(...)]}, or {@code null} when {@code key} is absent. */ - public static T lookup(int[] hashes, String[] names, T[] data, String key) { - int slot = indexOf(hashes, names, key); - return slot >= 0 ? data[slot] : null; + /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ + public int lookup(int[] data, String key) { + return EmbeddingSupport.lookup(this.hashes, this.names, data, key); } - /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ - public static T lookupOrDefault( - int[] hashes, String[] names, T[] data, String key, T defaultValue) { - int slot = indexOf(hashes, names, key); - return slot >= 0 ? data[slot] : defaultValue; + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public int lookupOrDefault(int[] data, String key, int defaultValue) { + return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); } - /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ - public static int lookup(int[] hashes, String[] names, int[] data, String key) { - int slot = indexOf(hashes, names, key); - return slot >= 0 ? data[slot] : 0; + /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ + public long lookup(long[] data, String key) { + return EmbeddingSupport.lookup(this.hashes, this.names, data, key); } - /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ - public static int lookupOrDefault( - int[] hashes, String[] names, int[] data, String key, int defaultValue) { - int slot = indexOf(hashes, names, key); - return slot >= 0 ? data[slot] : defaultValue; + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public long lookupOrDefault(long[] data, String key, long defaultValue) { + return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); } - /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ - public static long lookup(int[] hashes, String[] names, long[] data, String key) { - int slot = indexOf(hashes, names, key); - return slot >= 0 ? data[slot] : 0L; + /** Build-time carrier. Pull the fields into your own (static final) fields; don't keep this. */ + public static final class Data { + public final int[] hashes; + public final String[] names; + + Data(int[] hashes, String[] names) { + this.hashes = hashes; + this.names = names; + } } - /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ - public static long lookupOrDefault( - int[] hashes, String[] names, long[] data, String key, long defaultValue) { - int slot = indexOf(hashes, names, key); - return slot >= 0 ? data[slot] : defaultValue; + /** + * Static algorithm over raw arrays. Query helpers take raw arrays, never a Data or a StringIndex. + * + *
{@code
+     * private static final int[] METHOD_HASHES;
+     * private static final String[] METHOD_NAMES;
+     * private static final int[] METHOD_IDS;
+     *
+     * static {
+     *   Data data = EmbeddingSupport.create("GET", "POST", "PUT");
+     *   METHOD_HASHES = data.hashes;
+     *   METHOD_NAMES = data.names;
+     *   METHOD_IDS = EmbeddingSupport.mapIntValues(data.names, name -> nextMethodId());
+     * }
+     *
+     * ...
+     * int id = EmbeddingSupport.lookupOrDefault(
+     *     METHOD_HASHES, METHOD_NAMES, METHOD_IDS, incomingMethod, UNKNOWN_METHOD_ID);
+     * }
+ */ + public static final class EmbeddingSupport { + private EmbeddingSupport() {} + + /** Spread of String.hashCode; 0 reserved as the empty sentinel. */ + public static int hash(String name) { + int h = name.hashCode(); // cached on String -> field load + return h == 0 ? 0xDD06 : h ^ (h >>> 16); + } + + /** + * Balanced default load factor — target fill {@code <= 0.5} ({@code >= 2x} capacity). (Mirrors + * {@code FlatHashtable.DEFAULT_LOAD_FACTOR}; duplicated while the two are separate PRs, to be + * unified when the flat-collection family converges.) + */ + public static final float DEFAULT_LOAD_FACTOR = 0.5f; + + /** Sparse load factor — target fill {@code <= 0.25} ({@code >= 4x} capacity). */ + public static final float LOW_LOAD_FACTOR = 0.25f; + + /** Largest power-of-two capacity {@link #capacityFor} can return without overflowing. */ + public static final int MAX_CAPACITY = 1 << 30; + + /** Power-of-two capacity for {@code n} names at the {@link #DEFAULT_LOAD_FACTOR}. */ + public static int capacityFor(int n) { + return capacityFor(n, DEFAULT_LOAD_FACTOR); + } + + /** + * Power-of-two capacity for {@code n} names at {@code loadFactor}: the smallest power of two + * {@code >= ceil(n / loadFactor)} (so the achieved fill is {@code <= loadFactor}). {@code n == + * 0} yields a minimal 2-slot table (StringIndex allows the empty set, unlike FlatHashtable). + * + * @throws IllegalArgumentException if {@code n} is negative, {@code loadFactor} is not in + * {@code (0, 1)}, or the required capacity exceeds {@link #MAX_CAPACITY} — computing {@code + * ceil(n / loadFactor)} as a {@code double} first (rather than narrowing to {@code int} + * before the check) means a huge result saturates the check instead of silently wrapping to + * a negative array size. + */ + public static int capacityFor(int n, float loadFactor) { + if (n < 0) { + throw new IllegalArgumentException("n must be non-negative: " + n); + } + if (!(loadFactor > 0f && loadFactor < 1f)) { + throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); + } + if (n == 0) { + return 2; // empty set -> minimal table (one always-empty slot suffices, 2 keeps it pow2) + } + double min = Math.ceil(n / (double) loadFactor); + if (min > MAX_CAPACITY) { + throw new IllegalArgumentException("capacity for n=" + + n + + " at loadFactor=" + + loadFactor + + " exceeds maximum capacity (" + + MAX_CAPACITY + + ")"); + } + return Integer.highestOneBit((int) min - 1) << 1; + } + + /** Build the placed table. Returns a Data carrier; pull its arrays into your own fields. */ + public static Data create(String... names) { + int size = capacityFor(names.length); + int[] hashes = new int[size]; + String[] placed = new String[size]; + for (String name : names) { + put(hashes, placed, name, hash(name)); + } + return new Data(hashes, placed); + } + + /** + * Slot-aligned {@code T[]} over placed {@code names}: {@code out[slot] = fn(name)} per name, + * {@code null} elsewhere. {@code type} is the array element type (generic-array allocation). + */ + @SuppressWarnings("unchecked") + public static T[] mapValues(String[] names, Class type, Function fn) { + T[] out = (T[]) Array.newInstance(type, names.length); + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.apply(name); + } + } + return out; + } + + /** + * Slot-aligned {@code int[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. + */ + public static int[] mapIntValues(String[] names, ToIntFunction fn) { + int[] out = new int[names.length]; + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.applyAsInt(name); + } + } + return out; + } + + /** + * Slot-aligned {@code long[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. + */ + public static long[] mapLongValues(String[] names, ToLongFunction fn) { + long[] out = new long[names.length]; + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.applyAsLong(name); + } + } + return out; + } + + /** + * Build-time placement. Returns the slot. Package-private: only {@link #create} places names. + */ + static int put(int[] hashes, String[] names, String name, int h) { + final int mask = hashes.length - 1; + for (int probes = 0; probes <= mask; probes++) { + int i = (h + probes) & mask; + if (hashes[i] == 0) { + hashes[i] = h; + names[i] = name; + return i; + } + if (hashes[i] == h && names[i].equals(name)) { + return i; // already present + } + } + throw new IllegalStateException("table full"); // impossible at LF <= 0.5 + } + + /** + * Probe; returns the slot or -1. Raw arrays — no Data, no instance. + * + * @param h {@code name}'s hash, precomputed by the caller (skips a recomputation when the + * caller already has it, e.g. hashing once to probe several tables). Must be {@link + * #hash(String)}'s exact output for {@code name} — a plain {@code name.hashCode()} (no + * spread) or any other value silently degrades probing or masks a real match as a miss. + * Prefer {@link #indexOf(int[], String[], String)} unless you already have the hash. + */ + public static int indexOf(int[] hashes, String[] names, String name, int h) { + final int mask = hashes.length - 1; + for (int probes = 0; probes <= mask; probes++) { + int i = (h + probes) & mask; + int sh = hashes[i]; + if (sh == 0) { + return -1; + } + if (sh == h && names[i].equals(name)) { + return i; + } + } + return -1; + } + + /** + * Probe; returns the slot or -1. Computes {@code name}'s hash for you — prefer this overload + * unless you already have the hash on hand (e.g. from probing another table with it). + */ + public static int indexOf(int[] hashes, String[] names, String name) { + return indexOf(hashes, names, name, hash(name)); + } + + /** + * Mirrors {@link StringIndex#contains}. + * + * @return {@code true} when {@code name} is present in the index + */ + public static boolean contains(int[] hashes, String[] names, String name) { + return indexOf(hashes, names, name) >= 0; + } + + /** Number of slots — the length to size parallel payload arrays to. */ + public static int numSlots(int[] hashes) { + return hashes.length; + } + + /** {@code data[indexOf(...)]}, or {@code null} when {@code key} is absent. */ + public static T lookup(int[] hashes, String[] names, T[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : null; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static T lookupOrDefault(int[] hashes, String[] names, T[] data, String key, T defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + + /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ + public static int lookup(int[] hashes, String[] names, int[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : 0; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static int lookupOrDefault(int[] hashes, String[] names, int[] data, String key, int defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + + /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ + public static long lookup(int[] hashes, String[] names, long[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : 0L; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static long lookupOrDefault(int[] hashes, String[] names, long[] data, String key, long defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/Strings.java b/internal-api/src/main/java/datadog/trace/util/Strings.java index 7bd2055f32c..9d41883fe48 100644 --- a/internal-api/src/main/java/datadog/trace/util/Strings.java +++ b/internal-api/src/main/java/datadog/trace/util/Strings.java @@ -14,390 +14,390 @@ public final class Strings { - private static final byte[] HEX_DIGITS = { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' - }; - - /** com.foo.Bar -> com/foo/Bar.class */ - public static String getResourceName(final String className) { - if (!className.endsWith(".class")) { - return className.replace('.', '/') + ".class"; - } else { - return className; + private static final byte[] HEX_DIGITS = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + + /** com.foo.Bar -> com/foo/Bar.class */ + public static String getResourceName(final String className) { + if (!className.endsWith(".class")) { + return className.replace('.', '/') + ".class"; + } else { + return className; + } } - } - /** com/foo/Bar.class -> com.foo.Bar */ - public static String getClassName(final String resourceName) { - if (resourceName.endsWith(".class")) { - return resourceName.substring(0, resourceName.length() - 6).replace('/', '.'); + /** com/foo/Bar.class -> com.foo.Bar */ + public static String getClassName(final String resourceName) { + if (resourceName.endsWith(".class")) { + return resourceName.substring(0, resourceName.length() - 6).replace('/', '.'); + } + return resourceName.replace('/', '.'); } - return resourceName.replace('/', '.'); - } - - /** com.foo.Bar -> com/foo/Bar */ - public static String getInternalName(final String className) { - return className.replace('.', '/'); - } - - /** com.foo.Bar -> com.foo */ - public static String getPackageName(final String className) { - int lastDot = className.lastIndexOf('.'); - return lastDot < 0 ? "" : className.substring(0, lastDot); - } - - /** com.foo.Bar -> Bar */ - public static String getSimpleName(final String className) { - return className.substring(className.lastIndexOf('.') + 1); - } - - // reimplementation of string functions without regex - public static String replace(String str, String delimiter, String replacement) { - StringBuilder sb = new StringBuilder(str); - int matchIndex, curIndex = 0; - while ((matchIndex = sb.indexOf(delimiter, curIndex)) != -1) { - sb.replace(matchIndex, matchIndex + delimiter.length(), replacement); - curIndex = matchIndex + replacement.length(); - } - return sb.toString(); - } - - public static String replaceFirst(String str, String delimiter, String replacement) { - StringBuilder sb = new StringBuilder(str); - int i = sb.indexOf(delimiter); - if (i != -1) { - sb.replace(i, i + delimiter.length(), replacement); - } - return sb.toString(); - } - - public static String sha256(String input) throws NoSuchAlgorithmException { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); - StringBuilder hexString = new StringBuilder(2 * hash.length); - for (byte b : hash) { - String hex = Integer.toHexString(0xFF & b); - if (hex.length() == 1) { - hexString.append('0'); - } - hexString.append(hex); - } - return hexString.toString(); - } - public static String truncate(String input, int limit) { - return (String) truncate((CharSequence) input, limit); - } + /** com.foo.Bar -> com/foo/Bar */ + public static String getInternalName(final String className) { + return className.replace('.', '/'); + } - public static CharSequence truncate(CharSequence input, int limit) { - if (input == null || input.length() <= limit) { - return input; + /** com.foo.Bar -> com.foo */ + public static String getPackageName(final String className) { + int lastDot = className.lastIndexOf('.'); + return lastDot < 0 ? "" : className.substring(0, lastDot); } - return input.subSequence(0, limit); - } - - /** - * Truncates a pre-encoded {@link UTF8BytesString}. Returns the same instance when within the - * limit, so callers writing it back out keep the zero-copy fast path; only the rare over-limit - * case re-encodes the truncated value. - */ - public static UTF8BytesString truncate(UTF8BytesString input, int limit) { - if (input == null || input.length() <= limit) { - return input; + + /** com.foo.Bar -> Bar */ + public static String getSimpleName(final String className) { + return className.substring(className.lastIndexOf('.') + 1); } - return UTF8BytesString.create(input.subSequence(0, limit)); - } - - /** - * Checks that a string is not blank, i.e. contains at least one character that is not a - * whitespace - * - * @param s The string to be checked - * @return {@code true} if string is not blank, {@code false} otherwise (string is {@code null}, - * empty, or contains only whitespace characters) - */ - public static boolean isNotBlank(String s) { - if (s == null || s.isEmpty()) { - return false; + + // reimplementation of string functions without regex + public static String replace(String str, String delimiter, String replacement) { + StringBuilder sb = new StringBuilder(str); + int matchIndex, curIndex = 0; + while ((matchIndex = sb.indexOf(delimiter, curIndex)) != -1) { + sb.replace(matchIndex, matchIndex + delimiter.length(), replacement); + curIndex = matchIndex + replacement.length(); + } + return sb.toString(); } - // the code below traverses string characters one by one - // and checks if there is any character that is not a whitespace (space, tab, newline, etc); - final int length = s.length(); - for (int offset = 0; offset < length; ) { - // codepoints are used instead of chars, to properly handle non-unicode symbols - final int codepoint = s.codePointAt(offset); - if (!Character.isWhitespace(codepoint)) { - return true; - } - offset += Character.charCount(codepoint); + public static String replaceFirst(String str, String delimiter, String replacement) { + StringBuilder sb = new StringBuilder(str); + int i = sb.indexOf(delimiter); + if (i != -1) { + sb.replace(i, i + delimiter.length(), replacement); + } + return sb.toString(); } - return false; - } - - /** - * Checks that a string is blank, i.e. doest not contain characters or is null - * - * @param s The string to be checked - * @return {@code true} if string is blank (string is {@code null}, empty, or contains only - * whitespace characters), {@code false} otherwise - */ - public static boolean isBlank(String s) { - return !isNotBlank(s); - } - - /** - * Generates a random string of the given length from lowercase characters a-z - * - * @param length length of the string - * @return random string containing lowercase latin characters - */ - public static String random(int length) { - char[] c = new char[length]; - for (int i = 0; i < length; i++) { - c[i] = (char) ('a' + ThreadLocalRandom.current().nextInt(26)); + public static String sha256(String input) throws NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(2 * hash.length); + for (byte b : hash) { + String hex = Integer.toHexString(0xFF & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); } - return new String(c); - } - public static String toHexString(byte[] value) { - if (value == null) { - return null; + public static String truncate(String input, int limit) { + return (String) truncate((CharSequence) input, limit); } - byte[] bytes = new byte[value.length * 2]; - for (int i = 0; i < value.length; i++) { - byte v = value[i]; - bytes[i * 2] = HEX_DIGITS[(v & 0xF0) >>> 4]; - bytes[i * 2 + 1] = HEX_DIGITS[v & 0x0F]; + + public static CharSequence truncate(CharSequence input, int limit) { + if (input == null || input.length() <= limit) { + return input; + } + return input.subSequence(0, limit); } - return new String(bytes, US_ASCII); - } - - public static String[] concat(String[] arr, String... extra) { - if (arr.length == 0) return extra; - if (extra.length == 0) return arr; - String[] result = new String[arr.length + extra.length]; - System.arraycopy(arr, 0, result, 0, arr.length); - System.arraycopy(extra, 0, result, arr.length, extra.length); - return result; - } - - /** - * @return first non-blank string out of the two, {@code null} if both are blank - */ - @Nullable - public static String coalesce(@Nullable final String first, @Nullable final String second) { - if (isNotBlank(first)) { - return first; - } else if (isNotBlank(second)) { - return second; - } else { - return null; + + /** + * Truncates a pre-encoded {@link UTF8BytesString}. Returns the same instance when within the + * limit, so callers writing it back out keep the zero-copy fast path; only the rare over-limit + * case re-encodes the truncated value. + */ + public static UTF8BytesString truncate(UTF8BytesString input, int limit) { + if (input == null || input.length() <= limit) { + return input; + } + return UTF8BytesString.create(input.subSequence(0, limit)); } - } - /** Low overhead replaceAll */ - public static final String replaceAll(String input, String needle, String replacement) { - int index = input.indexOf(needle); - if (index == -1) return input; + /** + * Checks that a string is not blank, i.e. contains at least one character that is not a + * whitespace + * + * @param s The string to be checked + * @return {@code true} if string is not blank, {@code false} otherwise (string is {@code null}, + * empty, or contains only whitespace characters) + */ + public static boolean isNotBlank(String s) { + if (s == null || s.isEmpty()) { + return false; + } + + // the code below traverses string characters one by one + // and checks if there is any character that is not a whitespace (space, tab, newline, etc); + final int length = s.length(); + for (int offset = 0; offset < length; ) { + // codepoints are used instead of chars, to properly handle non-unicode symbols + final int codepoint = s.codePointAt(offset); + if (!Character.isWhitespace(codepoint)) { + return true; + } + offset += Character.charCount(codepoint); + } + + return false; + } - int needleLen = needle.length(); + /** + * Checks that a string is blank, i.e. doest not contain characters or is null + * + * @param s The string to be checked + * @return {@code true} if string is blank (string is {@code null}, empty, or contains only + * whitespace characters), {@code false} otherwise + */ + public static boolean isBlank(String s) { + return !isNotBlank(s); + } - StringBuilder builder = new StringBuilder(input.length() + 10); - builder.append(input, 0, index); - builder.append(replacement); + /** + * Generates a random string of the given length from lowercase characters a-z + * + * @param length length of the string + * @return random string containing lowercase latin characters + */ + public static String random(int length) { + char[] c = new char[length]; + for (int i = 0; i < length; i++) { + c[i] = (char) ('a' + ThreadLocalRandom.current().nextInt(26)); + } + return new String(c); + } - int prevIndex = index; - index = input.indexOf(needle, index + needleLen); - for (; index != -1; prevIndex = index, index = input.indexOf(needle, index + needleLen)) { - builder.append(input, prevIndex + needleLen, index); - builder.append(replacement); + public static String toHexString(byte[] value) { + if (value == null) { + return null; + } + byte[] bytes = new byte[value.length * 2]; + for (int i = 0; i < value.length; i++) { + byte v = value[i]; + bytes[i * 2] = HEX_DIGITS[(v & 0xF0) >>> 4]; + bytes[i * 2 + 1] = HEX_DIGITS[v & 0x0F]; + } + return new String(bytes, US_ASCII); } - builder.append(input, prevIndex + needleLen, input.length()); - - return builder.toString(); - } - - /** - * Provides a SubSequence which a view into the provided String Unlike String.subSequence (which - * is usually just a wrapper around String.substring), this routine doesn't allocate a new String - * or byte[]/char[]. - */ - public static final SubSequence subSequence(String str, int beginIndex) { - return new SubSequence(str, beginIndex, str.length()); - } - - /** - * Provides a SubSequence which a view into the provided String Unlike String.subSequence (which - * is usually just a wrapper around String.substring), this routine doesn't allocate a new - * String or byte[] / char[]. - */ - public static final SubSequence subSequence(String str, int beginIndex, int endIndex) { - return new SubSequence(str, beginIndex, endIndex); - } - - /** - * Provides an Iterable where the sub-sequences are separated by splitChar - * . Unlike other approaches to splitting, this routine doesn't allocate any new - * String or byte[] / char[] - */ - public static final Iterable split(String str, char splitChar) { - if (str.isEmpty()) { - return Collections.emptyList(); + + public static String[] concat(String[] arr, String... extra) { + if (arr.length == 0) return extra; + if (extra.length == 0) return arr; + String[] result = new String[arr.length + extra.length]; + System.arraycopy(arr, 0, result, 0, arr.length); + System.arraycopy(extra, 0, result, arr.length, extra.length); + return result; } - int firstIndex = str.indexOf(splitChar); - if (firstIndex == -1) { - return Collections.singletonList(subSequence(str, 0)); + /** + * @return first non-blank string out of the two, {@code null} if both are blank + */ + @Nullable + public static String coalesce(@Nullable final String first, @Nullable final String second) { + if (isNotBlank(first)) { + return first; + } else if (isNotBlank(second)) { + return second; + } else { + return null; + } } - return new SplitIterable(str, splitChar, firstIndex); - } + /** Low overhead replaceAll */ + public static final String replaceAll(String input, String needle, String replacement) { + int index = input.indexOf(needle); + if (index == -1) return input; - static final class SplitIterable implements Iterable { - private final String str; - private final int len; - private final char splitChar; - private final int firstIndex; + int needleLen = needle.length(); - SplitIterable(String str, char splitChar, int firstIndex) { - this.str = str; - this.len = str.length(); - this.splitChar = splitChar; - this.firstIndex = firstIndex; - } + StringBuilder builder = new StringBuilder(input.length() + 10); + builder.append(input, 0, index); + builder.append(replacement); + + int prevIndex = index; + index = input.indexOf(needle, index + needleLen); + for (; index != -1; prevIndex = index, index = input.indexOf(needle, index + needleLen)) { + builder.append(input, prevIndex + needleLen, index); + builder.append(replacement); + } + builder.append(input, prevIndex + needleLen, input.length()); - @Override - public SplitIterator iterator() { - return new SplitIterator(this.str, this.len, this.splitChar, this.firstIndex); + return builder.toString(); } - } - static final class SplitIterator implements Iterator { - private final String str; - private final int len; - private final char splitChar; + /** + * Provides a SubSequence which a view into the provided String Unlike String.subSequence (which + * is usually just a wrapper around String.substring), this routine doesn't allocate a new String + * or byte[]/char[]. + */ + public static final SubSequence subSequence(String str, int beginIndex) { + return new SubSequence(str, beginIndex, str.length()); + } - private int curIndex; - private int nextIndex; + /** + * Provides a SubSequence which a view into the provided String Unlike String.subSequence (which + * is usually just a wrapper around String.substring), this routine doesn't allocate a new + * String or byte[] / char[]. + */ + public static final SubSequence subSequence(String str, int beginIndex, int endIndex) { + return new SubSequence(str, beginIndex, endIndex); + } - SplitIterator(String str, int len, char splitChar, int firstIndex) { - this.str = str; - this.len = len; - this.splitChar = splitChar; + /** + * Provides an Iterable where the sub-sequences are separated by splitChar + * . Unlike other approaches to splitting, this routine doesn't allocate any new + * String or byte[] / char[] + */ + public static final Iterable split(String str, char splitChar) { + if (str.isEmpty()) { + return Collections.emptyList(); + } + + int firstIndex = str.indexOf(splitChar); + if (firstIndex == -1) { + return Collections.singletonList(subSequence(str, 0)); + } + + return new SplitIterable(str, splitChar, firstIndex); + } - this.curIndex = 0; - this.nextIndex = firstIndex == -1 ? len : firstIndex; + static final class SplitIterable implements Iterable { + private final String str; + private final int len; + private final char splitChar; + private final int firstIndex; + + SplitIterable(String str, char splitChar, int firstIndex) { + this.str = str; + this.len = str.length(); + this.splitChar = splitChar; + this.firstIndex = firstIndex; + } + + @Override + public SplitIterator iterator() { + return new SplitIterator(this.str, this.len, this.splitChar, this.firstIndex); + } } - @Override - public boolean hasNext() { - return (this.curIndex <= this.len); + static final class SplitIterator implements Iterator { + private final String str; + private final int len; + private final char splitChar; + + private int curIndex; + private int nextIndex; + + SplitIterator(String str, int len, char splitChar, int firstIndex) { + this.str = str; + this.len = len; + this.splitChar = splitChar; + + this.curIndex = 0; + this.nextIndex = firstIndex == -1 ? len : firstIndex; + } + + @Override + public boolean hasNext() { + return (this.curIndex <= this.len); + } + + @Override + public SubSequence next() { + int curIndex = this.curIndex; + int len = this.len; + + if (curIndex > len) throw new NoSuchElementException(); + + // NOTE: Experimented with returning a single mutable SubSequence + // where the index range is updated each time. In typical usage, + // that was slightly worse -- likely because escape analysis was + // able to eliminate the allocation, but that hasn't been directly + // confirmed. + SubSequence subSeq; + + int nextIndex = this.nextIndex; + if (nextIndex == len - 1) { + // Handles the case where there's a trailing separator, + // curIndex is moved to len to represent the empty string + // after the trailing separator + + // Next call then goes into the special case below + subSeq = new SubSequence(this.str, curIndex, nextIndex); + this.curIndex = len; + this.nextIndex = len; + } else if (curIndex == len) { + // Handles the empty string after the trailing separator + // curIndex is given the terminating value `len + 1` + + // Don't use SubSequence.EMPTY because it wouldn't have + // the correct beginIndex + subSeq = new SubSequence(this.str, len, len); + this.curIndex = len + 1; + } else { + subSeq = new SubSequence(this.str, curIndex, nextIndex); + + // core advancing logic + this.curIndex = nextIndex + 1; + int searchIndex = this.str.indexOf(this.splitChar, nextIndex + 1); + this.nextIndex = (searchIndex == -1) ? len : searchIndex; + } + + return subSeq; + } } - @Override - public SubSequence next() { - int curIndex = this.curIndex; - int len = this.len; - - if (curIndex > len) throw new NoSuchElementException(); - - // NOTE: Experimented with returning a single mutable SubSequence - // where the index range is updated each time. In typical usage, - // that was slightly worse -- likely because escape analysis was - // able to eliminate the allocation, but that hasn't been directly - // confirmed. - SubSequence subSeq; - - int nextIndex = this.nextIndex; - if (nextIndex == len - 1) { - // Handles the case where there's a trailing separator, - // curIndex is moved to len to represent the empty string - // after the trailing separator - - // Next call then goes into the special case below - subSeq = new SubSequence(this.str, curIndex, nextIndex); - this.curIndex = len; - this.nextIndex = len; - } else if (curIndex == len) { - // Handles the empty string after the trailing separator - // curIndex is given the terminating value `len + 1` - - // Don't use SubSequence.EMPTY because it wouldn't have - // the correct beginIndex - subSeq = new SubSequence(this.str, len, len); - this.curIndex = len + 1; - } else { - subSeq = new SubSequence(this.str, curIndex, nextIndex); - - // core advancing logic - this.curIndex = nextIndex + 1; - int searchIndex = this.str.indexOf(this.splitChar, nextIndex + 1); - this.nextIndex = (searchIndex == -1) ? len : searchIndex; - } - - return subSeq; + /** + * True if {@code needle} occurs fully within {@code s[beginIndex, endIndex)} -- a range-limited, + * allocation-free alternative to {@code s.substring(beginIndex, endIndex).contains(needle)}. + * + *

{@code indexOf} returns the earliest occurrence at or after {@code beginIndex}; if that one + * overshoots {@code endIndex} there is no earlier full occurrence in range, so the bound check is + * exact. + */ + public static boolean regionContains(String s, int beginIndex, int endIndex, String needle) { + int idx = s.indexOf(needle, beginIndex); + return idx >= 0 && idx + needle.length() <= endIndex; } - } - - /** - * True if {@code needle} occurs fully within {@code s[beginIndex, endIndex)} -- a range-limited, - * allocation-free alternative to {@code s.substring(beginIndex, endIndex).contains(needle)}. - * - *

{@code indexOf} returns the earliest occurrence at or after {@code beginIndex}; if that one - * overshoots {@code endIndex} there is no earlier full occurrence in range, so the bound check is - * exact. - */ - public static boolean regionContains(String s, int beginIndex, int endIndex, String needle) { - int idx = s.indexOf(needle, beginIndex); - return idx >= 0 && idx + needle.length() <= endIndex; - } - - /** - * A {@code hashCode} consistent with {@link String#equalsIgnoreCase}: any two strings that are - * equal ignoring case produce the same value. Same polynomial as {@link String#hashCode} but over - * the case-folded characters, so it never allocates (no {@code toLowerCase} copy). - * - *

Uses the same two-way fold {@code String.equalsIgnoreCase} / {@code - * String.regionMatches(ignoreCase)} use ({@code toLowerCase(toUpperCase(c))}), so the two stay - * consistent for all inputs, not just ASCII — pairing a one-way fold here with {@code - * equalsIgnoreCase} would risk silent false misses on the Unicode characters where they diverge. - * - *

Folds by code point, not by {@code char} (UTF-16 unit). On JDK 9+, {@code equalsIgnoreCase} - * itself reconstructs supplementary code points before folding (e.g. it treats U+10400 and - * U+10428 as equal), so a per-{@code char} fold would make this hash inconsistent with it for - * such pairs -- a supplementary-plane analog of the same silent-false-miss risk called out above. - * A per-{@code char} fold matched {@code equalsIgnoreCase} only on JDK 8, where it did not - * reconstruct code points; folding by code point keeps the two consistent on every supported JDK. - * (On JDK 8, where {@code equalsIgnoreCase} still treats such pairs as distinct, folding them - * together here is merely an extra, harmless collision -- consistency only needs to hold in the - * equal-hash direction, not the reverse.) - */ - public static int caseInsensitiveHashCode(String s) { - int h = 0; - final int len = s.length(); - for (int i = 0; i < len; ) { - final char c = s.charAt(i); - if (c < 0x80) { - // ASCII fast path: case folding is exactly A-Z -> a-z, and every ASCII char is its own - // code point, so this is equivalent to (and cheaper than) the code-point fold below. - h = 31 * h + ((c >= 'A' && c <= 'Z') ? (c + 32) : c); - ++i; - continue; - } - final int cp = s.codePointAt(i); - final int folded = Character.toLowerCase(Character.toUpperCase(cp)); - if (Character.isSupplementaryCodePoint(folded)) { - // Fold back into the two chars equalsIgnoreCase itself compares, so the polynomial stays - // over the same "characters" for both single- and multi-char inputs. - h = 31 * h + Character.highSurrogate(folded); - h = 31 * h + Character.lowSurrogate(folded); - } else { - h = 31 * h + folded; - } - i += Character.charCount(cp); + + /** + * A {@code hashCode} consistent with {@link String#equalsIgnoreCase}: any two strings that are + * equal ignoring case produce the same value. Same polynomial as {@link String#hashCode} but over + * the case-folded characters, so it never allocates (no {@code toLowerCase} copy). + * + *

Uses the same two-way fold {@code String.equalsIgnoreCase} / {@code + * String.regionMatches(ignoreCase)} use ({@code toLowerCase(toUpperCase(c))}), so the two stay + * consistent for all inputs, not just ASCII — pairing a one-way fold here with {@code + * equalsIgnoreCase} would risk silent false misses on the Unicode characters where they diverge. + * + *

Folds by code point, not by {@code char} (UTF-16 unit). On JDK 9+, {@code equalsIgnoreCase} + * itself reconstructs supplementary code points before folding (e.g. it treats U+10400 and + * U+10428 as equal), so a per-{@code char} fold would make this hash inconsistent with it for + * such pairs -- a supplementary-plane analog of the same silent-false-miss risk called out above. + * A per-{@code char} fold matched {@code equalsIgnoreCase} only on JDK 8, where it did not + * reconstruct code points; folding by code point keeps the two consistent on every supported JDK. + * (On JDK 8, where {@code equalsIgnoreCase} still treats such pairs as distinct, folding them + * together here is merely an extra, harmless collision -- consistency only needs to hold in the + * equal-hash direction, not the reverse.) + */ + public static int caseInsensitiveHashCode(String s) { + int h = 0; + final int len = s.length(); + for (int i = 0; i < len; ) { + final char c = s.charAt(i); + if (c < 0x80) { + // ASCII fast path: case folding is exactly A-Z -> a-z, and every ASCII char is its own + // code point, so this is equivalent to (and cheaper than) the code-point fold below. + h = 31 * h + ((c >= 'A' && c <= 'Z') ? (c + 32) : c); + ++i; + continue; + } + final int cp = s.codePointAt(i); + final int folded = Character.toLowerCase(Character.toUpperCase(cp)); + if (Character.isSupplementaryCodePoint(folded)) { + // Fold back into the two chars equalsIgnoreCase itself compares, so the polynomial stays + // over the same "characters" for both single- and multi-char inputs. + h = 31 * h + Character.highSurrogate(folded); + h = 31 * h + Character.lowSurrogate(folded); + } else { + h = 31 * h + folded; + } + i += Character.charCount(cp); + } + return h; } - return h; - } } diff --git a/internal-api/src/main/java/datadog/trace/util/SubSequence.java b/internal-api/src/main/java/datadog/trace/util/SubSequence.java index 1f9405d823d..cf7ea190ec8 100644 --- a/internal-api/src/main/java/datadog/trace/util/SubSequence.java +++ b/internal-api/src/main/java/datadog/trace/util/SubSequence.java @@ -22,261 +22,261 @@ */ @NoEscape public final class SubSequence implements CharSequence { - // @NoEscape exemption: backed by the interned "" literal, which is already permanently - // retained by the JVM -- holding this instance pins nothing beyond what's already immortal. - public static final SubSequence EMPTY = new SubSequence("", 0, 0); - - /** - * SubSequence from beginIndex to end of str Equivalent to - * str.subSequence(str, startIndex) - */ - public static final SubSequence of(String str, int startIndex) { - return new SubSequence(str, startIndex, str.length()); - } - - /** - * SubSequence from beginIndex inclusive to endIndex exclusive of - * str Equivalent to str.subSequence(str, startIndex, endIndex) - * - *

Unlike {@code String.substring}, this always allocates a view, even when startIndex - * == endIndex -- there is no free empty case here, because returning {@link #EMPTY} - * instead would mean this factory sometimes returns the singleton and sometimes a fresh instance, - * which defeats escape analysis at the call site (see {@link NoEscape}). {@code String.substring} - * pays no such cost for an empty range: it returns the interned {@code ""} literal - * unconditionally, independent of whether escape analysis succeeds. So on an empty range - * specifically, this is a real allocation with no offsetting copy avoided; use {@link #EMPTY} - * directly when the range is known to be empty. - */ - public static final SubSequence of(String str, int startIndex, int endIndex) { - return new SubSequence(str, startIndex, endIndex); - } - - private final String str; - private final int beginIndex; - private final int endIndex; - - private String cachedSubstr = null; - - SubSequence(String str, int startIndex, int endIndex) { - this.str = str; - this.beginIndex = startIndex; - this.endIndex = endIndex; - } - - /** Beginning index of the subseqence in the backing String - can be useful in text processing */ - public int beginIndex() { - return this.beginIndex; - } - - /** Ending index of the subsequence in the backing String - can be useful in text processing */ - public int endIndex() { - return this.endIndex; - } - - @Override - public char charAt(int index) { - return this.str.charAt(this.beginIndex + index); - } - - @Override - public int length() { - return this.endIndex - this.beginIndex; - } - - @Override - public SubSequence subSequence(int start, int end) { - // start/end are offsets in THIS view's coordinates (CharSequence contract), so the absolute - // end is beginIndex + end -- NOT beginIndex + start + end (which overshoots by `start`). - int newBeginIndex = this.beginIndex + start; - int newEndIndex = this.beginIndex + end; - - return new SubSequence(this.str, newBeginIndex, newEndIndex); - } - - /** Appends this SubSequence to the StringBuilder Equivalent to builder.append(this) but faster */ - public void appendTo(StringBuilder builder) { - int beginIndex = this.beginIndex; - int endIndex = this.endIndex; - - // Guards against the special case empty SubSequence at this.str.length - if (beginIndex != endIndex) builder.append(this.str, beginIndex, endIndex); - } - - /** - * The same value as {@code toString().hashCode()} -- the {@link String} hash polynomial over this - * window -- but computed directly over the backing characters so hashing a view does not - * materialize a substring. Stays consistent with {@link #equals}: a view, its content-equal - * {@code String}, and an equal-content view all share this hash. - */ - @Override - public int hashCode() { - int h = 0; - for (int i = this.beginIndex; i < this.endIndex; ++i) { - h = 31 * h + this.str.charAt(i); + // @NoEscape exemption: backed by the interned "" literal, which is already permanently + // retained by the JVM -- holding this instance pins nothing beyond what's already immortal. + public static final SubSequence EMPTY = new SubSequence("", 0, 0); + + /** + * SubSequence from beginIndex to end of str Equivalent to + * str.subSequence(str, startIndex) + */ + public static final SubSequence of(String str, int startIndex) { + return new SubSequence(str, startIndex, str.length()); } - return h; - } - - /** - * Dispatches on the argument's runtime type: a {@code String} takes the {@link #equals(String)} - * region-compare fast path; any other {@code CharSequence} is compared via {@link - * #contentEquals(CharSequence)}. So {@code this.equals(backingStr.substring(beginIndex, - * endIndex))} is true, and two views with equal content are equal. - */ - // Intentionally asymmetric: a SubSequence equals any String/CharSequence with the same content, - // so subSeq.equals(str) can be true while str.equals(subSeq) is false. This view-equality - // convenience is by design (see class javadoc); accepted despite the equals() symmetry contract. - @Override - @SuppressFBWarnings("EQ_CHECK_FOR_OPERAND_NOT_COMPATIBLE_WITH_THIS") - public boolean equals(Object obj) { - if (obj instanceof String) return this.equals((String) obj); - if (obj instanceof CharSequence) return this.contentEquals((CharSequence) obj); - - return false; - } - - /** - * Equivalent to {@code toString().equals(other)}. A thin alias for {@link - * #contentEquals(String)}, kept for callers working in terms of {@code equals}; prefer {@link - * #contentEquals(String)} directly. - */ - public final boolean equals(String other) { - return this.contentEquals(other); - } - - /** - * Equivalent to {@code toString().contentEquals(that)}: true when {@code that} has the same - * length and characters as this window. The general char-by-char comparison for any {@code - * CharSequence}; prefer {@link #contentEquals(String)} when the argument is known to be a {@code - * String}. - */ - public final boolean contentEquals(CharSequence that) { - if (that == null) return false; - - int len = this.length(); - if (len != that.length()) return false; - - for (int i = 0; i < len; ++i) { - if (this.charAt(i) != that.charAt(i)) return false; + + /** + * SubSequence from beginIndex inclusive to endIndex exclusive of + * str Equivalent to str.subSequence(str, startIndex, endIndex) + * + *

Unlike {@code String.substring}, this always allocates a view, even when startIndex + * == endIndex -- there is no free empty case here, because returning {@link #EMPTY} + * instead would mean this factory sometimes returns the singleton and sometimes a fresh instance, + * which defeats escape analysis at the call site (see {@link NoEscape}). {@code String.substring} + * pays no such cost for an empty range: it returns the interned {@code ""} literal + * unconditionally, independent of whether escape analysis succeeds. So on an empty range + * specifically, this is a real allocation with no offsetting copy avoided; use {@link #EMPTY} + * directly when the range is known to be empty. + */ + public static final SubSequence of(String str, int startIndex, int endIndex) { + return new SubSequence(str, startIndex, endIndex); + } + + private final String str; + private final int beginIndex; + private final int endIndex; + + private String cachedSubstr = null; + + SubSequence(String str, int startIndex, int endIndex) { + this.str = str; + this.beginIndex = startIndex; + this.endIndex = endIndex; + } + + /** Beginning index of the subseqence in the backing String - can be useful in text processing */ + public int beginIndex() { + return this.beginIndex; + } + + /** Ending index of the subsequence in the backing String - can be useful in text processing */ + public int endIndex() { + return this.endIndex; + } + + @Override + public char charAt(int index) { + return this.str.charAt(this.beginIndex + index); + } + + @Override + public int length() { + return this.endIndex - this.beginIndex; + } + + @Override + public SubSequence subSequence(int start, int end) { + // start/end are offsets in THIS view's coordinates (CharSequence contract), so the absolute + // end is beginIndex + end -- NOT beginIndex + start + end (which overshoots by `start`). + int newBeginIndex = this.beginIndex + start; + int newEndIndex = this.beginIndex + end; + + return new SubSequence(this.str, newBeginIndex, newEndIndex); + } + + /** Appends this SubSequence to the StringBuilder Equivalent to builder.append(this) but faster */ + public void appendTo(StringBuilder builder) { + int beginIndex = this.beginIndex; + int endIndex = this.endIndex; + + // Guards against the special case empty SubSequence at this.str.length + if (beginIndex != endIndex) builder.append(this.str, beginIndex, endIndex); + } + + /** + * The same value as {@code toString().hashCode()} -- the {@link String} hash polynomial over this + * window -- but computed directly over the backing characters so hashing a view does not + * materialize a substring. Stays consistent with {@link #equals}: a view, its content-equal + * {@code String}, and an equal-content view all share this hash. + */ + @Override + public int hashCode() { + int h = 0; + for (int i = this.beginIndex; i < this.endIndex; ++i) { + h = 31 * h + this.str.charAt(i); + } + return h; + } + + /** + * Dispatches on the argument's runtime type: a {@code String} takes the {@link #equals(String)} + * region-compare fast path; any other {@code CharSequence} is compared via {@link + * #contentEquals(CharSequence)}. So {@code this.equals(backingStr.substring(beginIndex, + * endIndex))} is true, and two views with equal content are equal. + */ + // Intentionally asymmetric: a SubSequence equals any String/CharSequence with the same content, + // so subSeq.equals(str) can be true while str.equals(subSeq) is false. This view-equality + // convenience is by design (see class javadoc); accepted despite the equals() symmetry contract. + @Override + @SuppressFBWarnings("EQ_CHECK_FOR_OPERAND_NOT_COMPATIBLE_WITH_THIS") + public boolean equals(Object obj) { + if (obj instanceof String) return this.equals((String) obj); + if (obj instanceof CharSequence) return this.contentEquals((CharSequence) obj); + + return false; + } + + /** + * Equivalent to {@code toString().equals(other)}. A thin alias for {@link + * #contentEquals(String)}, kept for callers working in terms of {@code equals}; prefer {@link + * #contentEquals(String)} directly. + */ + public final boolean equals(String other) { + return this.contentEquals(other); + } + + /** + * Equivalent to {@code toString().contentEquals(that)}: true when {@code that} has the same + * length and characters as this window. The general char-by-char comparison for any {@code + * CharSequence}; prefer {@link #contentEquals(String)} when the argument is known to be a {@code + * String}. + */ + public final boolean contentEquals(CharSequence that) { + if (that == null) return false; + + int len = this.length(); + if (len != that.length()) return false; + + for (int i = 0; i < len; ++i) { + if (this.charAt(i) != that.charAt(i)) return false; + } + return true; + } + + /** + * String-specialized {@link #contentEquals(CharSequence)}: true when {@code other} has the same + * length and characters as this window, via {@link String#regionMatches} (backing-array compare, + * no per-char loop). Prefer this to {@link #equals(Object)} -- it states content-comparison + * intent and is free of the {@code equals} contract's cross-type asymmetry. + */ + public final boolean contentEquals(String other) { + return other != null + && other.length() == this.length() + && this.str.regionMatches(this.beginIndex, other, 0, other.length()); + } + + /** + * Case-insensitive counterpart of {@link #equals(String)}. Like {@link + * String#equalsIgnoreCase(String)}, a {@code null} argument is {@code false} rather than an + * error. + */ + public final boolean equalsIgnoreCase(String other) { + return other != null + && other.length() == this.length() + && this.str.regionMatches(true, this.beginIndex, other, 0, other.length()); + } + + /** + * Equivalent to {@code toString().startsWith(prefix)}. The window guard ({@code prefix.length() + * <= length()}) keeps the delegated read inside {@code [beginIndex, endIndex)}. + */ + public final boolean startsWith(String prefix) { + return prefix.length() <= this.length() && this.str.startsWith(prefix, this.beginIndex); + } + + /** + * Equivalent to {@code length() > 0 && charAt(0) == c}, the single-character {@link + * #startsWith(String)}. + */ + public final boolean startsWith(char c) { + return this.beginIndex < this.endIndex && this.str.charAt(this.beginIndex) == c; + } + + /** + * Equivalent to {@code toString().endsWith(suffix)}. Implemented as a prefix match anchored at + * {@code endIndex - suffix.length()} so the read stays inside this window. + */ + public final boolean endsWith(String suffix) { + int suffixLen = suffix.length(); + return suffixLen <= this.length() && this.str.startsWith(suffix, this.endIndex - suffixLen); + } + + /** + * Equivalent to {@code length() > 0 && charAt(length() - 1) == c}, the single-character {@link + * #endsWith(String)}. + */ + public final boolean endsWith(char c) { + return this.beginIndex < this.endIndex && this.str.charAt(this.endIndex - 1) == c; + } + + /** + * Equivalent to {@code toString().indexOf(needle)}: the offset of the first full occurrence of + * {@code needle} within this window relative to the window start, or {@code -1} if it does not + * occur fully in range. {@link String#indexOf(String, int)} returns the earliest occurrence at or + * after {@code beginIndex}, so a single bound check against {@code endIndex} is exact. + */ + public final int indexOf(String needle) { + int idx = this.str.indexOf(needle, this.beginIndex); + return (idx >= 0 && idx + needle.length() <= this.endIndex) ? idx - this.beginIndex : -1; + } + + /** + * Equivalent to {@code toString().indexOf(c)}: the offset of the first {@code c} within this + * window relative to the window start, or {@code -1} if it does not occur in range. + */ + public final int indexOf(char c) { + int idx = this.str.indexOf(c, this.beginIndex); + return (idx >= 0 && idx < this.endIndex) ? idx - this.beginIndex : -1; + } + + /** + * Equivalent to {@code toString().lastIndexOf(needle)}: the offset of the last full occurrence of + * {@code needle} within this window relative to the window start, or {@code -1} if it does not + * occur fully in range. Searches back from {@code endIndex - needle.length()} -- the latest start + * whose end still fits the window -- so the lower bound is a single check against {@code + * beginIndex}. + */ + public final int lastIndexOf(String needle) { + int idx = this.str.lastIndexOf(needle, this.endIndex - needle.length()); + return (idx >= this.beginIndex) ? idx - this.beginIndex : -1; + } + + /** + * Equivalent to {@code toString().lastIndexOf(c)}: the offset of the last {@code c} within this + * window relative to the window start, or {@code -1} if it does not occur in range. + */ + public final int lastIndexOf(char c) { + int idx = this.str.lastIndexOf(c, this.endIndex - 1); + return (idx >= this.beginIndex) ? idx - this.beginIndex : -1; + } + + /** + * True if this sub-sequence contains {@code needle} -- the zero-copy equivalent of {@code + * toString().contains(needle)}, with no substring materialized. + */ + public final boolean contains(String needle) { + return Strings.regionContains(this.str, this.beginIndex, this.endIndex, needle); + } + + @Override + public String toString() { + String cached = this.cachedSubstr; + if (cached != null) return cached; + + int beginIndex = this.beginIndex; + int endIndex = this.endIndex; + + String substr = (beginIndex == endIndex) ? "" : this.str.substring(beginIndex, endIndex); + this.cachedSubstr = substr; + return substr; } - return true; - } - - /** - * String-specialized {@link #contentEquals(CharSequence)}: true when {@code other} has the same - * length and characters as this window, via {@link String#regionMatches} (backing-array compare, - * no per-char loop). Prefer this to {@link #equals(Object)} -- it states content-comparison - * intent and is free of the {@code equals} contract's cross-type asymmetry. - */ - public final boolean contentEquals(String other) { - return other != null - && other.length() == this.length() - && this.str.regionMatches(this.beginIndex, other, 0, other.length()); - } - - /** - * Case-insensitive counterpart of {@link #equals(String)}. Like {@link - * String#equalsIgnoreCase(String)}, a {@code null} argument is {@code false} rather than an - * error. - */ - public final boolean equalsIgnoreCase(String other) { - return other != null - && other.length() == this.length() - && this.str.regionMatches(true, this.beginIndex, other, 0, other.length()); - } - - /** - * Equivalent to {@code toString().startsWith(prefix)}. The window guard ({@code prefix.length() - * <= length()}) keeps the delegated read inside {@code [beginIndex, endIndex)}. - */ - public final boolean startsWith(String prefix) { - return prefix.length() <= this.length() && this.str.startsWith(prefix, this.beginIndex); - } - - /** - * Equivalent to {@code length() > 0 && charAt(0) == c}, the single-character {@link - * #startsWith(String)}. - */ - public final boolean startsWith(char c) { - return this.beginIndex < this.endIndex && this.str.charAt(this.beginIndex) == c; - } - - /** - * Equivalent to {@code toString().endsWith(suffix)}. Implemented as a prefix match anchored at - * {@code endIndex - suffix.length()} so the read stays inside this window. - */ - public final boolean endsWith(String suffix) { - int suffixLen = suffix.length(); - return suffixLen <= this.length() && this.str.startsWith(suffix, this.endIndex - suffixLen); - } - - /** - * Equivalent to {@code length() > 0 && charAt(length() - 1) == c}, the single-character {@link - * #endsWith(String)}. - */ - public final boolean endsWith(char c) { - return this.beginIndex < this.endIndex && this.str.charAt(this.endIndex - 1) == c; - } - - /** - * Equivalent to {@code toString().indexOf(needle)}: the offset of the first full occurrence of - * {@code needle} within this window relative to the window start, or {@code -1} if it does not - * occur fully in range. {@link String#indexOf(String, int)} returns the earliest occurrence at or - * after {@code beginIndex}, so a single bound check against {@code endIndex} is exact. - */ - public final int indexOf(String needle) { - int idx = this.str.indexOf(needle, this.beginIndex); - return (idx >= 0 && idx + needle.length() <= this.endIndex) ? idx - this.beginIndex : -1; - } - - /** - * Equivalent to {@code toString().indexOf(c)}: the offset of the first {@code c} within this - * window relative to the window start, or {@code -1} if it does not occur in range. - */ - public final int indexOf(char c) { - int idx = this.str.indexOf(c, this.beginIndex); - return (idx >= 0 && idx < this.endIndex) ? idx - this.beginIndex : -1; - } - - /** - * Equivalent to {@code toString().lastIndexOf(needle)}: the offset of the last full occurrence of - * {@code needle} within this window relative to the window start, or {@code -1} if it does not - * occur fully in range. Searches back from {@code endIndex - needle.length()} -- the latest start - * whose end still fits the window -- so the lower bound is a single check against {@code - * beginIndex}. - */ - public final int lastIndexOf(String needle) { - int idx = this.str.lastIndexOf(needle, this.endIndex - needle.length()); - return (idx >= this.beginIndex) ? idx - this.beginIndex : -1; - } - - /** - * Equivalent to {@code toString().lastIndexOf(c)}: the offset of the last {@code c} within this - * window relative to the window start, or {@code -1} if it does not occur in range. - */ - public final int lastIndexOf(char c) { - int idx = this.str.lastIndexOf(c, this.endIndex - 1); - return (idx >= this.beginIndex) ? idx - this.beginIndex : -1; - } - - /** - * True if this sub-sequence contains {@code needle} -- the zero-copy equivalent of {@code - * toString().contains(needle)}, with no substring materialized. - */ - public final boolean contains(String needle) { - return Strings.regionContains(this.str, this.beginIndex, this.endIndex, needle); - } - - @Override - public String toString() { - String cached = this.cachedSubstr; - if (cached != null) return cached; - - int beginIndex = this.beginIndex; - int endIndex = this.endIndex; - - String substr = (beginIndex == endIndex) ? "" : this.str.substring(beginIndex, endIndex); - this.cachedSubstr = substr; - return substr; - } } diff --git a/internal-api/src/main/java/datadog/trace/util/TagsHelper.java b/internal-api/src/main/java/datadog/trace/util/TagsHelper.java index 3627cb47648..09ac1a96a14 100644 --- a/internal-api/src/main/java/datadog/trace/util/TagsHelper.java +++ b/internal-api/src/main/java/datadog/trace/util/TagsHelper.java @@ -4,83 +4,83 @@ /** Helper class for handling tags */ public final class TagsHelper { - private static final int MAX_LENGTH = 200; + private static final int MAX_LENGTH = 200; - /** - * Taken from: - * https://github.com/DataDog/logs-backend/blob/d0b3289ce2c63c1e8f961f03fc4e03318fb36b0f/processing/src/main/java/com/dd/logs/processing/common/Tags.java#L44 - * - *

Sanitizes a tag, more or less following (but not exactly) as per recommended DataDog - * guidelines. - * - *

See the exact guidelines: - * https://docs.datadoghq.com/getting_started/tagging/#tags-best-practices - * - *

1. Tags must start with a letter, and after that may contain: - Alphanumerics - Underscores - * - Minuses - Colons - Periods - Slashes Other special characters get converted to underscores. - * Note: A tag cannot end with a colon (e.g., tag:) - * - *

2. Tags can be up to 200 characters long and support unicode. - * - *

3. Tags are converted to lowercase. - * - *

4. A tag can have a value or a key:value syntax: For optimal functionality, we recommend - * constructing tags that use the key:value syntax. The key is always what precedes the first - * colon of the global tag definition, e.g.: - role:database:mysql is parsed as key:role , - * value:database:mysql - role_database:mysql is parsed as key:role_database , value:mysql - * Examples of commonly used metric tag keys are env, instance, name, and role. - * - *

5. device, host, and source are reserved tag keys and cannot be specified in the standard - * way. - * - *

6. Tags shouldn't originate from unbounded sources, such as EPOCH timestamps or user IDs. - * These tags may impact platform performance and billing. - * - *

Changes: we trim leading and trailing spaces. - * - * @param tag The tag to sanitize. - * @return A sanitized tag, or null if the provided tag was null. - */ - public static String sanitize(String tag) { - if (tag == null) { - return null; + /** + * Taken from: + * https://github.com/DataDog/logs-backend/blob/d0b3289ce2c63c1e8f961f03fc4e03318fb36b0f/processing/src/main/java/com/dd/logs/processing/common/Tags.java#L44 + * + *

Sanitizes a tag, more or less following (but not exactly) as per recommended DataDog + * guidelines. + * + *

See the exact guidelines: + * https://docs.datadoghq.com/getting_started/tagging/#tags-best-practices + * + *

1. Tags must start with a letter, and after that may contain: - Alphanumerics - Underscores + * - Minuses - Colons - Periods - Slashes Other special characters get converted to underscores. + * Note: A tag cannot end with a colon (e.g., tag:) + * + *

2. Tags can be up to 200 characters long and support unicode. + * + *

3. Tags are converted to lowercase. + * + *

4. A tag can have a value or a key:value syntax: For optimal functionality, we recommend + * constructing tags that use the key:value syntax. The key is always what precedes the first + * colon of the global tag definition, e.g.: - role:database:mysql is parsed as key:role , + * value:database:mysql - role_database:mysql is parsed as key:role_database , value:mysql + * Examples of commonly used metric tag keys are env, instance, name, and role. + * + *

5. device, host, and source are reserved tag keys and cannot be specified in the standard + * way. + * + *

6. Tags shouldn't originate from unbounded sources, such as EPOCH timestamps or user IDs. + * These tags may impact platform performance and billing. + * + *

Changes: we trim leading and trailing spaces. + * + * @param tag The tag to sanitize. + * @return A sanitized tag, or null if the provided tag was null. + */ + public static String sanitize(String tag) { + if (tag == null) { + return null; + } + String lower = tag.toLowerCase(Locale.ROOT).trim(); + int length = Math.min(lower.length(), MAX_LENGTH); + StringBuilder sanitized = new StringBuilder(length); + for (int i = 0; i < length; ++i) { + char c = lower.charAt(i); + if (isValid(c)) { + sanitized.append(c); + } else { + sanitized.append('_'); + } + } + return sanitized.toString(); } - String lower = tag.toLowerCase(Locale.ROOT).trim(); - int length = Math.min(lower.length(), MAX_LENGTH); - StringBuilder sanitized = new StringBuilder(length); - for (int i = 0; i < length; ++i) { - char c = lower.charAt(i); - if (isValid(c)) { - sanitized.append(c); - } else { - sanitized.append('_'); - } - } - return sanitized.toString(); - } - - static boolean isValid(char c) { - return (c >= 'a' && c <= 'z') - || (c >= '0' && c <= '9') - || c == '-' - || c == '_' - || c == '.' - || c == '/' - || c == ':'; - } - public static String concatTags(String... tags) { - if (tags == null || tags.length == 0) { - return ""; + static boolean isValid(char c) { + return (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '_' + || c == '.' + || c == '/' + || c == ':'; } - StringBuilder sb = new StringBuilder(); - for (String tag : tags) { - if (tag == null) { - continue; - } - sb.append(tag); - sb.append(','); + + public static String concatTags(String... tags) { + if (tags == null || tags.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (String tag : tags) { + if (tag == null) { + continue; + } + sb.append(tag); + sb.append(','); + } + return sb.substring(0, sb.length() - 1); // Remove last comma } - return sb.substring(0, sb.length() - 1); // Remove last comma - } } diff --git a/internal-api/src/main/java/datadog/trace/util/TempLocationManager.java b/internal-api/src/main/java/datadog/trace/util/TempLocationManager.java index bbdcedadfa6..c9da813c273 100644 --- a/internal-api/src/main/java/datadog/trace/util/TempLocationManager.java +++ b/internal-api/src/main/java/datadog/trace/util/TempLocationManager.java @@ -36,544 +36,521 @@ * to a running Java process. Also, the temporary location is cleaned up on shutdown. */ public final class TempLocationManager { - private static final Logger log = LoggerFactory.getLogger(TempLocationManager.class); - private static final String TEMPDIR_PREFIX = "pid_"; + private static final Logger log = LoggerFactory.getLogger(TempLocationManager.class); + private static final String TEMPDIR_PREFIX = "pid_"; - private static final class SingletonHolder { - private static final TempLocationManager INSTANCE = new TempLocationManager(); - } + private static final class SingletonHolder { + private static final TempLocationManager INSTANCE = new TempLocationManager(); + } - interface CleanupHook { - CleanupHook EMPTY = new CleanupHook() {}; + interface CleanupHook { + CleanupHook EMPTY = new CleanupHook() {}; - default FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs, boolean timeout) - throws IOException { - return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; - } + default FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs, boolean timeout) + throws IOException { + return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; + } - default FileVisitResult visitFile(Path file, BasicFileAttributes attrs, boolean timeout) - throws IOException { - return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; - } + default FileVisitResult visitFile(Path file, BasicFileAttributes attrs, boolean timeout) throws IOException { + return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; + } - default FileVisitResult visitFileFailed(Path file, IOException exc, boolean timeout) - throws IOException { - return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; - } + default FileVisitResult visitFileFailed(Path file, IOException exc, boolean timeout) throws IOException { + return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; + } + + default FileVisitResult postVisitDirectory(Path dir, IOException exc, boolean timeout) throws IOException { + return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; + } - default FileVisitResult postVisitDirectory(Path dir, IOException exc, boolean timeout) - throws IOException { - return timeout ? FileVisitResult.TERMINATE : FileVisitResult.CONTINUE; + default void onCleanupStart(long timeout, TimeUnit unit) {} } - default void onCleanupStart(long timeout, TimeUnit unit) {} - } + private final class CleanupVisitor implements FileVisitor { + private boolean shouldClean; - private final class CleanupVisitor implements FileVisitor { - private boolean shouldClean; + private Set pidSet; - private Set pidSet; + private final long cutoffMillis; + private final long timeoutTargetMillis; - private final long cutoffMillis; - private final long timeoutTargetMillis; + private boolean terminated = false; - private boolean terminated = false; + CleanupVisitor(long timeout, TimeUnit unit) { + long now = timeSource.getCurrentTimeMillis(); + this.cutoffMillis = now - TimeUnit.SECONDS.toMillis(cutoffSeconds); + this.timeoutTargetMillis = timeout > -1 ? now + unit.toMillis(timeout) : Long.MAX_VALUE; + } - CleanupVisitor(long timeout, TimeUnit unit) { - long now = timeSource.getCurrentTimeMillis(); - this.cutoffMillis = now - TimeUnit.SECONDS.toMillis(cutoffSeconds); - this.timeoutTargetMillis = timeout > -1 ? now + unit.toMillis(timeout) : Long.MAX_VALUE; - } + boolean isTerminated() { + return terminated; + } - boolean isTerminated() { - return terminated; - } + private boolean isTimedOut() { + return timeSource.getCurrentTimeMillis() > timeoutTargetMillis; + } - private boolean isTimedOut() { - return timeSource.getCurrentTimeMillis() > timeoutTargetMillis; - } + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + if (isTimedOut()) { + log.debug("Cleaning task timed out"); + terminated = true; + cleanupTestHook.preVisitDirectory(dir, attrs, true); + return FileVisitResult.TERMINATE; + } - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) - throws IOException { - if (isTimedOut()) { - log.debug("Cleaning task timed out"); - terminated = true; - cleanupTestHook.preVisitDirectory(dir, attrs, true); - return FileVisitResult.TERMINATE; - } - - cleanupTestHook.preVisitDirectory(dir, attrs, false); - - if (dir.equals(baseTempDir)) { - return FileVisitResult.CONTINUE; - } - String fileName = dir.getFileName().toString(); - // the JFR repository directories are under /pid_ - String pid = fileName.startsWith(TEMPDIR_PREFIX) ? fileName.substring(4) : null; - boolean isSelfPid = pid != null && pid.equals(PidHelper.getPid()); - if (!isSelfPid) { - if (pidSet == null) { - pidSet = PidHelper.getJavaPids(); // only fork jps when required + cleanupTestHook.preVisitDirectory(dir, attrs, false); + + if (dir.equals(baseTempDir)) { + return FileVisitResult.CONTINUE; + } + String fileName = dir.getFileName().toString(); + // the JFR repository directories are under /pid_ + String pid = fileName.startsWith(TEMPDIR_PREFIX) ? fileName.substring(4) : null; + boolean isSelfPid = pid != null && pid.equals(PidHelper.getPid()); + if (!isSelfPid) { + if (pidSet == null) { + pidSet = PidHelper.getJavaPids(); // only fork jps when required + } + shouldClean |= !pidSet.contains(pid); + } + if (shouldClean) { + log.debug("Cleaning temporary location {}", dir); + } + return shouldClean ? FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if (isTimedOut()) { + log.debug("Cleaning task timed out"); + terminated = true; + cleanupTestHook.visitFile(file, attrs, true); + return FileVisitResult.TERMINATE; + } + cleanupTestHook.visitFile(file, attrs, false); + try { + if (Files.getLastModifiedTime(file).toMillis() > cutoffMillis) { + return FileVisitResult.SKIP_SUBTREE; + } + Files.delete(file); + } catch (NoSuchFileException ignored) { + // another process has already cleaned it; ignore + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { + if (isTimedOut()) { + log.debug("Cleaning task timed out"); + terminated = true; + cleanupTestHook.visitFileFailed(file, exc, true); + return FileVisitResult.TERMINATE; + } + cleanupTestHook.visitFileFailed(file, exc, false); + // do not log files/directories removed by another process running concurrently + if (!(exc instanceof NoSuchFileException) && log.isDebugEnabled()) { + log.debug("Failed to delete file {}", file, exc); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (isTimedOut()) { + log.debug("Cleaning task timed out"); + terminated = true; + cleanupTestHook.postVisitDirectory(dir, exc, true); + return FileVisitResult.TERMINATE; + } + cleanupTestHook.postVisitDirectory(dir, exc, false); + if (exc instanceof NoSuchFileException) { + return FileVisitResult.CONTINUE; + } + if (shouldClean) { + try { + Files.delete(dir); + } catch (NoSuchFileException ignored) { + // another process has already cleaned it; ignore + } + String fileName = dir.getFileName().toString(); + // reset the flag only if we are done cleaning the top-level directory + shouldClean = !fileName.startsWith(TEMPDIR_PREFIX); + } + return FileVisitResult.CONTINUE; } - shouldClean |= !pidSet.contains(pid); - } - if (shouldClean) { - log.debug("Cleaning temporary location {}", dir); - } - return shouldClean ? FileVisitResult.CONTINUE : FileVisitResult.SKIP_SUBTREE; } - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - if (isTimedOut()) { - log.debug("Cleaning task timed out"); - terminated = true; - cleanupTestHook.visitFile(file, attrs, true); - return FileVisitResult.TERMINATE; - } - cleanupTestHook.visitFile(file, attrs, false); - try { - if (Files.getLastModifiedTime(file).toMillis() > cutoffMillis) { - return FileVisitResult.SKIP_SUBTREE; + private final class CleanupTask implements Runnable { + private final CountDownLatch latch = new CountDownLatch(1); + private volatile Throwable throwable = null; + + @Override + public void run() { + try { + cleanup(); + } catch (OutOfMemoryError oom) { + throw oom; + } catch (Throwable t) { + throwable = t; + } finally { + latch.countDown(); + } + } + + boolean await(long timeout, TimeUnit unit) throws Throwable { + boolean ret = latch.await(timeout, unit); + if (throwable != null) { + throw throwable; + } + return ret; } - Files.delete(file); - } catch (NoSuchFileException ignored) { - // another process has already cleaned it; ignore - } - return FileVisitResult.CONTINUE; } - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { - if (isTimedOut()) { - log.debug("Cleaning task timed out"); - terminated = true; - cleanupTestHook.visitFileFailed(file, exc, true); - return FileVisitResult.TERMINATE; - } - cleanupTestHook.visitFileFailed(file, exc, false); - // do not log files/directories removed by another process running concurrently - if (!(exc instanceof NoSuchFileException) && log.isDebugEnabled()) { - log.debug("Failed to delete file {}", file, exc); - } - return FileVisitResult.CONTINUE; + private static final Set GROUP_WORLD_BITS = EnumSet.of( + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_WRITE, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_WRITE, + PosixFilePermission.OTHERS_EXECUTE); + + private final boolean isPosixFs; + private final Path baseTempDir; + private final Path tempDir; + private final long cutoffSeconds; + private UserPrincipal expectedOwner; + + private final CleanupTask cleanupTask = new CleanupTask(); + private final CleanupHook cleanupTestHook; + private final TimeSource timeSource; + + /** + * Get the singleton instance of the TempLocationManager. It will run the cleanup task in the + * background. + * + * @return the singleton instance of the TempLocationManager + */ + public static TempLocationManager getInstance() { + return getInstance(false); } - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { - if (isTimedOut()) { - log.debug("Cleaning task timed out"); - terminated = true; - cleanupTestHook.postVisitDirectory(dir, exc, true); - return FileVisitResult.TERMINATE; - } - cleanupTestHook.postVisitDirectory(dir, exc, false); - if (exc instanceof NoSuchFileException) { - return FileVisitResult.CONTINUE; - } - if (shouldClean) { - try { - Files.delete(dir); - } catch (NoSuchFileException ignored) { - // another process has already cleaned it; ignore + /** + * Get the singleton instance of the TempLocationManager. + * + * @param waitForCleanup if true, wait for the cleanup task to finish before returning + * @return the singleton instance of the TempLocationManager + */ + static TempLocationManager getInstance(boolean waitForCleanup) { + TempLocationManager instance = SingletonHolder.INSTANCE; + if (waitForCleanup) { + instance.waitForCleanup(5, TimeUnit.SECONDS); } - String fileName = dir.getFileName().toString(); - // reset the flag only if we are done cleaning the top-level directory - shouldClean = !fileName.startsWith(TEMPDIR_PREFIX); - } - return FileVisitResult.CONTINUE; + return instance; } - } - - private final class CleanupTask implements Runnable { - private final CountDownLatch latch = new CountDownLatch(1); - private volatile Throwable throwable = null; - - @Override - public void run() { - try { - cleanup(); - } catch (OutOfMemoryError oom) { - throw oom; - } catch (Throwable t) { - throwable = t; - } finally { - latch.countDown(); - } + + private TempLocationManager() { + this(ConfigProvider.getInstance()); } - boolean await(long timeout, TimeUnit unit) throws Throwable { - boolean ret = latch.await(timeout, unit); - if (throwable != null) { - throw throwable; - } - return ret; + TempLocationManager(ConfigProvider configProvider) { + this(configProvider, true, CleanupHook.EMPTY, SystemTimeSource.INSTANCE); } - } - - private static final Set GROUP_WORLD_BITS = - EnumSet.of( - PosixFilePermission.GROUP_READ, - PosixFilePermission.GROUP_WRITE, - PosixFilePermission.GROUP_EXECUTE, - PosixFilePermission.OTHERS_READ, - PosixFilePermission.OTHERS_WRITE, - PosixFilePermission.OTHERS_EXECUTE); - - private final boolean isPosixFs; - private final Path baseTempDir; - private final Path tempDir; - private final long cutoffSeconds; - private UserPrincipal expectedOwner; - - private final CleanupTask cleanupTask = new CleanupTask(); - private final CleanupHook cleanupTestHook; - private final TimeSource timeSource; - - /** - * Get the singleton instance of the TempLocationManager. It will run the cleanup task in the - * background. - * - * @return the singleton instance of the TempLocationManager - */ - public static TempLocationManager getInstance() { - return getInstance(false); - } - - /** - * Get the singleton instance of the TempLocationManager. - * - * @param waitForCleanup if true, wait for the cleanup task to finish before returning - * @return the singleton instance of the TempLocationManager - */ - static TempLocationManager getInstance(boolean waitForCleanup) { - TempLocationManager instance = SingletonHolder.INSTANCE; - if (waitForCleanup) { - instance.waitForCleanup(5, TimeUnit.SECONDS); + + TempLocationManager(ConfigProvider configProvider, boolean runStartupCleanup, CleanupHook testHook) { + this(configProvider, runStartupCleanup, testHook, SystemTimeSource.INSTANCE); } - return instance; - } - - private TempLocationManager() { - this(ConfigProvider.getInstance()); - } - - TempLocationManager(ConfigProvider configProvider) { - this(configProvider, true, CleanupHook.EMPTY, SystemTimeSource.INSTANCE); - } - - TempLocationManager( - ConfigProvider configProvider, boolean runStartupCleanup, CleanupHook testHook) { - this(configProvider, runStartupCleanup, testHook, SystemTimeSource.INSTANCE); - } - - TempLocationManager( - ConfigProvider configProvider, - boolean runStartupCleanup, - CleanupHook testHook, - TimeSource timeSource) { - cleanupTestHook = testHook; - this.timeSource = timeSource; - - Set supportedViews = FileSystems.getDefault().supportedFileAttributeViews(); - isPosixFs = supportedViews.contains("posix"); - - // In order to avoid racy attempts to clean up files which are currently being processed in a - // JVM which is being shut down (the JVMs far in the shutdown routine may not be reported by - // 'jps' but still can be eg. processing JFR chunks) we will not clean up any files not older - // than '2 * PROFILING_UPLOAD_PERIOD' seconds. - // The reasoning is that even if the file is created immediately at JVM startup once it is - // 'PROFILING_UPLOAD_PERIOD' seconds old it gets processed to upload the final profile data and - // this processing will not take longer than another `PROFILING_UPLOAD_PERIOD' seconds. - // This is just an assumption but as long as the profiled application is working normally (eg. - // OS is not stalling) this assumption will hold. - cutoffSeconds = - configProvider.getLong( - ProfilingConfig.PROFILING_UPLOAD_PERIOD, - ProfilingConfig.PROFILING_UPLOAD_PERIOD_DEFAULT); - Path configuredTempDir = - Paths.get( - configProvider.getString( + + TempLocationManager( + ConfigProvider configProvider, boolean runStartupCleanup, CleanupHook testHook, TimeSource timeSource) { + cleanupTestHook = testHook; + this.timeSource = timeSource; + + Set supportedViews = FileSystems.getDefault().supportedFileAttributeViews(); + isPosixFs = supportedViews.contains("posix"); + + // In order to avoid racy attempts to clean up files which are currently being processed in a + // JVM which is being shut down (the JVMs far in the shutdown routine may not be reported by + // 'jps' but still can be eg. processing JFR chunks) we will not clean up any files not older + // than '2 * PROFILING_UPLOAD_PERIOD' seconds. + // The reasoning is that even if the file is created immediately at JVM startup once it is + // 'PROFILING_UPLOAD_PERIOD' seconds old it gets processed to upload the final profile data and + // this processing will not take longer than another `PROFILING_UPLOAD_PERIOD' seconds. + // This is just an assumption but as long as the profiled application is working normally (eg. + // OS is not stalling) this assumption will hold. + cutoffSeconds = configProvider.getLong( + ProfilingConfig.PROFILING_UPLOAD_PERIOD, ProfilingConfig.PROFILING_UPLOAD_PERIOD_DEFAULT); + Path configuredTempDir = Paths.get(configProvider.getString( ProfilingConfig.PROFILING_TEMP_DIR, ProfilingConfig.PROFILING_TEMP_DIR_DEFAULT)); - if (!Files.exists(configuredTempDir)) { - ProfilerFlareLogger.getInstance() - .log("Base temp directory, as defined in '{}' does not exist.", configuredTempDir); - throw new IllegalStateException( - "Base temp directory, as defined in '" - + ProfilingConfig.PROFILING_TEMP_DIR - + "' does not exist: " - + configuredTempDir); - } + if (!Files.exists(configuredTempDir)) { + ProfilerFlareLogger.getInstance() + .log("Base temp directory, as defined in '{}' does not exist.", configuredTempDir); + throw new IllegalStateException("Base temp directory, as defined in '" + + ProfilingConfig.PROFILING_TEMP_DIR + + "' does not exist: " + + configuredTempDir); + } + + String pid = PidHelper.getPid(); - String pid = PidHelper.getPid(); + baseTempDir = configuredTempDir.resolve(getBaseTempDirName()); + baseTempDir.toFile().deleteOnExit(); - baseTempDir = configuredTempDir.resolve(getBaseTempDirName()); - baseTempDir.toFile().deleteOnExit(); + tempDir = baseTempDir.resolve(TEMPDIR_PREFIX + pid); + if (runStartupCleanup) { + // do not execute the background cleanup task when running in tests + AgentTaskScheduler.get().execute(cleanupTask); + } - tempDir = baseTempDir.resolve(TEMPDIR_PREFIX + pid); - if (runStartupCleanup) { - // do not execute the background cleanup task when running in tests - AgentTaskScheduler.get().execute(cleanupTask); + createTempDir(tempDir); } - createTempDir(tempDir); - } - - @VisibleForTesting - static String getBaseTempDirName() { - String userName = SystemProperties.get("user.name"); - // unlikely, but fall-back to system env based user name - userName = userName == null ? ConfigHelper.env("USER") : userName; - // make sure we do not have any illegal characters in the user name - userName = - userName != null ? userName.replace('.', '_').replace('/', '_').replace(' ', '_') : null; - return "ddprof" + (userName != null ? "_" + userName : ""); - } - - /** - * Get the temporary directory for the current process. The directory will be removed at JVM exit. - * - * @return the temporary directory for the current process - */ - public Path getTempDir() { - return getTempDir(null); - } - - /** - * Get the temporary subdirectory for the current process. The directory will be removed at JVM - * exit. - * - * @param subPath the relative subdirectory path, may be {@literal null} - * @return the temporary subdirectory for the current process - */ - public Path getTempDir(Path subPath) { - return getTempDir(subPath, true); - } - - /** - * Get the temporary subdirectory for the current process. - * - * @param subPath the relative subdirectory path, may be {@literal null} - * @param create if true, create the directory if it does not exist - * @return the temporary directory for the current process - * @throws IllegalStateException if the directory could not be created - */ - public Path getTempDir(Path subPath, boolean create) { - Path rslt = - subPath != null && !subPath.toString().isEmpty() ? tempDir.resolve(subPath) : tempDir; - if (create && !Files.exists(rslt)) { - createTempDir(rslt); - } else if (isPosixFs && Files.exists(rslt)) { - validateOwnedSecureDir(rslt); + @VisibleForTesting + static String getBaseTempDirName() { + String userName = SystemProperties.get("user.name"); + // unlikely, but fall-back to system env based user name + userName = userName == null ? ConfigHelper.env("USER") : userName; + // make sure we do not have any illegal characters in the user name + userName = + userName != null ? userName.replace('.', '_').replace('/', '_').replace(' ', '_') : null; + return "ddprof" + (userName != null ? "_" + userName : ""); } - return rslt; - } - - /** - * Walk the base temp directory recursively and remove all inactive per-process entries. No - * timeout is applied. - * - * @return {@literal true} if cleanup fully succeeded or {@literal false} otherwise (eg. - * interruption etc.) - */ - boolean cleanup() { - return cleanup(-1, TimeUnit.SECONDS); - } - - /** - * Walk the base temp directory recursively and remove all inactive per-process entries - * - * @param timeout the task timeout; may be {@literal -1} to signal no timeout - * @param unit the task timeout unit - * @return {@literal true} if cleanup fully succeeded or {@literal false} otherwise (timeout, - * interruption etc.) - */ - boolean cleanup(long timeout, TimeUnit unit) { - try { - if (!Files.exists(baseTempDir)) { - // not even the main temp location exists; nothing to clean up - return true; - } - try (Stream paths = Files.walk(baseTempDir)) { - if (paths.noneMatch( - path -> - Files.isDirectory(path) - && path.getFileName().toString().startsWith(TEMPDIR_PREFIX))) { - // nothing to clean up; bail out early - return true; - } - } - cleanupTestHook.onCleanupStart(timeout, unit); - CleanupVisitor visitor = new CleanupVisitor(timeout, unit); - Files.walkFileTree(baseTempDir, visitor); - return !visitor.isTerminated(); - } catch (IOException e) { - log.debug("Unable to cleanup temp location {}", baseTempDir, e); + + /** + * Get the temporary directory for the current process. The directory will be removed at JVM exit. + * + * @return the temporary directory for the current process + */ + public Path getTempDir() { + return getTempDir(null); } - return false; - } - - @VisibleForTesting - boolean waitForCleanup(long timeout, TimeUnit unit) { - try { - return cleanupTask.await(timeout, unit); - } catch (InterruptedException e) { - log.debug("Temp directory cleanup was interrupted"); - Thread.currentThread().interrupt(); - } catch (Throwable t) { - if (log.isDebugEnabled()) { - log.debug("Failed to cleanup temp directory: {}", tempDir, t); - } else { - log.debug("Failed to cleanup temp directory: {}", tempDir); - } + + /** + * Get the temporary subdirectory for the current process. The directory will be removed at JVM + * exit. + * + * @param subPath the relative subdirectory path, may be {@literal null} + * @return the temporary subdirectory for the current process + */ + public Path getTempDir(Path subPath) { + return getTempDir(subPath, true); } - return false; - } - - @VisibleForTesting - void createDirStructure() throws IOException { - Files.createDirectories(baseTempDir); - if (isPosixFs) { - if (expectedOwner == null) { - expectedOwner = Files.getOwner(baseTempDir); - } - validateOwnedSecureDir(baseTempDir); + + /** + * Get the temporary subdirectory for the current process. + * + * @param subPath the relative subdirectory path, may be {@literal null} + * @param create if true, create the directory if it does not exist + * @return the temporary directory for the current process + * @throws IllegalStateException if the directory could not be created + */ + public Path getTempDir(Path subPath, boolean create) { + Path rslt = subPath != null && !subPath.toString().isEmpty() ? tempDir.resolve(subPath) : tempDir; + if (create && !Files.exists(rslt)) { + createTempDir(rslt); + } else if (isPosixFs && Files.exists(rslt)) { + validateOwnedSecureDir(rslt); + } + return rslt; + } + + /** + * Walk the base temp directory recursively and remove all inactive per-process entries. No + * timeout is applied. + * + * @return {@literal true} if cleanup fully succeeded or {@literal false} otherwise (eg. + * interruption etc.) + */ + boolean cleanup() { + return cleanup(-1, TimeUnit.SECONDS); } - } - - /** - * Validates that the given directory is owned by the current JVM user and has no group or world - * permission bits set (effective {@code 0700}). Only enforced on POSIX file systems. - * - * @throws IllegalStateException if the directory fails the ownership or permission check - */ - private void validateOwnedSecureDir(Path dir) { - if (!isPosixFs) { - return; + + /** + * Walk the base temp directory recursively and remove all inactive per-process entries + * + * @param timeout the task timeout; may be {@literal -1} to signal no timeout + * @param unit the task timeout unit + * @return {@literal true} if cleanup fully succeeded or {@literal false} otherwise (timeout, + * interruption etc.) + */ + boolean cleanup(long timeout, TimeUnit unit) { + try { + if (!Files.exists(baseTempDir)) { + // not even the main temp location exists; nothing to clean up + return true; + } + try (Stream paths = Files.walk(baseTempDir)) { + if (paths.noneMatch(path -> + Files.isDirectory(path) && path.getFileName().toString().startsWith(TEMPDIR_PREFIX))) { + // nothing to clean up; bail out early + return true; + } + } + cleanupTestHook.onCleanupStart(timeout, unit); + CleanupVisitor visitor = new CleanupVisitor(timeout, unit); + Files.walkFileTree(baseTempDir, visitor); + return !visitor.isTerminated(); + } catch (IOException e) { + log.debug("Unable to cleanup temp location {}", baseTempDir, e); + } + return false; } - try { - Set perms = Files.getPosixFilePermissions(dir); - UserPrincipal owner = Files.getOwner(dir); - boolean hasGroupWorldBits = perms.stream().anyMatch(GROUP_WORLD_BITS::contains); - boolean wrongOwner = expectedOwner != null && !expectedOwner.equals(owner); - if (hasGroupWorldBits || wrongOwner) { - ProfilerFlareLogger.getInstance() - .log( - "Refusing to use temp directory {} owned by {} with perms {}", - dir, - owner, - PosixFilePermissions.toString(perms)); - throw new IllegalStateException("Untrusted temp directory: " + dir); - } - } catch (IllegalStateException e) { - throw e; - } catch (IOException e) { - ProfilerFlareLogger.getInstance() - .log("Failed to validate temp directory ownership/permissions for {}", dir); - throw new IllegalStateException("Untrusted temp directory: " + dir, e); + + @VisibleForTesting + boolean waitForCleanup(long timeout, TimeUnit unit) { + try { + return cleanupTask.await(timeout, unit); + } catch (InterruptedException e) { + log.debug("Temp directory cleanup was interrupted"); + Thread.currentThread().interrupt(); + } catch (Throwable t) { + if (log.isDebugEnabled()) { + log.debug("Failed to cleanup temp directory: {}", tempDir, t); + } else { + log.debug("Failed to cleanup temp directory: {}", tempDir); + } + } + return false; } - } - - private void createTempDir(Path tempDir) { - String msg = "Failed to create temp directory: " + tempDir; - try { - if (isPosixFs) { - // Validate any already-existing components from baseTempDir down to tempDir - // before creating anything new, to reject attacker-pre-planted directories. - Path current = baseTempDir; - if (Files.exists(current)) { - if (expectedOwner == null) { - expectedOwner = Files.getOwner(current); - } - validateOwnedSecureDir(current); - // Walk the relative path components between baseTempDir and tempDir - Path rel = baseTempDir.relativize(tempDir); - for (int i = 0; i < rel.getNameCount(); i++) { - current = current.resolve(rel.getName(i)); - if (Files.exists(current)) { - validateOwnedSecureDir(current); + + @VisibleForTesting + void createDirStructure() throws IOException { + Files.createDirectories(baseTempDir); + if (isPosixFs) { + if (expectedOwner == null) { + expectedOwner = Files.getOwner(baseTempDir); } - } + validateOwnedSecureDir(baseTempDir); } - Files.createDirectories( - tempDir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - // Capture owner from a freshly created reference if not yet established - if (expectedOwner == null) { - expectedOwner = Files.getOwner(tempDir); + } + + /** + * Validates that the given directory is owned by the current JVM user and has no group or world + * permission bits set (effective {@code 0700}). Only enforced on POSIX file systems. + * + * @throws IllegalStateException if the directory fails the ownership or permission check + */ + private void validateOwnedSecureDir(Path dir) { + if (!isPosixFs) { + return; } - validateOwnedSecureDir(tempDir); - } else { - Files.createDirectories(tempDir); - } - } catch (IllegalStateException e) { - throw e; - } catch (IOException e) { - // if on a posix fs, let's check the expected permissions - // we will find the first offender not having the expected permissions and fail the check - if (isPosixFs) { - // take the first subfolder below the base temp dir - // we can wave the checks for tempDir being a subdir of baseTempDir because that's how it is - // created - Path root = baseTempDir.resolve(baseTempDir.relativize(tempDir).getName(0)); try { - AtomicReference failed = new AtomicReference<>(); - Files.walkFileTree( - root, - new FileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) - throws IOException { - Set perms = Files.getPosixFilePermissions(dir); - if (!perms.contains(PosixFilePermission.OWNER_READ) - || !perms.contains(PosixFilePermission.OWNER_WRITE) - || !perms.contains(PosixFilePermission.OWNER_EXECUTE)) { - failed.set(dir); - return FileVisitResult.TERMINATE; - } - return FileVisitResult.CONTINUE; - } + Set perms = Files.getPosixFilePermissions(dir); + UserPrincipal owner = Files.getOwner(dir); + boolean hasGroupWorldBits = perms.stream().anyMatch(GROUP_WORLD_BITS::contains); + boolean wrongOwner = expectedOwner != null && !expectedOwner.equals(owner); + if (hasGroupWorldBits || wrongOwner) { + ProfilerFlareLogger.getInstance() + .log( + "Refusing to use temp directory {} owned by {} with perms {}", + dir, + owner, + PosixFilePermissions.toString(perms)); + throw new IllegalStateException("Untrusted temp directory: " + dir); + } + } catch (IllegalStateException e) { + throw e; + } catch (IOException e) { + ProfilerFlareLogger.getInstance() + .log("Failed to validate temp directory ownership/permissions for {}", dir); + throw new IllegalStateException("Untrusted temp directory: " + dir, e); + } + } - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) - throws IOException { - return FileVisitResult.SKIP_SIBLINGS; + private void createTempDir(Path tempDir) { + String msg = "Failed to create temp directory: " + tempDir; + try { + if (isPosixFs) { + // Validate any already-existing components from baseTempDir down to tempDir + // before creating anything new, to reject attacker-pre-planted directories. + Path current = baseTempDir; + if (Files.exists(current)) { + if (expectedOwner == null) { + expectedOwner = Files.getOwner(current); + } + validateOwnedSecureDir(current); + // Walk the relative path components between baseTempDir and tempDir + Path rel = baseTempDir.relativize(tempDir); + for (int i = 0; i < rel.getNameCount(); i++) { + current = current.resolve(rel.getName(i)); + if (Files.exists(current)) { + validateOwnedSecureDir(current); + } + } } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) - throws IOException { - return FileVisitResult.TERMINATE; + Files.createDirectories( + tempDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + // Capture owner from a freshly created reference if not yet established + if (expectedOwner == null) { + expectedOwner = Files.getOwner(tempDir); } - - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) - throws IOException { - return FileVisitResult.CONTINUE; + validateOwnedSecureDir(tempDir); + } else { + Files.createDirectories(tempDir); + } + } catch (IllegalStateException e) { + throw e; + } catch (IOException e) { + // if on a posix fs, let's check the expected permissions + // we will find the first offender not having the expected permissions and fail the check + if (isPosixFs) { + // take the first subfolder below the base temp dir + // we can wave the checks for tempDir being a subdir of baseTempDir because that's how it is + // created + Path root = baseTempDir.resolve(baseTempDir.relativize(tempDir).getName(0)); + try { + AtomicReference failed = new AtomicReference<>(); + Files.walkFileTree(root, new FileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + Set perms = Files.getPosixFilePermissions(dir); + if (!perms.contains(PosixFilePermission.OWNER_READ) + || !perms.contains(PosixFilePermission.OWNER_WRITE) + || !perms.contains(PosixFilePermission.OWNER_EXECUTE)) { + failed.set(dir); + return FileVisitResult.TERMINATE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + return FileVisitResult.SKIP_SIBLINGS; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { + return FileVisitResult.TERMINATE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + return FileVisitResult.CONTINUE; + } + }); + Path failedDir = failed.get(); + + if (failedDir != null) { + ProfilerFlareLogger.getInstance() + .log( + "Failed to create temp directory: {}, offender: {}, permissions: {}", + msg, + failedDir, + PosixFilePermissions.toString(Files.getPosixFilePermissions(failedDir)), + e); + } else { + ProfilerFlareLogger.getInstance().log(msg); + } + } catch (IOException ignored) { + // should not happen, but let's ignore it anyway } - }); - Path failedDir = failed.get(); - - if (failedDir != null) { - ProfilerFlareLogger.getInstance() - .log( - "Failed to create temp directory: {}, offender: {}, permissions: {}", - msg, - failedDir, - PosixFilePermissions.toString(Files.getPosixFilePermissions(failedDir)), - e); - } else { - ProfilerFlareLogger.getInstance().log(msg); - } - } catch (IOException ignored) { - // should not happen, but let's ignore it anyway + throw new IllegalStateException(msg, e); + } else { + ProfilerFlareLogger.getInstance().log(msg, e); + throw new IllegalStateException(msg, e); + } } - throw new IllegalStateException(msg, e); - } else { - ProfilerFlareLogger.getInstance().log(msg, e); - throw new IllegalStateException(msg, e); - } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/TraceUtils.java b/internal-api/src/main/java/datadog/trace/util/TraceUtils.java index 2e4c52c606c..8e6dcfc8332 100644 --- a/internal-api/src/main/java/datadog/trace/util/TraceUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/TraceUtils.java @@ -12,80 +12,80 @@ */ public class TraceUtils { - private static final int MAX_TYPE_LEN = 100; - private static final int MAX_SERVICE_LEN = 100; - private static final int MAX_OP_NAME_LEN = 100; - private static final int MAX_ENV_LEN = 200; - - static final String DEFAULT_SERVICE_NAME = "unnamed-service"; - static final String DEFAULT_OPERATION_NAME = "unnamed_operation"; - static final String DEFAULT_ENV = "none"; - - private static final Logger log = LoggerFactory.getLogger(TraceUtils.class); - - public static String normalizeServiceName(final String service) { - if (service == null || service.isEmpty()) { - log.debug( - "Fixing malformed trace. Service is empty (reason:service_empty), setting span.service={}.", - service); - return DEFAULT_SERVICE_NAME; - } + private static final int MAX_TYPE_LEN = 100; + private static final int MAX_SERVICE_LEN = 100; + private static final int MAX_OP_NAME_LEN = 100; + private static final int MAX_ENV_LEN = 200; + + static final String DEFAULT_SERVICE_NAME = "unnamed-service"; + static final String DEFAULT_OPERATION_NAME = "unnamed_operation"; + static final String DEFAULT_ENV = "none"; + + private static final Logger log = LoggerFactory.getLogger(TraceUtils.class); + + public static String normalizeServiceName(final String service) { + if (service == null || service.isEmpty()) { + log.debug( + "Fixing malformed trace. Service is empty (reason:service_empty), setting span.service={}.", + service); + return DEFAULT_SERVICE_NAME; + } + + String svc = service; + if (svc.length() > MAX_SERVICE_LEN) { + log.debug( + "Fixing malformed trace. Service is too long (reason:service_truncate), truncating span.service to length={}.", + MAX_SERVICE_LEN); + svc = truncate(svc, MAX_SERVICE_LEN); + } - String svc = service; - if (svc.length() > MAX_SERVICE_LEN) { - log.debug( - "Fixing malformed trace. Service is too long (reason:service_truncate), truncating span.service to length={}.", - MAX_SERVICE_LEN); - svc = truncate(svc, MAX_SERVICE_LEN); + return normalizeTag(svc); } - return normalizeTag(svc); - } + public static CharSequence normalizeOperationName(final CharSequence opName) { + if (opName == null || opName.length() == 0) { + return DEFAULT_OPERATION_NAME; + } - public static CharSequence normalizeOperationName(final CharSequence opName) { - if (opName == null || opName.length() == 0) { - return DEFAULT_OPERATION_NAME; - } + CharSequence name = opName; + if (name.length() > MAX_OP_NAME_LEN) { + log.debug( + "Fixing malformed trace. Name is too long (reason:span_name_truncate), truncating span.name to length={}.", + MAX_OP_NAME_LEN); + name = truncate(name, MAX_OP_NAME_LEN); + } - CharSequence name = opName; - if (name.length() > MAX_OP_NAME_LEN) { - log.debug( - "Fixing malformed trace. Name is too long (reason:span_name_truncate), truncating span.name to length={}.", - MAX_OP_NAME_LEN); - name = truncate(name, MAX_OP_NAME_LEN); + name = normalizeSpanName(name); + if (name.length() == 0) { + name = DEFAULT_OPERATION_NAME; + } + return name; } - name = normalizeSpanName(name); - if (name.length() == 0) { - name = DEFAULT_OPERATION_NAME; - } - return name; - } - - public static CharSequence normalizeSpanType(final CharSequence spanType) { - if (spanType != null && spanType.length() > MAX_TYPE_LEN) { - log.debug( - "Fixing malformed trace. Type is too long (reason:type_truncate), truncating span.type to length={}", - MAX_TYPE_LEN); - return truncate(spanType, MAX_TYPE_LEN); + public static CharSequence normalizeSpanType(final CharSequence spanType) { + if (spanType != null && spanType.length() > MAX_TYPE_LEN) { + log.debug( + "Fixing malformed trace. Type is too long (reason:type_truncate), truncating span.type to length={}", + MAX_TYPE_LEN); + return truncate(spanType, MAX_TYPE_LEN); + } + return spanType; } - return spanType; - } - public static String normalizeEnv(final String env) { - if (env == null || env.length() == 0) { - return DEFAULT_ENV; - } + public static String normalizeEnv(final String env) { + if (env == null || env.length() == 0) { + return DEFAULT_ENV; + } - String e = truncate(env, MAX_ENV_LEN); - return normalizeTag(e); - } + String e = truncate(env, MAX_ENV_LEN); + return normalizeTag(e); + } - public static boolean isValidStatusCode(final int httpStatusCode) { - return (httpStatusCode >= 100 && httpStatusCode < 600); - } + public static boolean isValidStatusCode(final int httpStatusCode) { + return (httpStatusCode >= 100 && httpStatusCode < 600); + } - // spotless:off + // spotless:off /** * Normalizes a full tag (key:value): @@ -99,74 +99,74 @@ public static boolean isValidStatusCode(final int httpStatusCode) { * See https://docs.datadoghq.com/getting_started/tagging/ */ // spotless:on - public static String normalizeTag(final String tag) { - return doNormalize(tag, true); - } - - /** - * Normalizes a tag value according to the datadog tag conventions - Only letters, digits, ":", - * ".", "-", "_" and "/" are allowed. - If a non-valid char is found, it's replaced with "_". If - * it's the last char, it's removed. - It applies lower case. - * - * @param tagValue the tag value - * @return normalized tag value See https://docs.datadoghq.com/getting_started/tagging/ - */ - public static String normalizeTagValue(final String tagValue) { - return doNormalize(tagValue, false); - } + public static String normalizeTag(final String tag) { + return doNormalize(tag, true); + } - private static String doNormalize(String tag, boolean skipNumericalPrefixes) { - if (tag == null || tag.isEmpty()) { - return ""; + /** + * Normalizes a tag value according to the datadog tag conventions - Only letters, digits, ":", + * ".", "-", "_" and "/" are allowed. - If a non-valid char is found, it's replaced with "_". If + * it's the last char, it's removed. - It applies lower case. + * + * @param tagValue the tag value + * @return normalized tag value See https://docs.datadoghq.com/getting_started/tagging/ + */ + public static String normalizeTagValue(final String tagValue) { + return doNormalize(tagValue, false); } - StringBuilder builder = new StringBuilder(tag.length()); - boolean isJumping = false; - for (int i = 0; i < tag.length(); ++i) { - char ch = tag.charAt(i); - if (ch >= 'a' && ch <= 'z' || ch == ':') { - isJumping = false; - builder.append(ch); - continue; - } - if (ch >= 'A' && ch <= 'Z') { - isJumping = false; - ch += 'a' - 'A'; - builder.append(ch); - continue; - } - if (Character.isUpperCase(ch)) { - ch = Character.toLowerCase(ch); - } - if (Character.isLetter(ch)) { - isJumping = false; - builder.append(ch); - continue; - } - if (builder.length() == 0) { - if (skipNumericalPrefixes || !Character.isDigit(ch)) { - // this character can't start the string, trim - continue; + private static String doNormalize(String tag, boolean skipNumericalPrefixes) { + if (tag == null || tag.isEmpty()) { + return ""; } - } - if (Character.isDigit(ch) || ch == '.' || ch == '/' || ch == '-') { - isJumping = false; - builder.append(ch); - continue; - } - if (!isJumping) { - builder.append('_'); - isJumping = true; - } - } - // If last added character was due to a bad pattern, just remove the last added underscore - if (isJumping) { - builder.setLength(builder.length() - 1); + + StringBuilder builder = new StringBuilder(tag.length()); + boolean isJumping = false; + for (int i = 0; i < tag.length(); ++i) { + char ch = tag.charAt(i); + if (ch >= 'a' && ch <= 'z' || ch == ':') { + isJumping = false; + builder.append(ch); + continue; + } + if (ch >= 'A' && ch <= 'Z') { + isJumping = false; + ch += 'a' - 'A'; + builder.append(ch); + continue; + } + if (Character.isUpperCase(ch)) { + ch = Character.toLowerCase(ch); + } + if (Character.isLetter(ch)) { + isJumping = false; + builder.append(ch); + continue; + } + if (builder.length() == 0) { + if (skipNumericalPrefixes || !Character.isDigit(ch)) { + // this character can't start the string, trim + continue; + } + } + if (Character.isDigit(ch) || ch == '.' || ch == '/' || ch == '-') { + isJumping = false; + builder.append(ch); + continue; + } + if (!isJumping) { + builder.append('_'); + isJumping = true; + } + } + // If last added character was due to a bad pattern, just remove the last added underscore + if (isJumping) { + builder.setLength(builder.length() - 1); + } + return builder.toString(); } - return builder.toString(); - } - // spotless:off + // spotless:off /** * Normalizes the span name: @@ -179,57 +179,57 @@ private static String doNormalize(String tag, boolean skipNumericalPrefixes) { * @return normalized span name */ // spotless:on - private static CharSequence normalizeSpanName(final CharSequence name) { - if (name.length() == 0) { - return name; - } + private static CharSequence normalizeSpanName(final CharSequence name) { + if (name.length() == 0) { + return name; + } - final StringBuilder builder = new StringBuilder(name.length()); + final StringBuilder builder = new StringBuilder(name.length()); - int i = 0; - char previousCh = 0; - // skip non-alphabetic characters - for (i = 0; i < name.length() && !isAlpha(name.charAt(i)); i++) {} + int i = 0; + char previousCh = 0; + // skip non-alphabetic characters + for (i = 0; i < name.length() && !isAlpha(name.charAt(i)); i++) {} - // if there were no alphabetic characters it wasn't valid - if (i == name.length()) { - return ""; - } + // if there were no alphabetic characters it wasn't valid + if (i == name.length()) { + return ""; + } - for (; i < name.length(); i++) { - char ch = name.charAt(i); + for (; i < name.length(); i++) { + char ch = name.charAt(i); + + if (isAlphaNum(ch)) { + builder.append(ch); + previousCh = ch; + } else if (ch == '.') { + if (previousCh == '_') { + // overwrite underscores that happen before periods + builder.setLength(builder.length() - 1); + } + builder.append(ch); + previousCh = ch; + } else { + if (previousCh != '.' && previousCh != '_') { + builder.append('_'); + previousCh = '_'; + } + } + } - if (isAlphaNum(ch)) { - builder.append(ch); - previousCh = ch; - } else if (ch == '.') { if (previousCh == '_') { - // overwrite underscores that happen before periods - builder.setLength(builder.length() - 1); + builder.setLength(builder.length() - 1); } - builder.append(ch); - previousCh = ch; - } else { - if (previousCh != '.' && previousCh != '_') { - builder.append('_'); - previousCh = '_'; - } - } + return builder.toString(); + } + + // fast isAlpha for ascii + private static boolean isAlpha(char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); } - if (previousCh == '_') { - builder.setLength(builder.length() - 1); + // fast isAlphaNumeric for ascii + private static boolean isAlphaNum(char ch) { + return isAlpha(ch) || (ch >= '0' && ch <= '9'); } - return builder.toString(); - } - - // fast isAlpha for ascii - private static boolean isAlpha(char ch) { - return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); - } - - // fast isAlphaNumeric for ascii - private static boolean isAlphaNum(char ch) { - return isAlpha(ch) || (ch >= '0' && ch <= '9'); - } } diff --git a/internal-api/src/main/java/datadog/trace/util/UnsafeUtils.java b/internal-api/src/main/java/datadog/trace/util/UnsafeUtils.java index fc9fdabccd8..d377f3699a9 100644 --- a/internal-api/src/main/java/datadog/trace/util/UnsafeUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/UnsafeUtils.java @@ -10,68 +10,68 @@ public abstract class UnsafeUtils { - private static final Logger log = LoggerFactory.getLogger(UnsafeUtils.class); + private static final Logger log = LoggerFactory.getLogger(UnsafeUtils.class); - private static final Unsafe UNSAFE = getUnsafe(); + private static final Unsafe UNSAFE = getUnsafe(); - private static Unsafe getUnsafe() { - try { - Field f = Unsafe.class.getDeclaredField("theUnsafe"); - f.setAccessible(true); - return (Unsafe) f.get(null); + private static Unsafe getUnsafe() { + try { + Field f = Unsafe.class.getDeclaredField("theUnsafe"); + f.setAccessible(true); + return (Unsafe) f.get(null); - } catch (Throwable t) { - log.debug("Unsafe is unavailable", t); - return null; + } catch (Throwable t) { + log.debug("Unsafe is unavailable", t); + return null; + } } - } - /** - * Tries to create a shallow clone of the provided object: another instance of the same class - * whose fields have the same values as the original (for reference fields that means referring to - * the same objects). - * - *

If cloning fails, the original instance is returned. - * - * @param original Original object - * @return A shallow clone - * @param Type of the object being cloned - */ - @SuppressFBWarnings( - value = "UNS_UNSAFE_CALL", - justification = "Intentional sun.misc.Unsafe wrapper for shallow cloning") - @SuppressWarnings("unchecked") - public static T tryShallowClone(T original) { - if (UNSAFE == null) { - log.debug("Unsafe is unavailable, {} will not be cloned", original); - return original; - } - try { - Class clazz = original.getClass(); - T clone = (T) UNSAFE.allocateInstance(clazz); + /** + * Tries to create a shallow clone of the provided object: another instance of the same class + * whose fields have the same values as the original (for reference fields that means referring to + * the same objects). + * + *

If cloning fails, the original instance is returned. + * + * @param original Original object + * @return A shallow clone + * @param Type of the object being cloned + */ + @SuppressFBWarnings( + value = "UNS_UNSAFE_CALL", + justification = "Intentional sun.misc.Unsafe wrapper for shallow cloning") + @SuppressWarnings("unchecked") + public static T tryShallowClone(T original) { + if (UNSAFE == null) { + log.debug("Unsafe is unavailable, {} will not be cloned", original); + return original; + } + try { + Class clazz = original.getClass(); + T clone = (T) UNSAFE.allocateInstance(clazz); - while (clazz != Object.class) { - cloneFields(clazz, original, clone); - clazz = clazz.getSuperclass(); - } - return clone; + while (clazz != Object.class) { + cloneFields(clazz, original, clone); + clazz = clazz.getSuperclass(); + } + return clone; - } catch (Throwable t) { - log.debug("Error while cloning {}: {}", original, t); - return original; + } catch (Throwable t) { + log.debug("Error while cloning {}: {}", original, t); + return original; + } } - } - // TODO: JEP 500 - avoid mutating final fields - @SuppressForbidden - private static void cloneFields(Class clazz, Object original, Object clone) throws Exception { - for (Field field : clazz.getDeclaredFields()) { - if ((field.getModifiers() & Modifier.STATIC) != 0) { - continue; - } - field.setAccessible(true); - Object fieldValue = field.get(original); - field.set(clone, fieldValue); + // TODO: JEP 500 - avoid mutating final fields + @SuppressForbidden + private static void cloneFields(Class clazz, Object original, Object clone) throws Exception { + for (Field field : clazz.getDeclaredFields()) { + if ((field.getModifiers() & Modifier.STATIC) != 0) { + continue; + } + field.setAccessible(true); + Object fieldValue = field.get(original); + field.set(clone, fieldValue); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/json/JsonPath.java b/internal-api/src/main/java/datadog/trace/util/json/JsonPath.java index 1c1c7221def..d77ccf6ce6e 100644 --- a/internal-api/src/main/java/datadog/trace/util/json/JsonPath.java +++ b/internal-api/src/main/java/datadog/trace/util/json/JsonPath.java @@ -6,170 +6,168 @@ public final class JsonPath { - public static class Builder { - private final List segments; + public static class Builder { + private final List segments; - public Builder() { - super(); - segments = new ArrayList<>(); - segments.add(Segment.Singleton.ROOT); - } + public Builder() { + super(); + segments = new ArrayList<>(); + segments.add(Segment.Singleton.ROOT); + } - public static Builder start() { - return new Builder(); - } + public static Builder start() { + return new Builder(); + } - public Builder name(String name) { - segments.add(new Segment.Name(name)); - return this; - } + public Builder name(String name) { + segments.add(new Segment.Name(name)); + return this; + } - public Builder index(int index) { - segments.add(new Segment.Index(index)); - return this; - } + public Builder index(int index) { + segments.add(new Segment.Index(index)); + return this; + } - public Builder anyDesc() { - segments.add(Segment.Singleton.DESCENDANT); - return this; - } + public Builder anyDesc() { + segments.add(Segment.Singleton.DESCENDANT); + return this; + } - public Builder anyChild() { - segments.add(Segment.Singleton.WILDCARD); - return this; - } + public Builder anyChild() { + segments.add(Segment.Singleton.WILDCARD); + return this; + } - public JsonPath build() { - return new JsonPath(segments); + public JsonPath build() { + return new JsonPath(segments); + } } - } - - private final Segment[] segments; - - private JsonPath(Collection segments) { - this.segments = segments.toArray(new Segment[0]); - } - - public boolean matches(PathCursor pathCursor) { - int i = segments.length - 1; - int j = pathCursor.length(); - while (i >= 0 && j >= 0 && segments[i].matches(jsonPathSegment(pathCursor, j))) { - i--; - j--; - if (segments[i + 1] == Segment.Singleton.DESCENDANT) { - int prevSearchSegmentPos = findPrevDescendantSegment(this, i); - int blockSize = i - prevSearchSegmentPos; - int offset2 = j - blockSize + 2; - while (offset2 > 0 - && !matchPathBlock(this, prevSearchSegmentPos + 1, pathCursor, offset2, blockSize)) { - offset2--; - } - i = prevSearchSegmentPos; - j = offset2 - 1; - } + + private final Segment[] segments; + + private JsonPath(Collection segments) { + this.segments = segments.toArray(new Segment[0]); + } + + public boolean matches(PathCursor pathCursor) { + int i = segments.length - 1; + int j = pathCursor.length(); + while (i >= 0 && j >= 0 && segments[i].matches(jsonPathSegment(pathCursor, j))) { + i--; + j--; + if (segments[i + 1] == Segment.Singleton.DESCENDANT) { + int prevSearchSegmentPos = findPrevDescendantSegment(this, i); + int blockSize = i - prevSearchSegmentPos; + int offset2 = j - blockSize + 2; + while (offset2 > 0 && !matchPathBlock(this, prevSearchSegmentPos + 1, pathCursor, offset2, blockSize)) { + offset2--; + } + i = prevSearchSegmentPos; + j = offset2 - 1; + } + } + return j < 0 && i < 0; } - return j < 0 && i < 0; - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - for (Segment segment : segments) { - sb.append(segment); + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (Segment segment : segments) { + sb.append(segment); + } + return sb.toString(); } - return sb.toString(); - } - - private boolean matchPathBlock( - JsonPath pattern, int offset1, PathCursor pathCursor, int offset2, int blockSize) { - for (int i = 0; i < blockSize; i++) { - if (!pattern.segments[offset1 + i].matches(jsonPathSegment(pathCursor, offset2 + i))) { - return false; - } + + private boolean matchPathBlock(JsonPath pattern, int offset1, PathCursor pathCursor, int offset2, int blockSize) { + for (int i = 0; i < blockSize; i++) { + if (!pattern.segments[offset1 + i].matches(jsonPathSegment(pathCursor, offset2 + i))) { + return false; + } + } + return true; } - return true; - } - private Object jsonPathSegment(PathCursor pathCursor, int index) { - if (index == 0) { - return null; + private Object jsonPathSegment(PathCursor pathCursor, int index) { + if (index == 0) { + return null; + } + return pathCursor.get(index - 1); } - return pathCursor.get(index - 1); - } - private int findPrevDescendantSegment(JsonPath pattern, int from) { - int i = from - 1; - while (i > 0) { - if (pattern.segments[i] == Segment.Singleton.DESCENDANT) { + private int findPrevDescendantSegment(JsonPath pattern, int from) { + int i = from - 1; + while (i > 0) { + if (pattern.segments[i] == Segment.Singleton.DESCENDANT) { + return i; + } + i--; + } return i; - } - i--; } - return i; - } - private abstract static class Segment { + private abstract static class Segment { - protected boolean matches(Object value) { - return this == Singleton.WILDCARD - || this == Singleton.DESCENDANT - || this == Singleton.ROOT && value == null; - } + protected boolean matches(Object value) { + return this == Singleton.WILDCARD + || this == Singleton.DESCENDANT + || this == Singleton.ROOT && value == null; + } - private static final class Singleton extends Segment { - private static final Segment ROOT = new Singleton("$"); - private static final Segment WILDCARD = new Singleton("[*]"); - private static final Segment DESCENDANT = new Singleton(".."); + private static final class Singleton extends Segment { + private static final Segment ROOT = new Singleton("$"); + private static final Segment WILDCARD = new Singleton("[*]"); + private static final Segment DESCENDANT = new Singleton(".."); - private final String repr; + private final String repr; - private Singleton(String repr) { - super(); - this.repr = repr; - } + private Singleton(String repr) { + super(); + this.repr = repr; + } - @Override - public String toString() { - return repr; - } - } + @Override + public String toString() { + return repr; + } + } - private static final class Name extends Segment { - private final String name; + private static final class Name extends Segment { + private final String name; - public Name(String name) { - super(); - this.name = name; - } + public Name(String name) { + super(); + this.name = name; + } - @Override - protected boolean matches(Object value) { - return value instanceof String && value.equals(name); - } + @Override + protected boolean matches(Object value) { + return value instanceof String && value.equals(name); + } - @Override - public String toString() { - return "['" + name + "']"; - } - } + @Override + public String toString() { + return "['" + name + "']"; + } + } - private static final class Index extends Segment { - private final int index; + private static final class Index extends Segment { + private final int index; - public Index(int index) { - super(); - this.index = index; - } + public Index(int index) { + super(); + this.index = index; + } - @Override - protected boolean matches(Object value) { - return value instanceof Integer && ((Integer) value) == index; - } + @Override + protected boolean matches(Object value) { + return value instanceof Integer && ((Integer) value) == index; + } - @Override - public String toString() { - return "[" + index + "]"; - } + @Override + public String toString() { + return "[" + index + "]"; + } + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/json/JsonPathParser.java b/internal-api/src/main/java/datadog/trace/util/json/JsonPathParser.java index 4bb2218da49..0651392b42c 100644 --- a/internal-api/src/main/java/datadog/trace/util/json/JsonPathParser.java +++ b/internal-api/src/main/java/datadog/trace/util/json/JsonPathParser.java @@ -10,327 +10,323 @@ public class JsonPathParser { - public static final class ParseError extends Exception { - public final int position; - public final String error; - - public ParseError(CharSequence path, int position, String error) { - super("Failed to parse JsonPath \"" + path + "\" at " + position + ". " + error); - this.position = position; - this.error = error; - } - } - - private static final char OPEN_BRACKET = '['; - private static final char CLOSE_BRACKET = ']'; - private static final char OPEN_BRACE = '('; - private static final char ASTERISK = '*'; - private static final char PERIOD = '.'; - private static final char SPACE = ' '; - private static final char COMMA = ','; - private static final char SINGLE_QUOTE = '\''; - private static final char DOUBLE_QUOTE = '"'; - private static final char ESC = '\\'; - - private static final Logger log = LoggerFactory.getLogger(JsonPathParser.class); - - public static List parseJsonPaths(List rules) { - if (null == rules || rules.isEmpty()) { - return Collections.emptyList(); - } - List result = new ArrayList<>(rules.size()); - for (String rule : rules) { - try { - JsonPath jp = parse(rule); - result.add(jp); - } catch (Exception ex) { - log.warn("Failed to parse redaction rule '{}'. {}. Skipping rule.", rule, ex.getMessage()); - } + public static final class ParseError extends Exception { + public final int position; + public final String error; + + public ParseError(CharSequence path, int position, String error) { + super("Failed to parse JsonPath \"" + path + "\" at " + position + ". " + error); + this.position = position; + this.error = error; + } } - return result; - } - public static JsonPath parse(String path) throws ParseError { - Cursor cur = new Cursor(path); + private static final char OPEN_BRACKET = '['; + private static final char CLOSE_BRACKET = ']'; + private static final char OPEN_BRACE = '('; + private static final char ASTERISK = '*'; + private static final char PERIOD = '.'; + private static final char SPACE = ' '; + private static final char COMMA = ','; + private static final char SINGLE_QUOTE = '\''; + private static final char DOUBLE_QUOTE = '"'; + private static final char ESC = '\\'; - if (!(path.startsWith("$.") || path.startsWith("$["))) { - cur.fail("JsonPath must start with '$.' or '$['"); + private static final Logger log = LoggerFactory.getLogger(JsonPathParser.class); + + public static List parseJsonPaths(List rules) { + if (null == rules || rules.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(rules.size()); + for (String rule : rules) { + try { + JsonPath jp = parse(rule); + result.add(jp); + } catch (Exception ex) { + log.warn("Failed to parse redaction rule '{}'. {}. Skipping rule.", rule, ex.getMessage()); + } + } + return result; } - JsonPath.Builder builder = JsonPath.Builder.start(); - cur.incPos(1); - - while (cur.isWithinLimits()) { - if (cur.is(OPEN_BRACKET)) { - boolean ok = - tryToParsePropertyInBrackets(cur, builder) - || tryToParseIndex(cur, builder) - || tryToParseWildcard(cur, builder); - if (!ok) { - cur.fail("Expecting in brackets a property, an array index, or a wildcard."); - } - } else if (cur.is(PERIOD)) { - if (cur.nextIs(PERIOD)) { - builder.anyDesc(); - cur.incPos(1); - } else if (cur.nextIs(OPEN_BRACKET)) { - cur.fail("'" + PERIOD + "' can't go before '" + OPEN_BRACKET + "'"); - } - if (!cur.hasMoreCharacters()) { - cur.fail("Path must not end with a '" + PERIOD + "'"); - } else { - cur.incPos(1); - } - if (cur.is(PERIOD)) { - cur.fail("More than two '" + PERIOD + "' in a row"); - } - } else if (cur.is(ASTERISK)) { - if (cur.nextIs(ASTERISK)) { - cur.fail("More than one '" + ASTERISK + "' in a row"); + public static JsonPath parse(String path) throws ParseError { + Cursor cur = new Cursor(path); + + if (!(path.startsWith("$.") || path.startsWith("$["))) { + cur.fail("JsonPath must start with '$.' or '$['"); } + + JsonPath.Builder builder = JsonPath.Builder.start(); cur.incPos(1); - builder.anyChild(); - } else { - parseKey(cur, builder); - } - } - return builder.build(); - } - - private static void parseKey(Cursor cur, JsonPath.Builder builder) throws ParseError { - int start = cur.position(); - int i = start; - int end = 0; - - while (cur.isWithinLimits(i)) { - char c = cur.charAt(i); - if (c == SPACE) { - cur.failAt(i, "No spaces allowed in property names."); - } else if (c == PERIOD || c == OPEN_BRACKET) { - end = i; - break; - } else if (c == OPEN_BRACE) { - cur.failAt(i, "Expressions are not supported."); - } - i++; - } - if (end == 0) { - end = cur.len(); - } - cur.setPos(end); + while (cur.isWithinLimits()) { + if (cur.is(OPEN_BRACKET)) { + boolean ok = tryToParsePropertyInBrackets(cur, builder) + || tryToParseIndex(cur, builder) + || tryToParseWildcard(cur, builder); + if (!ok) { + cur.fail("Expecting in brackets a property, an array index, or a wildcard."); + } + } else if (cur.is(PERIOD)) { + if (cur.nextIs(PERIOD)) { + builder.anyDesc(); + cur.incPos(1); + } else if (cur.nextIs(OPEN_BRACKET)) { + cur.fail("'" + PERIOD + "' can't go before '" + OPEN_BRACKET + "'"); + } + if (!cur.hasMoreCharacters()) { + cur.fail("Path must not end with a '" + PERIOD + "'"); + } else { + cur.incPos(1); + } + if (cur.is(PERIOD)) { + cur.fail("More than two '" + PERIOD + "' in a row"); + } + } else if (cur.is(ASTERISK)) { + if (cur.nextIs(ASTERISK)) { + cur.fail("More than one '" + ASTERISK + "' in a row"); + } + cur.incPos(1); + builder.anyChild(); + } else { + parseKey(cur, builder); + } + } + return builder.build(); + } + + private static void parseKey(Cursor cur, JsonPath.Builder builder) throws ParseError { + int start = cur.position(); + int i = start; + int end = 0; + + while (cur.isWithinLimits(i)) { + char c = cur.charAt(i); + if (c == SPACE) { + cur.failAt(i, "No spaces allowed in property names."); + } else if (c == PERIOD || c == OPEN_BRACKET) { + end = i; + break; + } else if (c == OPEN_BRACE) { + cur.failAt(i, "Expressions are not supported."); + } + i++; + } + if (end == 0) { + end = cur.len(); + } - String property = cur.subSequence(start, end).toString(); - builder.name(property); - } + cur.setPos(end); - private static boolean tryToParseWildcard(Cursor cur, JsonPath.Builder builder) - throws ParseError { - if (!cur.nextIsIgnoreSpaces(cur.pos, ASTERISK)) { - return false; + String property = cur.subSequence(start, end).toString(); + builder.name(property); } - int asteriskPos = cur.findCharSkipSpaces(cur.pos, ASTERISK); - if (!cur.nextIsIgnoreSpaces(asteriskPos, CLOSE_BRACKET)) { - int offset = asteriskPos + 1; - cur.failAt(offset, "Expected '" + CLOSE_BRACKET + "'"); - } - int closedAt = cur.findCharSkipSpaces(asteriskPos, CLOSE_BRACKET); - cur.setPos(closedAt + 1); - builder.anyChild(); - return true; - } - - private static boolean tryToParseIndex(Cursor cur, JsonPath.Builder builder) throws ParseError { - int start = cur.position() + 1; - int end = cur.indexOf(start, CLOSE_BRACKET); - - if (end < 0) { - return false; + + private static boolean tryToParseWildcard(Cursor cur, JsonPath.Builder builder) throws ParseError { + if (!cur.nextIsIgnoreSpaces(cur.pos, ASTERISK)) { + return false; + } + int asteriskPos = cur.findCharSkipSpaces(cur.pos, ASTERISK); + if (!cur.nextIsIgnoreSpaces(asteriskPos, CLOSE_BRACKET)) { + int offset = asteriskPos + 1; + cur.failAt(offset, "Expected '" + CLOSE_BRACKET + "'"); + } + int closedAt = cur.findCharSkipSpaces(asteriskPos, CLOSE_BRACKET); + cur.setPos(closedAt + 1); + builder.anyChild(); + return true; } - String expr = cur.subSequence(start, end).toString().trim(); + private static boolean tryToParseIndex(Cursor cur, JsonPath.Builder builder) throws ParseError { + int start = cur.position() + 1; + int end = cur.indexOf(start, CLOSE_BRACKET); - for (int i = 0; i < expr.length(); i++) { - char c = expr.charAt(i); - if (!isDigit(c)) { - return false; - } - } + if (end < 0) { + return false; + } - try { - int index = Integer.parseInt(expr); - builder.index(index); - } catch (NumberFormatException ex) { - cur.fail("Invalid array index. Must be an integer."); - } + String expr = cur.subSequence(start, end).toString().trim(); - cur.setPos(end + 1); + for (int i = 0; i < expr.length(); i++) { + char c = expr.charAt(i); + if (!isDigit(c)) { + return false; + } + } - return true; - } + try { + int index = Integer.parseInt(expr); + builder.index(index); + } catch (NumberFormatException ex) { + cur.fail("Invalid array index. Must be an integer."); + } - private static boolean tryToParsePropertyInBrackets(Cursor cur, JsonPath.Builder builder) - throws ParseError { - char quote = cur.findNonSpace(); - if (quote != SINGLE_QUOTE && quote != DOUBLE_QUOTE) { - return false; - } + cur.setPos(end + 1); - String property = null; - - int startPosition = cur.position() + 1; - int readPosition = startPosition; - int endPosition = 0; - boolean inProperty = false; - - while (cur.isWithinLimits(readPosition)) { - char c = cur.charAt(readPosition); - - if (ESC == c) { - cur.failAt(readPosition, "Escape character is not supported in property name."); - } else if (c == COMMA) { - String message = - inProperty - ? "Comma is not allowed in property name." - : "Multiple properties are not supported."; - cur.failAt(readPosition, message); - } else if (c == CLOSE_BRACKET && !inProperty) { - break; - } else if (c == quote) { - if (inProperty) { - endPosition = readPosition; - property = cur.subSequence(startPosition, endPosition).toString(); - inProperty = false; - } else { - startPosition = readPosition + 1; - inProperty = true; - } - } - readPosition++; + return true; } - if (inProperty) { - cur.fail("Property has not been closed - missing closing " + quote); - } + private static boolean tryToParsePropertyInBrackets(Cursor cur, JsonPath.Builder builder) throws ParseError { + char quote = cur.findNonSpace(); + if (quote != SINGLE_QUOTE && quote != DOUBLE_QUOTE) { + return false; + } - int endBracketIndex = cur.findCharSkipSpaces(endPosition, CLOSE_BRACKET); - if (endBracketIndex < 0) { - cur.fail("Property has not been closed - missing closing '" + CLOSE_BRACKET + "'"); - } - endBracketIndex++; + String property = null; + + int startPosition = cur.position() + 1; + int readPosition = startPosition; + int endPosition = 0; + boolean inProperty = false; + + while (cur.isWithinLimits(readPosition)) { + char c = cur.charAt(readPosition); + + if (ESC == c) { + cur.failAt(readPosition, "Escape character is not supported in property name."); + } else if (c == COMMA) { + String message = inProperty + ? "Comma is not allowed in property name." + : "Multiple properties are not supported."; + cur.failAt(readPosition, message); + } else if (c == CLOSE_BRACKET && !inProperty) { + break; + } else if (c == quote) { + if (inProperty) { + endPosition = readPosition; + property = cur.subSequence(startPosition, endPosition).toString(); + inProperty = false; + } else { + startPosition = readPosition + 1; + inProperty = true; + } + } + readPosition++; + } - cur.setPos(endBracketIndex); + if (inProperty) { + cur.fail("Property has not been closed - missing closing " + quote); + } - builder.name(property); - return true; - } + int endBracketIndex = cur.findCharSkipSpaces(endPosition, CLOSE_BRACKET); + if (endBracketIndex < 0) { + cur.fail("Property has not been closed - missing closing '" + CLOSE_BRACKET + "'"); + } + endBracketIndex++; - private static class Cursor { - private final CharSequence data; - private int pos; - private final int endPos; + cur.setPos(endBracketIndex); - public Cursor(CharSequence data) { - this.data = data; - this.pos = 0; - this.endPos = data.length() - 1; + builder.name(property); + return true; } - public int len() { - return endPos + 1; - } + private static class Cursor { + private final CharSequence data; + private int pos; + private final int endPos; - public char charAt(int idx) { - return data.charAt(idx); - } + public Cursor(CharSequence data) { + this.data = data; + this.pos = 0; + this.endPos = data.length() - 1; + } - public boolean is(char c) { - return data.charAt(pos) == c; - } + public int len() { + return endPos + 1; + } - public boolean nextIs(char c) { - return isWithinLimits(pos + 1) && (data.charAt(pos + 1) == c); - } + public char charAt(int idx) { + return data.charAt(idx); + } - public void incPos(int count) { - pos += count; - } + public boolean is(char c) { + return data.charAt(pos) == c; + } - public void setPos(int newPos) { - pos = newPos; - } + public boolean nextIs(char c) { + return isWithinLimits(pos + 1) && (data.charAt(pos + 1) == c); + } - public int position() { - return pos; - } + public void incPos(int count) { + pos += count; + } - public int findCharSkipSpaces(int startAt, char c) { - int p = startAt == endPos ? startAt : skipSpaces(startAt + 1); - if (charAt(p) == c) { - return p; - } else { - return -1; - } - } + public void setPos(int newPos) { + pos = newPos; + } - private int skipSpaces(int p) { - while (isWithinLimits(p + 1) && charAt(p) == SPACE) { - p++; - } - return p; - } + public int position() { + return pos; + } - public int indexOf(int start, char c) { - int p = start; - while (isWithinLimits(p)) { - if (charAt(p) == c) { - return p; + public int findCharSkipSpaces(int startAt, char c) { + int p = startAt == endPos ? startAt : skipSpaces(startAt + 1); + if (charAt(p) == c) { + return p; + } else { + return -1; + } } - p++; - } - return -1; - } - public boolean nextIsIgnoreSpaces(int start, char c) { - int p = skipSpaces(start + 1); - return isWithinLimits(p) && charAt(p) == c; - } + private int skipSpaces(int p) { + while (isWithinLimits(p + 1) && charAt(p) == SPACE) { + p++; + } + return p; + } - public char findNonSpace() { - return findNonSpace(pos); - } + public int indexOf(int start, char c) { + int p = start; + while (isWithinLimits(p)) { + if (charAt(p) == c) { + return p; + } + p++; + } + return -1; + } - public char findNonSpace(int start) { - int p = skipSpaces(start + 1); - if (isWithinLimits(p)) { - return charAt(p); - } else { - return SPACE; - } - } + public boolean nextIsIgnoreSpaces(int start, char c) { + int p = skipSpaces(start + 1); + return isWithinLimits(p) && charAt(p) == c; + } - private boolean isWithinLimits() { - return pos <= endPos; - } + public char findNonSpace() { + return findNonSpace(pos); + } - public boolean hasMoreCharacters() { - return isWithinLimits(pos + 1); - } + public char findNonSpace(int start) { + int p = skipSpaces(start + 1); + if (isWithinLimits(p)) { + return charAt(p); + } else { + return SPACE; + } + } - public boolean isWithinLimits(int p) { - return p >= 0 && p <= endPos; - } + private boolean isWithinLimits() { + return pos <= endPos; + } - public CharSequence subSequence(int start, int end) { - return data.subSequence(start, end); - } + public boolean hasMoreCharacters() { + return isWithinLimits(pos + 1); + } - private void fail(String message) throws ParseError { - failAt(this.pos, message); - } + public boolean isWithinLimits(int p) { + return p >= 0 && p <= endPos; + } - private void failAt(int position, String message) throws ParseError { - throw new ParseError(this.data, position, message); + public CharSequence subSequence(int start, int end) { + return data.subSequence(start, end); + } + + private void fail(String message) throws ParseError { + failAt(this.pos, message); + } + + private void failAt(int position, String message) throws ParseError { + throw new ParseError(this.data, position, message); + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/json/PathCursor.java b/internal-api/src/main/java/datadog/trace/util/json/PathCursor.java index 5048c724e01..a33102b32b1 100644 --- a/internal-api/src/main/java/datadog/trace/util/json/PathCursor.java +++ b/internal-api/src/main/java/datadog/trace/util/json/PathCursor.java @@ -4,82 +4,82 @@ /** Represents a mutable path in a hierarchical structure such as JSON. */ public class PathCursor { - private final Object[] path; - private int length; + private final Object[] path; + private int length; - public PathCursor(int capacity) { - super(); - path = new Object[capacity]; - length = 0; - } + public PathCursor(int capacity) { + super(); + path = new Object[capacity]; + length = 0; + } - public PathCursor(Object[] path, int capacity) { - super(); - assert path.length <= capacity; - this.path = new Object[capacity]; - System.arraycopy(path, 0, this.path, 0, path.length); - length = path.length; - } + public PathCursor(Object[] path, int capacity) { + super(); + assert path.length <= capacity; + this.path = new Object[capacity]; + System.arraycopy(path, 0, this.path, 0, path.length); + length = path.length; + } - private PathCursor(PathCursor that) { - super(); - path = Arrays.copyOf(that.path, that.path.length); - length = that.length; - } + private PathCursor(PathCursor that) { + super(); + path = Arrays.copyOf(that.path, that.path.length); + length = that.length; + } - public PathCursor push(String name) { - assert length < path.length; - path[length] = name; - length += 1; - return this; - } + public PathCursor push(String name) { + assert length < path.length; + path[length] = name; + length += 1; + return this; + } - public PathCursor push(int index) { - assert length < path.length; - path[length] = index; - length += 1; - return this; - } + public PathCursor push(int index) { + assert length < path.length; + path[length] = index; + length += 1; + return this; + } - public void pop() { - if (length > 0) { - length -= 1; + public void pop() { + if (length > 0) { + length -= 1; + } } - } - public void advance() { - if (length > 0) { - Object last = path[length - 1]; - if (last instanceof Integer) { - path[length - 1] = (int) last + 1; - } else { - length -= 1; - } + public void advance() { + if (length > 0) { + Object last = path[length - 1]; + if (last instanceof Integer) { + path[length - 1] = (int) last + 1; + } else { + length -= 1; + } + } } - } - public PathCursor copy() { - return new PathCursor(this); - } + public PathCursor copy() { + return new PathCursor(this); + } - public int length() { - return length; - } + public int length() { + return length; + } - Object get(int i) { - return path[i]; - } + Object get(int i) { + return path[i]; + } - public String toString(String prefix) { - StringBuilder sb = new StringBuilder(prefix); - for (int i = 0; i < length; i++) { - sb.append('.'); - sb.append(String.valueOf(path[i]).replace(".", "\\.")); + public String toString(String prefix) { + StringBuilder sb = new StringBuilder(prefix); + for (int i = 0; i < length; i++) { + sb.append('.'); + sb.append(String.valueOf(path[i]).replace(".", "\\.")); + } + return sb.toString(); } - return sb.toString(); - } - public Object[] toPath() { - return Arrays.copyOf(path, length); - } + public Object[] toPath() { + return Arrays.copyOf(path, length); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java index 34a002f47bb..45ae05754c6 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/AbstractStackWalker.java @@ -5,21 +5,21 @@ public abstract class AbstractStackWalker implements StackWalker { - @Override - public T walk(Function, T> consumer) { - return doGetStack(input -> consumer.apply(doFilterStack(input))); - } + @Override + public T walk(Function, T> consumer) { + return doGetStack(input -> consumer.apply(doFilterStack(input))); + } - final Stream doFilterStack(Stream stream) { - return stream.filter(AbstractStackWalker::isNotDatadogTraceStackElement); - } + final Stream doFilterStack(Stream stream) { + return stream.filter(AbstractStackWalker::isNotDatadogTraceStackElement); + } - abstract T doGetStack(Function, T> consumer); + abstract T doGetStack(Function, T> consumer); - public static boolean isNotDatadogTraceStackElement(final StackTraceElement el) { - final String clazz = el.getClassName(); - return !clazz.startsWith("datadog.trace.") - && !clazz.startsWith("com.datadog.iast.") - && !clazz.startsWith("com.datadog.appsec."); - } + public static boolean isNotDatadogTraceStackElement(final StackTraceElement el) { + final String clazz = el.getClassName(); + return !clazz.startsWith("datadog.trace.") + && !clazz.startsWith("com.datadog.iast.") + && !clazz.startsWith("com.datadog.appsec."); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/DefaultStackWalker.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/DefaultStackWalker.java index a19d0b702bf..c01ff304eb7 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/DefaultStackWalker.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/DefaultStackWalker.java @@ -6,15 +6,15 @@ public class DefaultStackWalker extends AbstractStackWalker { - DefaultStackWalker() {} + DefaultStackWalker() {} - @Override - public boolean isEnabled() { - return true; - } + @Override + public boolean isEnabled() { + return true; + } - @Override - T doGetStack(final Function, T> consumer) { - return consumer.apply(Arrays.stream(new Throwable().getStackTrace())); - } + @Override + T doGetStack(final Function, T> consumer) { + return consumer.apply(Arrays.stream(new Throwable().getStackTrace())); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackTraceIterator.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackTraceIterator.java index 799079a2818..cf99188715e 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackTraceIterator.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackTraceIterator.java @@ -5,36 +5,35 @@ public class HotSpotStackTraceIterator implements Iterator { - private int index = 0; + private int index = 0; - private final int size; + private final int size; - private final Throwable throwable; + private final Throwable throwable; - private final sun.misc.JavaLangAccess access; + private final sun.misc.JavaLangAccess access; - public HotSpotStackTraceIterator( - final Throwable throwable, final sun.misc.JavaLangAccess access) { - this.throwable = throwable; - this.access = access; - size = this.access.getStackTraceDepth(throwable); - } + public HotSpotStackTraceIterator(final Throwable throwable, final sun.misc.JavaLangAccess access) { + this.throwable = throwable; + this.access = access; + size = this.access.getStackTraceDepth(throwable); + } - @Override - public boolean hasNext() { - return index < size; - } + @Override + public boolean hasNext() { + return index < size; + } - @Override - public StackTraceElement next() { - if (index >= size) { - throw new NoSuchElementException(); + @Override + public StackTraceElement next() { + if (index >= size) { + throw new NoSuchElementException(); + } + return access.getStackTraceElement(throwable, index++); } - return access.getStackTraceElement(throwable, index++); - } - @Override - public void remove() { - throw new UnsupportedOperationException(); - } + @Override + public void remove() { + throw new UnsupportedOperationException(); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackWalker.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackWalker.java index 6621478b502..2aa92bf0df4 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackWalker.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/HotSpotStackWalker.java @@ -7,33 +7,34 @@ import java.util.stream.StreamSupport; public class HotSpotStackWalker extends AbstractStackWalker { - @SuppressForbidden sun.misc.JavaLangAccess access; + @SuppressForbidden + sun.misc.JavaLangAccess access; - @SuppressForbidden - HotSpotStackWalker() { - try { - access = sun.misc.SharedSecrets.getJavaLangAccess(); - } catch (Throwable ignored) { + @SuppressForbidden + HotSpotStackWalker() { + try { + access = sun.misc.SharedSecrets.getJavaLangAccess(); + } catch (Throwable ignored) { + } } - } - @Override - public boolean isEnabled() { - try { - if (JavaVirtualMachine.isJavaVersion(8) && access != null) { - access.getStackTraceElement(new Throwable(), 0); - return true; - } - } catch (Throwable localThrowable) { + @Override + public boolean isEnabled() { + try { + if (JavaVirtualMachine.isJavaVersion(8) && access != null) { + access.getStackTraceElement(new Throwable(), 0); + return true; + } + } catch (Throwable localThrowable) { + } + return false; } - return false; - } - @Override - T doGetStack(Function, T> consumer) { + @Override + T doGetStack(Function, T> consumer) { - Throwable throwable = new Throwable(); - Iterable iterable = () -> new HotSpotStackTraceIterator(throwable, access); - return consumer.apply(StreamSupport.stream(iterable.spliterator(), false)); - } + Throwable throwable = new Throwable(); + Iterable iterable = () -> new HotSpotStackTraceIterator(throwable, access); + return consumer.apply(StreamSupport.stream(iterable.spliterator(), false)); + } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceEvent.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceEvent.java index 363a9dca1c8..348b1715ddc 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceEvent.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceEvent.java @@ -6,41 +6,48 @@ public class StackTraceEvent { - public static final String DEFAULT_LANGUAGE = "java"; - - @Nullable private final String language; - @Nullable private final String id; - @Nullable private final String message; - @Nonnull private final List frames; - - public StackTraceEvent( - @Nonnull final List frames, - @Nullable final String language, - @Nullable final String id, - @Nullable final String message) { - this.language = language; - this.id = id; - this.message = message; - this.frames = frames; - } - - @Nullable - public String getLanguage() { - return language; - } - - @Nullable - public String getId() { - return id; - } - - @Nullable - public String getMessage() { - return message; - } - - @Nonnull - public List getFrames() { - return frames; - } + public static final String DEFAULT_LANGUAGE = "java"; + + @Nullable + private final String language; + + @Nullable + private final String id; + + @Nullable + private final String message; + + @Nonnull + private final List frames; + + public StackTraceEvent( + @Nonnull final List frames, + @Nullable final String language, + @Nullable final String id, + @Nullable final String message) { + this.language = language; + this.id = id; + this.message = message; + this.frames = frames; + } + + @Nullable + public String getLanguage() { + return language; + } + + @Nullable + public String getId() { + return id; + } + + @Nullable + public String getMessage() { + return message; + } + + @Nonnull + public List getFrames() { + return frames; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceFrame.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceFrame.java index 0e1f385596c..958d0e9f36c 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceFrame.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackTraceFrame.java @@ -4,65 +4,74 @@ public class StackTraceFrame { - // Unsigned integer: index of the stack frame (0 = top of stack) - private final int id; - // Raw stack frame - @Nullable private final String text; - @Nullable private final String file; - @Nullable private final Integer line; - @Nullable private final String class_name; - @Nullable private final String function; + // Unsigned integer: index of the stack frame (0 = top of stack) + private final int id; + // Raw stack frame + @Nullable + private final String text; - public StackTraceFrame(final int id, final StackTraceElement element) { - this.id = id; - this.text = element.toString(); - this.file = element.getFileName(); - this.line = element.getLineNumber(); - this.class_name = element.getClassName(); - this.function = element.getMethodName(); - } + @Nullable + private final String file; - public StackTraceFrame( - final int id, - @Nullable final String text, - @Nullable final String file, - @Nullable final Integer line, - @Nullable final String class_name, - @Nullable final String function) { - this.id = id; - this.text = text; - this.file = file; - this.line = line; - this.class_name = class_name; - this.function = function; - } + @Nullable + private final Integer line; - public int getId() { - return id; - } + @Nullable + private final String class_name; - @Nullable - public String getText() { - return text; - } + @Nullable + private final String function; - @Nullable - public String getFile() { - return file; - } + public StackTraceFrame(final int id, final StackTraceElement element) { + this.id = id; + this.text = element.toString(); + this.file = element.getFileName(); + this.line = element.getLineNumber(); + this.class_name = element.getClassName(); + this.function = element.getMethodName(); + } - @Nullable - public Integer getLine() { - return line; - } + public StackTraceFrame( + final int id, + @Nullable final String text, + @Nullable final String file, + @Nullable final Integer line, + @Nullable final String class_name, + @Nullable final String function) { + this.id = id; + this.text = text; + this.file = file; + this.line = line; + this.class_name = class_name; + this.function = function; + } - @Nullable - public String getClass_name() { - return class_name; - } + public int getId() { + return id; + } - @Nullable - public String getFunction() { - return function; - } + @Nullable + public String getText() { + return text; + } + + @Nullable + public String getFile() { + return file; + } + + @Nullable + public Integer getLine() { + return line; + } + + @Nullable + public String getClass_name() { + return class_name; + } + + @Nullable + public String getFunction() { + return function; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackUtils.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackUtils.java index 35ad5151054..47315302a7f 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackUtils.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackUtils.java @@ -15,97 +15,87 @@ public abstract class StackUtils { - public static final String META_STRUCT_KEY = "_dd.stack"; - - public static E update( - final E exception, final Function filter) { - final StackTraceElement[] stack = exception.getStackTrace(); - exception.setStackTrace(filter.apply(stack)); - return exception; - } - - public static E filter( - final E exception, final Predicate filter) { - return update( - exception, stack -> Arrays.stream(stack).filter(filter).toArray(StackTraceElement[]::new)); - } - - public static E filterFirst( - final E exception, final Predicate filter) { - return filter(exception, new OneTimePredicate<>(filter)); - } - - public static E filterDatadog(final E exception) { - return filter(exception, AbstractStackWalker::isNotDatadogTraceStackElement); - } - - public static E filterFirstDatadog(final E exception) { - return filterFirst(exception, AbstractStackWalker::isNotDatadogTraceStackElement); - } - - public static E filterUntil( - final E exception, final Predicate trace) { - return update( - exception, - stack -> { - final StackTraceElement[] source = exception.getStackTrace(); - for (int i = 0; i < source.length; i++) { - if (trace.test(source[i])) { - final StackTraceElement[] result = new StackTraceElement[source.length - i - 1]; - System.arraycopy(source, i + 1, result, 0, result.length); - return result; + public static final String META_STRUCT_KEY = "_dd.stack"; + + public static E update( + final E exception, final Function filter) { + final StackTraceElement[] stack = exception.getStackTrace(); + exception.setStackTrace(filter.apply(stack)); + return exception; + } + + public static E filter(final E exception, final Predicate filter) { + return update(exception, stack -> Arrays.stream(stack).filter(filter).toArray(StackTraceElement[]::new)); + } + + public static E filterFirst(final E exception, final Predicate filter) { + return filter(exception, new OneTimePredicate<>(filter)); + } + + public static E filterDatadog(final E exception) { + return filter(exception, AbstractStackWalker::isNotDatadogTraceStackElement); + } + + public static E filterFirstDatadog(final E exception) { + return filterFirst(exception, AbstractStackWalker::isNotDatadogTraceStackElement); + } + + public static E filterUntil(final E exception, final Predicate trace) { + return update(exception, stack -> { + final StackTraceElement[] source = exception.getStackTrace(); + for (int i = 0; i < source.length; i++) { + if (trace.test(source[i])) { + final StackTraceElement[] result = new StackTraceElement[source.length - i - 1]; + System.arraycopy(source, i + 1, result, 0, result.length); + return result; + } } - } - return source; + return source; }); - } - - public static List generateUserCodeStackTrace() { - return generateUserCodeStackTrace(AbstractStackWalker::isNotDatadogTraceStackElement); - } - - /** Function generates stack trace of the user code (excluding datadog classes) */ - public static List generateUserCodeStackTrace( - final Predicate filterPredicate) { - int stackCapacity = Config.get().getAppSecMaxStackTraceDepth(); - List elements = - StackWalkerFactory.INSTANCE.walk( - stream -> - stream.filter(filterPredicate).limit(stackCapacity).collect(Collectors.toList())); - return IntStream.range(0, elements.size()) - .mapToObj(idx -> new StackTraceFrame(idx, elements.get(idx))) - .collect(Collectors.toList()); - } - - public static void addStacktraceEventsToMetaStruct( - final RequestContext reqCtx, final String productKey, final List events) { - final Map> stackTraceBatch = - reqCtx.getOrCreateMetaStructTop(META_STRUCT_KEY, k -> new ConcurrentHashMap<>()); - final List list = - stackTraceBatch.computeIfAbsent( - productKey, k -> Collections.synchronizedList(new ArrayList<>())); - list.addAll(events); - } - - private static class OneTimePredicate implements Predicate { - - private final Predicate delegate; - private boolean filtered; - - private OneTimePredicate(final Predicate delegate) { - this.delegate = delegate; } - @Override - public boolean test(final T item) { - if (filtered) { - return true; - } - final boolean test = delegate.test(item); - if (!test) { - filtered = true; - } - return test; + public static List generateUserCodeStackTrace() { + return generateUserCodeStackTrace(AbstractStackWalker::isNotDatadogTraceStackElement); + } + + /** Function generates stack trace of the user code (excluding datadog classes) */ + public static List generateUserCodeStackTrace(final Predicate filterPredicate) { + int stackCapacity = Config.get().getAppSecMaxStackTraceDepth(); + List elements = StackWalkerFactory.INSTANCE.walk( + stream -> stream.filter(filterPredicate).limit(stackCapacity).collect(Collectors.toList())); + return IntStream.range(0, elements.size()) + .mapToObj(idx -> new StackTraceFrame(idx, elements.get(idx))) + .collect(Collectors.toList()); + } + + public static void addStacktraceEventsToMetaStruct( + final RequestContext reqCtx, final String productKey, final List events) { + final Map> stackTraceBatch = + reqCtx.getOrCreateMetaStructTop(META_STRUCT_KEY, k -> new ConcurrentHashMap<>()); + final List list = + stackTraceBatch.computeIfAbsent(productKey, k -> Collections.synchronizedList(new ArrayList<>())); + list.addAll(events); + } + + private static class OneTimePredicate implements Predicate { + + private final Predicate delegate; + private boolean filtered; + + private OneTimePredicate(final Predicate delegate) { + this.delegate = delegate; + } + + @Override + public boolean test(final T item) { + if (filtered) { + return true; + } + final boolean test = delegate.test(item); + if (!test) { + filtered = true; + } + return test; + } } - } } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalker.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalker.java index 9364fb306ab..ec7ffee836b 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalker.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalker.java @@ -5,8 +5,8 @@ public interface StackWalker { - boolean isEnabled(); + boolean isEnabled(); - /** StackTrace should be returned without any element from the dd-trace-java-agent itself. */ - T walk(Function, T> consumer); + /** StackTrace should be returned without any element from the dd-trace-java-agent itself. */ + T walk(Function, T> consumer); } diff --git a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalkerFactory.java b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalkerFactory.java index bd9357260d9..768b88ba188 100644 --- a/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalkerFactory.java +++ b/internal-api/src/main/java/datadog/trace/util/stacktrace/StackWalkerFactory.java @@ -11,47 +11,44 @@ @SuppressForbidden public class StackWalkerFactory { - private static final Logger LOGGER = LoggerFactory.getLogger(StackWalkerFactory.class); - - public static final StackWalker INSTANCE; - - static { - Stream stream = Stream.of(hotspot(), jdk9()).map(Supplier::get); - INSTANCE = - stream - .filter(Objects::nonNull) - .filter(StackWalker::isEnabled) - .findFirst() - .orElseGet(defaultStackWalker()); - } - - private static Supplier defaultStackWalker() { - return DefaultStackWalker::new; - } - - private static Supplier hotspot() { - return () -> { - if (!JavaVirtualMachine.isJavaVersion(8)) { - return null; - } - return new HotSpotStackWalker(); - }; - } - - private static Supplier jdk9() { - return () -> { - if (!JavaVirtualMachine.isJavaVersionAtLeast(9)) { - return null; - } - try { - return (StackWalker) - Class.forName("datadog.trace.util.stacktrace.JDK9StackWalker") - .getDeclaredConstructor() - .newInstance(); - } catch (Throwable e) { - LOGGER.warn("JDK9StackWalker not available", e); - return null; - } - }; - } + private static final Logger LOGGER = LoggerFactory.getLogger(StackWalkerFactory.class); + + public static final StackWalker INSTANCE; + + static { + Stream stream = Stream.of(hotspot(), jdk9()).map(Supplier::get); + INSTANCE = stream.filter(Objects::nonNull) + .filter(StackWalker::isEnabled) + .findFirst() + .orElseGet(defaultStackWalker()); + } + + private static Supplier defaultStackWalker() { + return DefaultStackWalker::new; + } + + private static Supplier hotspot() { + return () -> { + if (!JavaVirtualMachine.isJavaVersion(8)) { + return null; + } + return new HotSpotStackWalker(); + }; + } + + private static Supplier jdk9() { + return () -> { + if (!JavaVirtualMachine.isJavaVersionAtLeast(9)) { + return null; + } + try { + return (StackWalker) Class.forName("datadog.trace.util.stacktrace.JDK9StackWalker") + .getDeclaredConstructor() + .newInstance(); + } catch (Throwable e) { + LOGGER.warn("JDK9StackWalker not available", e); + return null; + } + }; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/throwable/FatalAgentMisconfigurationError.java b/internal-api/src/main/java/datadog/trace/util/throwable/FatalAgentMisconfigurationError.java index 5feb71d1603..8f375f8f3b6 100644 --- a/internal-api/src/main/java/datadog/trace/util/throwable/FatalAgentMisconfigurationError.java +++ b/internal-api/src/main/java/datadog/trace/util/throwable/FatalAgentMisconfigurationError.java @@ -9,7 +9,7 @@ * renaming or moving it. */ public class FatalAgentMisconfigurationError extends Error { - public FatalAgentMisconfigurationError(String message) { - super(message); - } + public FatalAgentMisconfigurationError(String message) { + super(message); + } } diff --git a/internal-api/src/test/groovy/datadog/trace/api/env/FixedCapturedEnvironment.java b/internal-api/src/test/groovy/datadog/trace/api/env/FixedCapturedEnvironment.java index cccc9f0fda6..aaf1094136a 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/env/FixedCapturedEnvironment.java +++ b/internal-api/src/test/groovy/datadog/trace/api/env/FixedCapturedEnvironment.java @@ -4,10 +4,10 @@ /** Helper class that has access to {@link CapturedEnvironment} */ public class FixedCapturedEnvironment { - private FixedCapturedEnvironment() {} + private FixedCapturedEnvironment() {} - /** Load properties instance into the {@code CapturedEnvironment} instance. */ - public static void useFixedEnv(final Map properties) { - CapturedEnvironment.useFixedEnv(properties); - } + /** Load properties instance into the {@code CapturedEnvironment} instance. */ + public static void useFixedEnv(final Map properties) { + CapturedEnvironment.useFixedEnv(properties); + } } diff --git a/internal-api/src/test/groovy/datadog/trace/api/telemetry/ConfigInversionMetricCollectorTestHelper.java b/internal-api/src/test/groovy/datadog/trace/api/telemetry/ConfigInversionMetricCollectorTestHelper.java index 841ca68e26b..2b22905048a 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/telemetry/ConfigInversionMetricCollectorTestHelper.java +++ b/internal-api/src/test/groovy/datadog/trace/api/telemetry/ConfigInversionMetricCollectorTestHelper.java @@ -7,15 +7,14 @@ // Lightweight helper class to simulate Config Inversion ConfigHelper scenario where telemetry // metrics are emitted for "unknown" environment variables. public class ConfigInversionMetricCollectorTestHelper { - private static final Set SUPPORTED_ENV_VARS = - new HashSet<>(Arrays.asList("DD_ENV", "DD_SERVICE")); + private static final Set SUPPORTED_ENV_VARS = new HashSet<>(Arrays.asList("DD_ENV", "DD_SERVICE")); - private static final ConfigInversionMetricCollectorImpl configInversionMetricCollector = - ConfigInversionMetricCollectorImpl.getInstance(); + private static final ConfigInversionMetricCollectorImpl configInversionMetricCollector = + ConfigInversionMetricCollectorImpl.getInstance(); - public static void checkAndEmitUnsupported(String envVarName) { - if (!SUPPORTED_ENV_VARS.contains(envVarName)) { - configInversionMetricCollector.setUndocumentedEnvVarMetric(envVarName); + public static void checkAndEmitUnsupported(String envVarName) { + if (!SUPPORTED_ENV_VARS.contains(envVarName)) { + configInversionMetricCollector.setUndocumentedEnvVarMetric(envVarName); + } } - } } diff --git a/internal-api/src/test/java/constructors/InaccessibleConstructor.java b/internal-api/src/test/java/constructors/InaccessibleConstructor.java index c35175b9d92..17b569aded9 100644 --- a/internal-api/src/test/java/constructors/InaccessibleConstructor.java +++ b/internal-api/src/test/java/constructors/InaccessibleConstructor.java @@ -1,5 +1,5 @@ package constructors; public class InaccessibleConstructor { - private InaccessibleConstructor() {} + private InaccessibleConstructor() {} } diff --git a/internal-api/src/test/java/constructors/NoDefaultConstructor.java b/internal-api/src/test/java/constructors/NoDefaultConstructor.java index d12f5642e8b..c04273413fa 100644 --- a/internal-api/src/test/java/constructors/NoDefaultConstructor.java +++ b/internal-api/src/test/java/constructors/NoDefaultConstructor.java @@ -2,5 +2,5 @@ public class NoDefaultConstructor { - public NoDefaultConstructor(Object foo) {} + public NoDefaultConstructor(Object foo) {} } diff --git a/internal-api/src/test/java/constructors/ThrowingConstructor.java b/internal-api/src/test/java/constructors/ThrowingConstructor.java index 7e3219edc6c..5a9ecb1926b 100644 --- a/internal-api/src/test/java/constructors/ThrowingConstructor.java +++ b/internal-api/src/test/java/constructors/ThrowingConstructor.java @@ -1,7 +1,7 @@ package constructors; public class ThrowingConstructor { - public ThrowingConstructor() { - throw new RuntimeException(); - } + public ThrowingConstructor() { + throw new RuntimeException(); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigAIGuardRedactionTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigAIGuardRedactionTest.java index 338b2e30145..a12a4a0b228 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigAIGuardRedactionTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigAIGuardRedactionTest.java @@ -11,46 +11,46 @@ @ExtendWith(WithConfigExtension.class) class ConfigAIGuardRedactionTest { - @Test - void redactionIsEnabledByDefault() { - assertTrue(Config.get().isAiGuardRedactionEnabled()); - } - - @Test - void killSwitchDisablesRedactionViaSystemProperty() { - WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "false"); - - assertFalse(Config.get().isAiGuardRedactionEnabled()); - } - - @Test - void killSwitchDisablesRedactionViaEnvironmentVariable() { - WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); - - assertFalse(Config.get().isAiGuardRedactionEnabled()); - } - - @Test - void killSwitchAcceptsNumericAndMixedCaseValues() { - WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "0"); - assertFalse(Config.get().isAiGuardRedactionEnabled()); - - WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "FALSE"); - assertFalse(Config.get().isAiGuardRedactionEnabled()); - - WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "True"); - assertTrue(Config.get().isAiGuardRedactionEnabled()); - } - - /** - * Invalid boolean values resolve to {@code false} rather than to the configured default: see the - * backward-compatibility branch in {@code ConfigProvider#get}. A typo therefore turns redaction - * off, even though it defaults to on. - */ - @Test - void unparseableValueDisablesRedaction() { - WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "not-a-boolean"); - - assertFalse(Config.get().isAiGuardRedactionEnabled()); - } + @Test + void redactionIsEnabledByDefault() { + assertTrue(Config.get().isAiGuardRedactionEnabled()); + } + + @Test + void killSwitchDisablesRedactionViaSystemProperty() { + WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "false"); + + assertFalse(Config.get().isAiGuardRedactionEnabled()); + } + + @Test + void killSwitchDisablesRedactionViaEnvironmentVariable() { + WithConfigExtension.injectEnvConfig("DD_AI_GUARD_REDACTION_ENABLED", "false", false); + + assertFalse(Config.get().isAiGuardRedactionEnabled()); + } + + @Test + void killSwitchAcceptsNumericAndMixedCaseValues() { + WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "0"); + assertFalse(Config.get().isAiGuardRedactionEnabled()); + + WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "FALSE"); + assertFalse(Config.get().isAiGuardRedactionEnabled()); + + WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "True"); + assertTrue(Config.get().isAiGuardRedactionEnabled()); + } + + /** + * Invalid boolean values resolve to {@code false} rather than to the configured default: see the + * backward-compatibility branch in {@code ConfigProvider#get}. A typo therefore turns redaction + * off, even though it defaults to on. + */ + @Test + void unparseableValueDisablesRedaction() { + WithConfigExtension.injectSysConfig(AI_GUARD_REDACTION_ENABLED, "not-a-boolean"); + + assertFalse(Config.get().isAiGuardRedactionEnabled()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigCodeCoverageFlagsTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigCodeCoverageFlagsTest.java index 0bf89156176..7ff861434b5 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigCodeCoverageFlagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigCodeCoverageFlagsTest.java @@ -21,78 +21,77 @@ @ExtendWith(WithConfigExtension.class) class ConfigCodeCoverageFlagsTest { - @Test - void defaultsToNoFlags() { - assertEquals(Collections.emptyList(), Config.get().getCodeCoverageFlags()); - } - - @Test - void removesWhitespaceAndEmptyFlagsWhilePreservingOrderAndDuplicates() { - Config config = configWithFlags(" type:unit-tests, ,jvm-21,, type:unit-tests "); - - assertEquals( - Arrays.asList("type:unit-tests", "jvm-21", "type:unit-tests"), - config.getCodeCoverageFlags()); - } - - @Test - void readsFlagsFromCanonicalEnvironmentVariable() { - WithConfigExtension.injectEnvConfig("DD_CODE_COVERAGE_FLAGS", "type:unit-tests,jvm-21", false); - - assertEquals(Arrays.asList("type:unit-tests", "jvm-21"), Config.get().getCodeCoverageFlags()); - } - - @Test - void producesNoFlagsForEmptyInput() { - assertEquals(Collections.emptyList(), configWithFlags(" , , ").getCodeCoverageFlags()); - } - - @Test - void acceptsExactlyThirtyTwoFlagsAndReturnsImmutableSnapshot() { - List expectedFlags = flags(32); - Config config = configWithFlags(String.join(",", expectedFlags)); - - assertEquals(expectedFlags, config.getCodeCoverageFlags()); - assertThrows( - UnsupportedOperationException.class, () -> config.getCodeCoverageFlags().add("extra")); - } - - @Test - void omitsAllFlagsWhenMoreThanThirtyTwoAreConfigured() { - Logger logger = (Logger) LoggerFactory.getLogger(Config.class); - ListAppender appender = new ListAppender<>(); - appender.start(); - logger.addAppender(appender); - - try { - Config config = configWithFlags(String.join(",", flags(33))); - - assertEquals(Collections.emptyList(), config.getCodeCoverageFlags()); - List overflowWarnings = new ArrayList<>(); - for (ILoggingEvent event : appender.list) { - if (event.getLevel() == Level.WARN - && event.getFormattedMessage().contains("code coverage report flags")) { - overflowWarnings.add(event); + @Test + void defaultsToNoFlags() { + assertEquals(Collections.emptyList(), Config.get().getCodeCoverageFlags()); + } + + @Test + void removesWhitespaceAndEmptyFlagsWhilePreservingOrderAndDuplicates() { + Config config = configWithFlags(" type:unit-tests, ,jvm-21,, type:unit-tests "); + + assertEquals(Arrays.asList("type:unit-tests", "jvm-21", "type:unit-tests"), config.getCodeCoverageFlags()); + } + + @Test + void readsFlagsFromCanonicalEnvironmentVariable() { + WithConfigExtension.injectEnvConfig("DD_CODE_COVERAGE_FLAGS", "type:unit-tests,jvm-21", false); + + assertEquals(Arrays.asList("type:unit-tests", "jvm-21"), Config.get().getCodeCoverageFlags()); + } + + @Test + void producesNoFlagsForEmptyInput() { + assertEquals(Collections.emptyList(), configWithFlags(" , , ").getCodeCoverageFlags()); + } + + @Test + void acceptsExactlyThirtyTwoFlagsAndReturnsImmutableSnapshot() { + List expectedFlags = flags(32); + Config config = configWithFlags(String.join(",", expectedFlags)); + + assertEquals(expectedFlags, config.getCodeCoverageFlags()); + assertThrows( + UnsupportedOperationException.class, + () -> config.getCodeCoverageFlags().add("extra")); + } + + @Test + void omitsAllFlagsWhenMoreThanThirtyTwoAreConfigured() { + Logger logger = (Logger) LoggerFactory.getLogger(Config.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + + try { + Config config = configWithFlags(String.join(",", flags(33))); + + assertEquals(Collections.emptyList(), config.getCodeCoverageFlags()); + List overflowWarnings = new ArrayList<>(); + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == Level.WARN + && event.getFormattedMessage().contains("code coverage report flags")) { + overflowWarnings.add(event); + } + } + assertEquals(1, overflowWarnings.size()); + assertTrue(overflowWarnings.get(0).getFormattedMessage().contains("33")); + assertTrue(overflowWarnings.get(0).getFormattedMessage().contains("32")); + } finally { + logger.detachAppender(appender); } - } - assertEquals(1, overflowWarnings.size()); - assertTrue(overflowWarnings.get(0).getFormattedMessage().contains("33")); - assertTrue(overflowWarnings.get(0).getFormattedMessage().contains("32")); - } finally { - logger.detachAppender(appender); } - } - private static Config configWithFlags(String flags) { - WithConfigExtension.injectSysConfig(CODE_COVERAGE_FLAGS, flags); - return Config.get(); - } + private static Config configWithFlags(String flags) { + WithConfigExtension.injectSysConfig(CODE_COVERAGE_FLAGS, flags); + return Config.get(); + } - private static List flags(int count) { - List flags = new ArrayList<>(count); - for (int i = 0; i < count; i++) { - flags.add("flag-" + i); + private static List flags(int count) { + List flags = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + flags.add("flag-" + i); + } + return flags; } - return flags; - } } diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigOtelContextExposureTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigOtelContextExposureTest.java index a095fa72553..bacd4d4e895 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigOtelContextExposureTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigOtelContextExposureTest.java @@ -16,74 +16,74 @@ @ExtendWith(WithConfigExtension.class) class ConfigOtelContextExposureTest { - /** - * The Datadog profiler raw predicate is vetoed outright on platforms and JVM versions that cannot - * run it, regardless of any explicit opt-in. Tests that expect the feature to be enabled are only - * meaningful on a JVM where that veto does not apply. - */ - private static void assumeDatadogProfilerNotVetoed() { - assumeTrue( - !Config.isDatadogProfilerEnablementOverridden(), - "Datadog profiler is unavailable on this platform/JVM version"); - } + /** + * The Datadog profiler raw predicate is vetoed outright on platforms and JVM versions that cannot + * run it, regardless of any explicit opt-in. Tests that expect the feature to be enabled are only + * meaningful on a JVM where that veto does not apply. + */ + private static void assumeDatadogProfilerNotVetoed() { + assumeTrue( + !Config.isDatadogProfilerEnablementOverridden(), + "Datadog profiler is unavailable on this platform/JVM version"); + } - @Test - void disabledByDefault() { - assertFalse(Config.get().isOtelThreadContextEnabled()); - } + @Test + void disabledByDefault() { + assertFalse(Config.get().isOtelThreadContextEnabled()); + } - @Test - @WithConfig(key = PROFILING_ENABLED, value = "true") - @WithConfig(key = APPSEC_ENABLED, value = "false") - @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "true") - void enabledWhenProfilingIsEnabled() { - assumeDatadogProfilerNotVetoed(); + @Test + @WithConfig(key = PROFILING_ENABLED, value = "true") + @WithConfig(key = APPSEC_ENABLED, value = "false") + @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "true") + void enabledWhenProfilingIsEnabled() { + assumeDatadogProfilerNotVetoed(); - assertTrue(Config.get().isOtelThreadContextEnabled()); - } + assertTrue(Config.get().isOtelThreadContextEnabled()); + } - @Test - @WithConfig(key = APPSEC_ENABLED, value = "true") - @WithConfig(key = PROFILING_ENABLED, value = "false") - @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "true") - void enabledWhenAppSecIsFullyEnabledWithoutProfiling() { - assumeDatadogProfilerNotVetoed(); + @Test + @WithConfig(key = APPSEC_ENABLED, value = "true") + @WithConfig(key = PROFILING_ENABLED, value = "false") + @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "true") + void enabledWhenAppSecIsFullyEnabledWithoutProfiling() { + assumeDatadogProfilerNotVetoed(); - Config config = Config.get(); - assertFalse(config.isProfilingEnabled()); - assertTrue(config.isOtelThreadContextEnabled()); - } + Config config = Config.get(); + assertFalse(config.isProfilingEnabled()); + assertTrue(config.isOtelThreadContextEnabled()); + } - @Test - @WithConfig(key = APPSEC_ENABLED, value = "inactive") - @WithConfig(key = PROFILING_ENABLED, value = "false") - @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "true") - void disabledWhenAppSecIsOnlyEnabledInactive() { - assertFalse(Config.get().isOtelThreadContextEnabled()); - } + @Test + @WithConfig(key = APPSEC_ENABLED, value = "inactive") + @WithConfig(key = PROFILING_ENABLED, value = "false") + @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "true") + void disabledWhenAppSecIsOnlyEnabledInactive() { + assertFalse(Config.get().isOtelThreadContextEnabled()); + } - @Test - @WithConfig(key = APPSEC_ENABLED, value = "true") - @WithConfig(key = PROFILING_ENABLED, value = "false") - @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "false") - void disabledWhenDatadogProfilerIsExplicitlyDisabled() { - assertFalse(Config.get().isOtelThreadContextEnabled()); - } + @Test + @WithConfig(key = APPSEC_ENABLED, value = "true") + @WithConfig(key = PROFILING_ENABLED, value = "false") + @WithConfig(key = PROFILING_DATADOG_PROFILER_ENABLED, value = "false") + void disabledWhenDatadogProfilerIsExplicitlyDisabled() { + assertFalse(Config.get().isOtelThreadContextEnabled()); + } - /** - * An environment where the Datadog profiler cannot run (unsupported JVM version, Windows, GraalVM - * native image) makes the raw ddprof predicate {@code false}. That environment detection reads - * real, cached JVM and OS state that a unit test running on a normal JVM cannot fake, and there - * is no existing test helper in this module to stub it. This test therefore drives the identical - * boolean short-circuit through the {@code DD_PROFILING_DDPROF_ENABLED=false} env variable: from - * {@link Config}'s point of view an environment-detected "unsafe" and an explicit "false" - * collapse into the same raw-predicate value, so the downstream effect on {@link - * Config#isOtelThreadContextEnabled()} is the same. - */ - @Test - @WithConfig(key = "APPSEC_ENABLED", value = "true", env = true) - @WithConfig(key = "PROFILING_DDPROF_ENABLED", value = "false", env = true) - void disabledWhenDatadogProfilerIsUnsafeOrExplicitlyDisabledViaEnv() { - assertFalse(Config.get().isOtelThreadContextEnabled()); - } + /** + * An environment where the Datadog profiler cannot run (unsupported JVM version, Windows, GraalVM + * native image) makes the raw ddprof predicate {@code false}. That environment detection reads + * real, cached JVM and OS state that a unit test running on a normal JVM cannot fake, and there + * is no existing test helper in this module to stub it. This test therefore drives the identical + * boolean short-circuit through the {@code DD_PROFILING_DDPROF_ENABLED=false} env variable: from + * {@link Config}'s point of view an environment-detected "unsafe" and an explicit "false" + * collapse into the same raw-predicate value, so the downstream effect on {@link + * Config#isOtelThreadContextEnabled()} is the same. + */ + @Test + @WithConfig(key = "APPSEC_ENABLED", value = "true", env = true) + @WithConfig(key = "PROFILING_DDPROF_ENABLED", value = "false", env = true) + void disabledWhenDatadogProfilerIsUnsafeOrExplicitlyDisabledViaEnv() { + assertFalse(Config.get().isOtelThreadContextEnabled()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigSecureRandomTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigSecureRandomTest.java index b780a8a581a..b3f2029b371 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigSecureRandomTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigSecureRandomTest.java @@ -11,36 +11,41 @@ @ExtendWith(WithConfigExtension.class) class ConfigSecureRandomTest { - @Test - void defaultStrategyIsRandom() { - assertNotEquals("SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); - } - - @Test - void snapStartEnablesSecureRandom() { - WithConfigExtension.injectEnvConfig("AWS_LAMBDA_INITIALIZATION_TYPE", "snap-start", false); - - assertEquals("SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); - } - - @Test - void microvmImageArnEnablesSecureRandom() { - WithConfigExtension.injectEnvConfig( - "AWS_LAMBDA_MICROVM_IMAGE_ARN", "arn:aws:lambda:us-east-1::runtime:microvm", false); - - assertEquals("SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); - } - - @Test - void emptyMicrovmImageArnDoesNotEnableSecureRandom() { - WithConfigExtension.injectEnvConfig("AWS_LAMBDA_MICROVM_IMAGE_ARN", "", false); - - assertNotEquals("SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); - } - - @Test - @WithConfig(key = "trace.secure-random", value = "true") - void configPropertyEnablesSecureRandom() { - assertEquals("SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); - } + @Test + void defaultStrategyIsRandom() { + assertNotEquals( + "SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); + } + + @Test + void snapStartEnablesSecureRandom() { + WithConfigExtension.injectEnvConfig("AWS_LAMBDA_INITIALIZATION_TYPE", "snap-start", false); + + assertEquals( + "SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); + } + + @Test + void microvmImageArnEnablesSecureRandom() { + WithConfigExtension.injectEnvConfig( + "AWS_LAMBDA_MICROVM_IMAGE_ARN", "arn:aws:lambda:us-east-1::runtime:microvm", false); + + assertEquals( + "SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); + } + + @Test + void emptyMicrovmImageArnDoesNotEnableSecureRandom() { + WithConfigExtension.injectEnvConfig("AWS_LAMBDA_MICROVM_IMAGE_ARN", "", false); + + assertNotEquals( + "SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); + } + + @Test + @WithConfig(key = "trace.secure-random", value = "true") + void configPropertyEnablesSecureRandom() { + assertEquals( + "SRandom", Config.get().getIdGenerationStrategy().getClass().getSimpleName()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java b/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java index c47f15e1b3e..75b022d0933 100644 --- a/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java +++ b/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java @@ -12,128 +12,128 @@ class EntryReadingHelperTest { - @Test - void setTagValueExposesIntValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("my.tag", 42); - - assertEquals("my.tag", helper.tag()); - assertEquals(42, helper.intValue()); - assertEquals(42L, helper.longValue()); - assertEquals(TagMap.EntryReader.INT, helper.type()); - assertTrue(helper.isNumber()); - assertTrue(helper.isNumericPrimitive()); - assertFalse(helper.isObject()); - assertTrue(helper.is(TagMap.EntryReader.INT)); - } - - @Test - void setTagValueExposesLongValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("id", 123456789L); - - assertEquals(123456789L, helper.longValue()); - assertEquals(TagMap.EntryReader.LONG, helper.type()); - assertTrue(helper.isNumber()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesBooleanValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("flag", true); - - assertTrue(helper.booleanValue()); - assertEquals(TagMap.EntryReader.BOOLEAN, helper.type()); - assertFalse(helper.isNumber()); - assertFalse(helper.isObject()); - } - - @Test - void setTagValueExposesFloatValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("rate", 1.5f); - - assertEquals(1.5f, helper.floatValue()); - assertEquals(TagMap.EntryReader.FLOAT, helper.type()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesDoubleValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("pi", 3.14); - - assertEquals(3.14, helper.doubleValue()); - assertEquals(TagMap.EntryReader.DOUBLE, helper.type()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesStringAsObject() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("name", "foo"); - - assertEquals("foo", helper.stringValue()); - assertEquals("foo", helper.objectValue()); - assertEquals(TagMap.EntryReader.OBJECT, helper.type()); - assertTrue(helper.isObject()); - assertFalse(helper.isNumber()); - assertFalse(helper.isNumericPrimitive()); - } - - @Test - void setTagValueCreatesEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("tag", "value"); - - TagMap.Entry entry = helper.entry(); - assertNotNull(entry); - assertEquals("tag", entry.tag()); - assertEquals("value", entry.objectValue()); - } - - @Test - void mapEntryFallsBackToNewEntryWhenSetWithTagValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("tag", "value"); - - Map.Entry mapEntry = helper.mapEntry(); - assertEquals("tag", mapEntry.getKey()); - assertEquals("value", mapEntry.getValue()); - } - - @Test - void setMapEntryExposesReadMethods() { - EntryReadingHelper helper = new EntryReadingHelper(); - Map.Entry original = new AbstractMap.SimpleEntry<>("key", "hello"); - helper.set(original); - - assertEquals("key", helper.tag()); - assertEquals("hello", helper.stringValue()); - assertEquals("hello", helper.objectValue()); - assertEquals(TagMap.EntryReader.OBJECT, helper.type()); - assertTrue(helper.isObject()); - assertFalse(helper.isNumber()); - } - - @Test - void mapEntryReturnsSameInstanceWhenSetWithMapEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - Map.Entry original = new AbstractMap.SimpleEntry<>("key", "value"); - helper.set(original); - - assertSame(original, helper.mapEntry()); - } - - @Test - void setMapEntryCreatesEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set(new AbstractMap.SimpleEntry<>("k", 99)); - - TagMap.Entry entry = helper.entry(); - assertNotNull(entry); - assertEquals("k", entry.tag()); - assertEquals(99, entry.objectValue()); - } + @Test + void setTagValueExposesIntValue() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("my.tag", 42); + + assertEquals("my.tag", helper.tag()); + assertEquals(42, helper.intValue()); + assertEquals(42L, helper.longValue()); + assertEquals(TagMap.EntryReader.INT, helper.type()); + assertTrue(helper.isNumber()); + assertTrue(helper.isNumericPrimitive()); + assertFalse(helper.isObject()); + assertTrue(helper.is(TagMap.EntryReader.INT)); + } + + @Test + void setTagValueExposesLongValue() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("id", 123456789L); + + assertEquals(123456789L, helper.longValue()); + assertEquals(TagMap.EntryReader.LONG, helper.type()); + assertTrue(helper.isNumber()); + assertTrue(helper.isNumericPrimitive()); + } + + @Test + void setTagValueExposesBooleanValue() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("flag", true); + + assertTrue(helper.booleanValue()); + assertEquals(TagMap.EntryReader.BOOLEAN, helper.type()); + assertFalse(helper.isNumber()); + assertFalse(helper.isObject()); + } + + @Test + void setTagValueExposesFloatValue() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("rate", 1.5f); + + assertEquals(1.5f, helper.floatValue()); + assertEquals(TagMap.EntryReader.FLOAT, helper.type()); + assertTrue(helper.isNumericPrimitive()); + } + + @Test + void setTagValueExposesDoubleValue() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("pi", 3.14); + + assertEquals(3.14, helper.doubleValue()); + assertEquals(TagMap.EntryReader.DOUBLE, helper.type()); + assertTrue(helper.isNumericPrimitive()); + } + + @Test + void setTagValueExposesStringAsObject() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("name", "foo"); + + assertEquals("foo", helper.stringValue()); + assertEquals("foo", helper.objectValue()); + assertEquals(TagMap.EntryReader.OBJECT, helper.type()); + assertTrue(helper.isObject()); + assertFalse(helper.isNumber()); + assertFalse(helper.isNumericPrimitive()); + } + + @Test + void setTagValueCreatesEntry() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("tag", "value"); + + TagMap.Entry entry = helper.entry(); + assertNotNull(entry); + assertEquals("tag", entry.tag()); + assertEquals("value", entry.objectValue()); + } + + @Test + void mapEntryFallsBackToNewEntryWhenSetWithTagValue() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set("tag", "value"); + + Map.Entry mapEntry = helper.mapEntry(); + assertEquals("tag", mapEntry.getKey()); + assertEquals("value", mapEntry.getValue()); + } + + @Test + void setMapEntryExposesReadMethods() { + EntryReadingHelper helper = new EntryReadingHelper(); + Map.Entry original = new AbstractMap.SimpleEntry<>("key", "hello"); + helper.set(original); + + assertEquals("key", helper.tag()); + assertEquals("hello", helper.stringValue()); + assertEquals("hello", helper.objectValue()); + assertEquals(TagMap.EntryReader.OBJECT, helper.type()); + assertTrue(helper.isObject()); + assertFalse(helper.isNumber()); + } + + @Test + void mapEntryReturnsSameInstanceWhenSetWithMapEntry() { + EntryReadingHelper helper = new EntryReadingHelper(); + Map.Entry original = new AbstractMap.SimpleEntry<>("key", "value"); + helper.set(original); + + assertSame(original, helper.mapEntry()); + } + + @Test + void setMapEntryCreatesEntry() { + EntryReadingHelper helper = new EntryReadingHelper(); + helper.set(new AbstractMap.SimpleEntry<>("k", 99)); + + TagMap.Entry entry = helper.entry(); + assertNotNull(entry); + assertEquals("k", entry.tag()); + assertEquals(99, entry.objectValue()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/FunctionsBase64Test.java b/internal-api/src/test/java/datadog/trace/api/FunctionsBase64Test.java index 7330e9a5538..c969d95358a 100644 --- a/internal-api/src/test/java/datadog/trace/api/FunctionsBase64Test.java +++ b/internal-api/src/test/java/datadog/trace/api/FunctionsBase64Test.java @@ -12,32 +12,32 @@ class FunctionsBase64Test { - @Test - void utf8BytesToStringConvertsBytes() { - byte[] bytes = "hello".getBytes(UTF_8); - assertEquals("hello", UTF8_BYTES_TO_STRING.apply(bytes)); - } - - @Test - void base64DecodeDecodesValidInput() { - String original = "x-datadog-trace-id"; - byte[] encoded = Base64.getEncoder().encode(original.getBytes(UTF_8)); - assertEquals(original, BASE64_DECODE.apply(encoded)); - } - - @Test - void base64DecodeReturnsNullForInvalidBase64() { - assertNull(BASE64_DECODE.apply("not-valid-base64!@#".getBytes(UTF_8))); - } - - @Test - void base64DecodeReturnsNullForUrlSafeChars() { - // URL-safe Base64 uses '-' and '_' which the standard decoder rejects - assertNull(BASE64_DECODE.apply("abc-def_ghi".getBytes(UTF_8))); - } - - @Test - void base64DecodeIsNotNull() { - assertNotNull(BASE64_DECODE); - } + @Test + void utf8BytesToStringConvertsBytes() { + byte[] bytes = "hello".getBytes(UTF_8); + assertEquals("hello", UTF8_BYTES_TO_STRING.apply(bytes)); + } + + @Test + void base64DecodeDecodesValidInput() { + String original = "x-datadog-trace-id"; + byte[] encoded = Base64.getEncoder().encode(original.getBytes(UTF_8)); + assertEquals(original, BASE64_DECODE.apply(encoded)); + } + + @Test + void base64DecodeReturnsNullForInvalidBase64() { + assertNull(BASE64_DECODE.apply("not-valid-base64!@#".getBytes(UTF_8))); + } + + @Test + void base64DecodeReturnsNullForUrlSafeChars() { + // URL-safe Base64 uses '-' and '_' which the standard decoder rejects + assertNull(BASE64_DECODE.apply("abc-def_ghi".getBytes(UTF_8))); + } + + @Test + void base64DecodeIsNotNull() { + assertNotNull(BASE64_DECODE); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapBucketGroupTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapBucketGroupTest.java index 477a5ae58d8..b394cbea118 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapBucketGroupTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapBucketGroupTest.java @@ -11,364 +11,359 @@ import org.junit.jupiter.api.Test; public class TagMapBucketGroupTest { - @Test - public void newGroup() { - TagMap.Entry firstEntry = TagMap.Entry.newIntEntry("foo", 0xDD06); - TagMap.Entry secondEntry = TagMap.Entry.newObjectEntry("bar", "quux"); + @Test + public void newGroup() { + TagMap.Entry firstEntry = TagMap.Entry.newIntEntry("foo", 0xDD06); + TagMap.Entry secondEntry = TagMap.Entry.newObjectEntry("bar", "quux"); - int firstHash = firstEntry.hash(); - int secondHash = secondEntry.hash(); + int firstHash = firstEntry.hash(); + int secondHash = secondEntry.hash(); - TagMap.BucketGroup group = - new TagMap.BucketGroup( - firstHash, firstEntry, - secondHash, secondEntry); + TagMap.BucketGroup group = new TagMap.BucketGroup( + firstHash, firstEntry, + secondHash, secondEntry); - assertEquals(firstHash, group._hashAt(0)); - assertEquals(firstEntry, group._entryAt(0)); + assertEquals(firstHash, group._hashAt(0)); + assertEquals(firstEntry, group._entryAt(0)); - assertEquals(secondEntry.hash(), group._hashAt(1)); - assertEquals(secondEntry, group._entryAt(1)); + assertEquals(secondEntry.hash(), group._hashAt(1)); + assertEquals(secondEntry, group._entryAt(1)); - assertFalse(group._isEmpty()); - assertFalse(group.isEmptyChain()); + assertFalse(group._isEmpty()); + assertFalse(group.isEmptyChain()); - assertContainsDirectly(firstEntry, group); - assertContainsDirectly(secondEntry, group); - } + assertContainsDirectly(firstEntry, group); + assertContainsDirectly(secondEntry, group); + } - @Test - public void _insert() { - TagMap.Entry firstEntry = TagMap.Entry.newIntEntry("foo", 0xDD06); - TagMap.Entry secondEntry = TagMap.Entry.newObjectEntry("bar", "quux"); + @Test + public void _insert() { + TagMap.Entry firstEntry = TagMap.Entry.newIntEntry("foo", 0xDD06); + TagMap.Entry secondEntry = TagMap.Entry.newObjectEntry("bar", "quux"); - int firstHash = firstEntry.hash(); - int secondHash = secondEntry.hash(); + int firstHash = firstEntry.hash(); + int secondHash = secondEntry.hash(); - TagMap.BucketGroup group = - new TagMap.BucketGroup(firstHash, firstEntry, secondHash, secondEntry); + TagMap.BucketGroup group = new TagMap.BucketGroup(firstHash, firstEntry, secondHash, secondEntry); - TagMap.Entry newEntry = TagMap.Entry.newAnyEntry("baz", "lorem ipsum"); - int newHash = newEntry.hash(); + TagMap.Entry newEntry = TagMap.Entry.newAnyEntry("baz", "lorem ipsum"); + int newHash = newEntry.hash(); - assertTrue(group._insert(newHash, newEntry)); + assertTrue(group._insert(newHash, newEntry)); - assertContainsDirectly(newEntry, group); - assertContainsDirectly(firstEntry, group); - assertContainsDirectly(secondEntry, group); + assertContainsDirectly(newEntry, group); + assertContainsDirectly(firstEntry, group); + assertContainsDirectly(secondEntry, group); - TagMap.Entry newEntry2 = TagMap.Entry.newDoubleEntry("new", 3.1415926535D); - int newHash2 = newEntry2.hash(); + TagMap.Entry newEntry2 = TagMap.Entry.newDoubleEntry("new", 3.1415926535D); + int newHash2 = newEntry2.hash(); - assertTrue(group._insert(newHash2, newEntry2)); + assertTrue(group._insert(newHash2, newEntry2)); - assertContainsDirectly(newEntry2, group); - assertContainsDirectly(newEntry, group); - assertContainsDirectly(firstEntry, group); - assertContainsDirectly(secondEntry, group); + assertContainsDirectly(newEntry2, group); + assertContainsDirectly(newEntry, group); + assertContainsDirectly(firstEntry, group); + assertContainsDirectly(secondEntry, group); - TagMap.Entry overflowEntry = TagMap.Entry.newDoubleEntry("overflow", 2.718281828D); - int overflowHash = overflowEntry.hash(); - assertFalse(group._insert(overflowHash, overflowEntry)); + TagMap.Entry overflowEntry = TagMap.Entry.newDoubleEntry("overflow", 2.718281828D); + int overflowHash = overflowEntry.hash(); + assertFalse(group._insert(overflowHash, overflowEntry)); - assertDoesntContainDirectly(overflowEntry, group); - } + assertDoesntContainDirectly(overflowEntry, group); + } - @Test - public void _replace() { - TagMap.Entry origEntry = TagMap.Entry.newIntEntry("replaceable", 0xDD06); - TagMap.Entry otherEntry = TagMap.Entry.newObjectEntry("bar", "quux"); + @Test + public void _replace() { + TagMap.Entry origEntry = TagMap.Entry.newIntEntry("replaceable", 0xDD06); + TagMap.Entry otherEntry = TagMap.Entry.newObjectEntry("bar", "quux"); - int origHash = origEntry.hash(); - int otherHash = otherEntry.hash(); + int origHash = origEntry.hash(); + int otherHash = otherEntry.hash(); - TagMap.BucketGroup group = new TagMap.BucketGroup(origHash, origEntry, otherHash, otherEntry); - assertContainsDirectly(origEntry, group); - assertContainsDirectly(otherEntry, group); + TagMap.BucketGroup group = new TagMap.BucketGroup(origHash, origEntry, otherHash, otherEntry); + assertContainsDirectly(origEntry, group); + assertContainsDirectly(otherEntry, group); - TagMap.Entry replacementEntry = TagMap.Entry.newBooleanEntry("replaceable", true); - int replacementHash = replacementEntry.hash(); - assertEquals(replacementHash, origHash); + TagMap.Entry replacementEntry = TagMap.Entry.newBooleanEntry("replaceable", true); + int replacementHash = replacementEntry.hash(); + assertEquals(replacementHash, origHash); - TagMap.Entry priorEntry = group._replace(origHash, replacementEntry); - assertSame(priorEntry, origEntry); + TagMap.Entry priorEntry = group._replace(origHash, replacementEntry); + assertSame(priorEntry, origEntry); - assertContainsDirectly(replacementEntry, group); - assertDoesntContainDirectly(priorEntry, group); + assertContainsDirectly(replacementEntry, group); + assertDoesntContainDirectly(priorEntry, group); - TagMap.Entry dneEntry = TagMap.Entry.newAnyEntry("dne", "not present"); - int dneHash = dneEntry.hash(); + TagMap.Entry dneEntry = TagMap.Entry.newAnyEntry("dne", "not present"); + int dneHash = dneEntry.hash(); - assertNull(group._replace(dneHash, dneEntry)); - assertDoesntContainDirectly(dneEntry, group); - } + assertNull(group._replace(dneHash, dneEntry)); + assertDoesntContainDirectly(dneEntry, group); + } - @Test - public void _remove() { - TagMap.Entry firstEntry = TagMap.Entry.newIntEntry("first", 0xDD06); - TagMap.Entry secondEntry = TagMap.Entry.newObjectEntry("second", "quux"); + @Test + public void _remove() { + TagMap.Entry firstEntry = TagMap.Entry.newIntEntry("first", 0xDD06); + TagMap.Entry secondEntry = TagMap.Entry.newObjectEntry("second", "quux"); - int firstHash = firstEntry.hash(); - int secondHash = secondEntry.hash(); + int firstHash = firstEntry.hash(); + int secondHash = secondEntry.hash(); - TagMap.BucketGroup group = - new TagMap.BucketGroup( - firstHash, firstEntry, - secondHash, secondEntry); + TagMap.BucketGroup group = new TagMap.BucketGroup( + firstHash, firstEntry, + secondHash, secondEntry); - assertFalse(group._isEmpty()); + assertFalse(group._isEmpty()); - assertContainsDirectly(firstEntry, group); - assertContainsDirectly(secondEntry, group); + assertContainsDirectly(firstEntry, group); + assertContainsDirectly(secondEntry, group); - assertSame(firstEntry, group._remove(firstHash, "first")); + assertSame(firstEntry, group._remove(firstHash, "first")); - assertDoesntContainDirectly(firstEntry, group); - assertContainsDirectly(secondEntry, group); - assertFalse(group._isEmpty()); + assertDoesntContainDirectly(firstEntry, group); + assertContainsDirectly(secondEntry, group); + assertFalse(group._isEmpty()); - assertSame(secondEntry, group._remove(secondHash, "second")); - assertDoesntContainDirectly(secondEntry, group); + assertSame(secondEntry, group._remove(secondHash, "second")); + assertDoesntContainDirectly(secondEntry, group); - assertTrue(group._isEmpty()); - } + assertTrue(group._isEmpty()); + } - @Test - public void groupChaining() { - int startingIndex = 10; - TagMap.BucketGroup firstGroup = fullGroup(startingIndex); + @Test + public void groupChaining() { + int startingIndex = 10; + TagMap.BucketGroup firstGroup = fullGroup(startingIndex); - for (int offset = 0; offset < TagMap.BucketGroup.LEN; ++offset) { - assertChainContainsTag(tag(startingIndex + offset), firstGroup); - } + for (int offset = 0; offset < TagMap.BucketGroup.LEN; ++offset) { + assertChainContainsTag(tag(startingIndex + offset), firstGroup); + } - TagMap.Entry newEntry = TagMap.Entry.newObjectEntry("new", "new"); - int newHash = newEntry.hash(); + TagMap.Entry newEntry = TagMap.Entry.newObjectEntry("new", "new"); + int newHash = newEntry.hash(); - // This is a test of the process used by TagMap#put - assertNull(firstGroup._replace(newHash, newEntry)); - assertFalse(firstGroup._insert(newHash, newEntry)); - assertDoesntContainDirectly(newEntry, firstGroup); + // This is a test of the process used by TagMap#put + assertNull(firstGroup._replace(newHash, newEntry)); + assertFalse(firstGroup._insert(newHash, newEntry)); + assertDoesntContainDirectly(newEntry, firstGroup); - TagMap.BucketGroup newHeadGroup = new TagMap.BucketGroup(newHash, newEntry, firstGroup); - assertContainsDirectly(newEntry, newHeadGroup); - assertSame(firstGroup, newHeadGroup.prev); + TagMap.BucketGroup newHeadGroup = new TagMap.BucketGroup(newHash, newEntry, firstGroup); + assertContainsDirectly(newEntry, newHeadGroup); + assertSame(firstGroup, newHeadGroup.prev); - assertChainContainsTag("new", newHeadGroup); - for (int offset = 0; offset < TagMap.BucketGroup.LEN; ++offset) { - assertChainContainsTag(tag(startingIndex + offset), newHeadGroup); + assertChainContainsTag("new", newHeadGroup); + for (int offset = 0; offset < TagMap.BucketGroup.LEN; ++offset) { + assertChainContainsTag(tag(startingIndex + offset), newHeadGroup); + } } - } - @Test - public void removeInChain() { - TagMap.BucketGroup firstGroup = fullGroup(10); - TagMap.BucketGroup headGroup = fullGroup(20, firstGroup); + @Test + public void removeInChain() { + TagMap.BucketGroup firstGroup = fullGroup(10); + TagMap.BucketGroup headGroup = fullGroup(20, firstGroup); - for (int offset = 0; offset < TagMap.BucketGroup.LEN; ++offset) { - assertChainContainsTag(tag(10, offset), headGroup); - assertChainContainsTag(tag(20, offset), headGroup); - } + for (int offset = 0; offset < TagMap.BucketGroup.LEN; ++offset) { + assertChainContainsTag(tag(10, offset), headGroup); + assertChainContainsTag(tag(20, offset), headGroup); + } - assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); + assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); - String firstRemovedTag = tag(10, 1); - int firstRemovedHash = TagMap.Entry._hash(firstRemovedTag); + String firstRemovedTag = tag(10, 1); + int firstRemovedHash = TagMap.Entry._hash(firstRemovedTag); - TagMap.BucketGroup firstContainingGroup = - headGroup.findContainingGroupInChain(firstRemovedHash, firstRemovedTag); - assertSame(firstContainingGroup, firstGroup); - assertNotNull(firstContainingGroup._remove(firstRemovedHash, firstRemovedTag)); + TagMap.BucketGroup firstContainingGroup = + headGroup.findContainingGroupInChain(firstRemovedHash, firstRemovedTag); + assertSame(firstContainingGroup, firstGroup); + assertNotNull(firstContainingGroup._remove(firstRemovedHash, firstRemovedTag)); - assertChainDoesntContainTag(firstRemovedTag, headGroup); + assertChainDoesntContainTag(firstRemovedTag, headGroup); - assertEquals(TagMap.BucketGroup.LEN * 2 - 1, headGroup.sizeInChain()); + assertEquals(TagMap.BucketGroup.LEN * 2 - 1, headGroup.sizeInChain()); - String secondRemovedTag = tag(20, 2); - int secondRemovedHash = TagMap.Entry._hash(secondRemovedTag); + String secondRemovedTag = tag(20, 2); + int secondRemovedHash = TagMap.Entry._hash(secondRemovedTag); - TagMap.BucketGroup secondContainingGroup = - headGroup.findContainingGroupInChain(secondRemovedHash, secondRemovedTag); - assertSame(secondContainingGroup, headGroup); - assertNotNull(secondContainingGroup._remove(secondRemovedHash, secondRemovedTag)); + TagMap.BucketGroup secondContainingGroup = + headGroup.findContainingGroupInChain(secondRemovedHash, secondRemovedTag); + assertSame(secondContainingGroup, headGroup); + assertNotNull(secondContainingGroup._remove(secondRemovedHash, secondRemovedTag)); - assertChainDoesntContainTag(secondRemovedTag, headGroup); + assertChainDoesntContainTag(secondRemovedTag, headGroup); - assertEquals(TagMap.BucketGroup.LEN * 2 - 2, headGroup.sizeInChain()); - } + assertEquals(TagMap.BucketGroup.LEN * 2 - 2, headGroup.sizeInChain()); + } - @Test - public void replaceInChain() { - TagMap.BucketGroup firstGroup = fullGroup(10); - TagMap.BucketGroup headGroup = fullGroup(20, firstGroup); + @Test + public void replaceInChain() { + TagMap.BucketGroup firstGroup = fullGroup(10); + TagMap.BucketGroup headGroup = fullGroup(20, firstGroup); - assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); + assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); - TagMap.Entry firstReplacementEntry = TagMap.Entry.newObjectEntry(tag(10, 1), "replaced"); - assertNotNull(headGroup.replaceInChain(firstReplacementEntry.hash(), firstReplacementEntry)); + TagMap.Entry firstReplacementEntry = TagMap.Entry.newObjectEntry(tag(10, 1), "replaced"); + assertNotNull(headGroup.replaceInChain(firstReplacementEntry.hash(), firstReplacementEntry)); - assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); + assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); - TagMap.Entry secondReplacementEntry = TagMap.Entry.newObjectEntry(tag(20, 2), "replaced"); - assertNotNull(headGroup.replaceInChain(secondReplacementEntry.hash(), secondReplacementEntry)); + TagMap.Entry secondReplacementEntry = TagMap.Entry.newObjectEntry(tag(20, 2), "replaced"); + assertNotNull(headGroup.replaceInChain(secondReplacementEntry.hash(), secondReplacementEntry)); - assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); - } + assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); + } - @Test - public void insertInChain() { - // set-up a chain with some gaps in it - TagMap.BucketGroup firstGroup = fullGroup(10); - TagMap.BucketGroup headGroup = fullGroup(20, firstGroup); + @Test + public void insertInChain() { + // set-up a chain with some gaps in it + TagMap.BucketGroup firstGroup = fullGroup(10); + TagMap.BucketGroup headGroup = fullGroup(20, firstGroup); - assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); + assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); - String firstHoleTag = tag(10, 1); - int firstHoleHash = TagMap.Entry._hash(firstHoleTag); - firstGroup._remove(firstHoleHash, firstHoleTag); + String firstHoleTag = tag(10, 1); + int firstHoleHash = TagMap.Entry._hash(firstHoleTag); + firstGroup._remove(firstHoleHash, firstHoleTag); - String secondHoleTag = tag(20, 2); - int secondHoleHash = TagMap.Entry._hash(secondHoleTag); - headGroup._remove(secondHoleHash, secondHoleTag); + String secondHoleTag = tag(20, 2); + int secondHoleHash = TagMap.Entry._hash(secondHoleTag); + headGroup._remove(secondHoleHash, secondHoleTag); - assertEquals(TagMap.BucketGroup.LEN * 2 - 2, headGroup.sizeInChain()); + assertEquals(TagMap.BucketGroup.LEN * 2 - 2, headGroup.sizeInChain()); - String firstNewTag = "new-tag-0"; - TagMap.Entry firstNewEntry = TagMap.Entry.newObjectEntry(firstNewTag, "new"); - int firstNewHash = firstNewEntry.hash(); + String firstNewTag = "new-tag-0"; + TagMap.Entry firstNewEntry = TagMap.Entry.newObjectEntry(firstNewTag, "new"); + int firstNewHash = firstNewEntry.hash(); - assertTrue(headGroup.insertInChain(firstNewHash, firstNewEntry)); - assertChainContainsTag(firstNewTag, headGroup); + assertTrue(headGroup.insertInChain(firstNewHash, firstNewEntry)); + assertChainContainsTag(firstNewTag, headGroup); - String secondNewTag = "new-tag-1"; - TagMap.Entry secondNewEntry = TagMap.Entry.newObjectEntry(secondNewTag, "new"); - int secondNewHash = secondNewEntry.hash(); + String secondNewTag = "new-tag-1"; + TagMap.Entry secondNewEntry = TagMap.Entry.newObjectEntry(secondNewTag, "new"); + int secondNewHash = secondNewEntry.hash(); - assertTrue(headGroup.insertInChain(secondNewHash, secondNewEntry)); - assertChainContainsTag(secondNewTag, headGroup); + assertTrue(headGroup.insertInChain(secondNewHash, secondNewEntry)); + assertChainContainsTag(secondNewTag, headGroup); - String thirdNewTag = "new-tag-2"; - TagMap.Entry thirdNewEntry = TagMap.Entry.newObjectEntry(secondNewTag, "new"); - int thirdNewHash = secondNewEntry.hash(); + String thirdNewTag = "new-tag-2"; + TagMap.Entry thirdNewEntry = TagMap.Entry.newObjectEntry(secondNewTag, "new"); + int thirdNewHash = secondNewEntry.hash(); - assertFalse(headGroup.insertInChain(thirdNewHash, thirdNewEntry)); - assertChainDoesntContainTag(thirdNewTag, headGroup); + assertFalse(headGroup.insertInChain(thirdNewHash, thirdNewEntry)); + assertChainDoesntContainTag(thirdNewTag, headGroup); + + assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); + } - assertEquals(TagMap.BucketGroup.LEN * 2, headGroup.sizeInChain()); - } + @Test + public void cloneChain() { + TagMap.BucketGroup firstGroup = fullGroup(10); + TagMap.BucketGroup secondGroup = fullGroup(20, firstGroup); + TagMap.BucketGroup headGroup = fullGroup(30, secondGroup); - @Test - public void cloneChain() { - TagMap.BucketGroup firstGroup = fullGroup(10); - TagMap.BucketGroup secondGroup = fullGroup(20, firstGroup); - TagMap.BucketGroup headGroup = fullGroup(30, secondGroup); + TagMap.BucketGroup clonedHeadGroup = headGroup.cloneChain(); + TagMap.BucketGroup clonedSecondGroup = clonedHeadGroup.prev; + TagMap.BucketGroup clonedFirstGroup = clonedSecondGroup.prev; - TagMap.BucketGroup clonedHeadGroup = headGroup.cloneChain(); - TagMap.BucketGroup clonedSecondGroup = clonedHeadGroup.prev; - TagMap.BucketGroup clonedFirstGroup = clonedSecondGroup.prev; + assertGroupContentsStrictEquals(headGroup, clonedHeadGroup); + assertGroupContentsStrictEquals(secondGroup, clonedSecondGroup); + assertGroupContentsStrictEquals(firstGroup, clonedFirstGroup); + } - assertGroupContentsStrictEquals(headGroup, clonedHeadGroup); - assertGroupContentsStrictEquals(secondGroup, clonedSecondGroup); - assertGroupContentsStrictEquals(firstGroup, clonedFirstGroup); - } + @Test + public void removeGroupInChain() { + TagMap.BucketGroup tailGroup = fullGroup(10); + TagMap.BucketGroup secondGroup = fullGroup(20, tailGroup); + TagMap.BucketGroup thirdGroup = fullGroup(30, secondGroup); + TagMap.BucketGroup fourthGroup = fullGroup(40, thirdGroup); + TagMap.BucketGroup headGroup = fullGroup(50, fourthGroup); + assertChain(headGroup, fourthGroup, thirdGroup, secondGroup, tailGroup); - @Test - public void removeGroupInChain() { - TagMap.BucketGroup tailGroup = fullGroup(10); - TagMap.BucketGroup secondGroup = fullGroup(20, tailGroup); - TagMap.BucketGroup thirdGroup = fullGroup(30, secondGroup); - TagMap.BucketGroup fourthGroup = fullGroup(40, thirdGroup); - TagMap.BucketGroup headGroup = fullGroup(50, fourthGroup); - assertChain(headGroup, fourthGroup, thirdGroup, secondGroup, tailGroup); + // need to test group removal - at head, middle, and tail of the chain - // need to test group removal - at head, middle, and tail of the chain + // middle + assertSame(headGroup, headGroup.removeGroupInChain(thirdGroup)); + assertChain(headGroup, fourthGroup, secondGroup, tailGroup); - // middle - assertSame(headGroup, headGroup.removeGroupInChain(thirdGroup)); - assertChain(headGroup, fourthGroup, secondGroup, tailGroup); + // tail + assertSame(headGroup, headGroup.removeGroupInChain(tailGroup)); + assertChain(headGroup, fourthGroup, secondGroup); - // tail - assertSame(headGroup, headGroup.removeGroupInChain(tailGroup)); - assertChain(headGroup, fourthGroup, secondGroup); + // head + assertSame(fourthGroup, headGroup.removeGroupInChain(headGroup)); + assertChain(fourthGroup, secondGroup); + } - // head - assertSame(fourthGroup, headGroup.removeGroupInChain(headGroup)); - assertChain(fourthGroup, secondGroup); - } + static final TagMap.BucketGroup fullGroup(int startingIndex) { + TagMap.Entry firstEntry = TagMap.Entry.newObjectEntry(tag(startingIndex), value(startingIndex)); + TagMap.Entry secondEntry = TagMap.Entry.newObjectEntry(tag(startingIndex + 1), value(startingIndex + 1)); + + TagMap.BucketGroup group = + new TagMap.BucketGroup(firstEntry.hash(), firstEntry, secondEntry.hash(), secondEntry); + for (int offset = 2; offset < TagMap.BucketGroup.LEN; ++offset) { + TagMap.Entry anotherEntry = + TagMap.Entry.newObjectEntry(tag(startingIndex + offset), value(startingIndex + offset)); + group._insert(anotherEntry.hash(), anotherEntry); + } + return group; + } - static final TagMap.BucketGroup fullGroup(int startingIndex) { - TagMap.Entry firstEntry = TagMap.Entry.newObjectEntry(tag(startingIndex), value(startingIndex)); - TagMap.Entry secondEntry = - TagMap.Entry.newObjectEntry(tag(startingIndex + 1), value(startingIndex + 1)); + static final TagMap.BucketGroup fullGroup(int startingIndex, TagMap.BucketGroup prev) { + TagMap.BucketGroup group = fullGroup(startingIndex); + group.prev = prev; + return group; + } - TagMap.BucketGroup group = - new TagMap.BucketGroup(firstEntry.hash(), firstEntry, secondEntry.hash(), secondEntry); - for (int offset = 2; offset < TagMap.BucketGroup.LEN; ++offset) { - TagMap.Entry anotherEntry = - TagMap.Entry.newObjectEntry(tag(startingIndex + offset), value(startingIndex + offset)); - group._insert(anotherEntry.hash(), anotherEntry); + static final String tag(int startingIndex, int offset) { + return tag(startingIndex + offset); } - return group; - } - static final TagMap.BucketGroup fullGroup(int startingIndex, TagMap.BucketGroup prev) { - TagMap.BucketGroup group = fullGroup(startingIndex); - group.prev = prev; - return group; - } + static final String tag(int i) { + return "tag-" + i; + } - static final String tag(int startingIndex, int offset) { - return tag(startingIndex + offset); - } + static final String value(int i) { + return "value-" + i; + } - static final String tag(int i) { - return "tag-" + i; - } + static void assertContainsDirectly(TagMap.Entry entry, TagMap.BucketGroup group) { + int hash = entry.hash(); + String tag = entry.tag(); - static final String value(int i) { - return "value-" + i; - } + assertSame(entry, group._find(hash, tag)); - static void assertContainsDirectly(TagMap.Entry entry, TagMap.BucketGroup group) { - int hash = entry.hash(); - String tag = entry.tag(); + assertSame(entry, group.findInChain(hash, tag)); + assertSame(group, group.findContainingGroupInChain(hash, tag)); + } - assertSame(entry, group._find(hash, tag)); + static void assertDoesntContainDirectly(TagMap.Entry entry, TagMap.BucketGroup group) { + for (int i = 0; i < TagMap.BucketGroup.LEN; ++i) { + assertNotSame(entry, group._entryAt(i)); + } + } - assertSame(entry, group.findInChain(hash, tag)); - assertSame(group, group.findContainingGroupInChain(hash, tag)); - } + static void assertChainContainsTag(String tag, TagMap.BucketGroup group) { + int hash = TagMap.Entry._hash(tag); + assertNotNull(group.findInChain(hash, tag)); + } - static void assertDoesntContainDirectly(TagMap.Entry entry, TagMap.BucketGroup group) { - for (int i = 0; i < TagMap.BucketGroup.LEN; ++i) { - assertNotSame(entry, group._entryAt(i)); + static void assertChainDoesntContainTag(String tag, TagMap.BucketGroup group) { + int hash = TagMap.Entry._hash(tag); + assertNull(group.findInChain(hash, tag)); } - } - - static void assertChainContainsTag(String tag, TagMap.BucketGroup group) { - int hash = TagMap.Entry._hash(tag); - assertNotNull(group.findInChain(hash, tag)); - } - - static void assertChainDoesntContainTag(String tag, TagMap.BucketGroup group) { - int hash = TagMap.Entry._hash(tag); - assertNull(group.findInChain(hash, tag)); - } - - static void assertGroupContentsStrictEquals( - TagMap.BucketGroup expected, TagMap.BucketGroup actual) { - for (int i = 0; i < TagMap.BucketGroup.LEN; ++i) { - assertEquals(expected._hashAt(i), actual._hashAt(i)); - assertSame(expected._entryAt(i), actual._entryAt(i)); + + static void assertGroupContentsStrictEquals(TagMap.BucketGroup expected, TagMap.BucketGroup actual) { + for (int i = 0; i < TagMap.BucketGroup.LEN; ++i) { + assertEquals(expected._hashAt(i), actual._hashAt(i)); + assertSame(expected._entryAt(i), actual._entryAt(i)); + } } - } - static void assertChain(TagMap.BucketGroup... chain) { - TagMap.BucketGroup cur; - int index; - for (cur = chain[0], index = 0; cur != null; cur = cur.prev, ++index) { - assertSame(chain[index], cur); + static void assertChain(TagMap.BucketGroup... chain) { + TagMap.BucketGroup cur; + int index; + for (cur = chain[0], index = 0; cur != null; cur = cur.prev, ++index) { + assertSame(chain[index], cur); + } + assertEquals(chain.length, index); } - assertEquals(chain.length, index); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapEntryTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapEntryTest.java index e7c483e80ec..13a04687f4e 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapEntryTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapEntryTest.java @@ -37,983 +37,904 @@ * @author dougqh */ public class TagMapEntryTest { - @Test - public void isNumericPrimitive() { - assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.ANY)); - assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.BOOLEAN)); - assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.CHAR_RESERVED)); - assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.OBJECT)); - - assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.BYTE_RESERVED)); - assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.SHORT_RESERVED)); - assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.INT)); - assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.LONG)); - assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.FLOAT)); - assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.DOUBLE)); - } - - @Test - @DisplayName("TagMap.Entry.create: Object") - public void createObject() { - Map map = new HashMap<>(); - - test( - () -> TagMap.Entry.create("foo", map), - TagMap.Entry.ANY, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(map, entry), - checkTrue(entry::isObject), - checkFalse(entry::isNumber), - checkType(TagMap.Entry.OBJECT, entry))); - } - - @Test - @DisplayName("TagMap.Entry.create: null Object") - public void createEntryNullObject() { - assertNull(TagMap.Entry.create("foo", null)); - } - - @Test - @DisplayName("TagMap.Entry.create: CharSequence") - public void createCharSequence() { - test( - () -> TagMap.Entry.create("foo", "bar"), - TagMap.Entry.OBJECT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue("bar", entry), - checkEquals("bar", entry::stringValue), - checkFalse(entry::isNumber), - checkTrue(entry::isObject), - checkType(TagMap.Entry.OBJECT, entry))); - } - - @Test - @DisplayName("TagMap.Entry.create: null CharSequence") - public void createNullCharSequence() { - assertNull(TagMap.Entry.create("foo", (String) null)); - } - - @Test - @DisplayName("TagMap.Entry.create: empty CharSequence") - public void createEmptyCharSequence() { - assertNull(TagMap.Entry.create("foo", "")); - } - - @Test - public void newObjectEntry() { - test( - () -> TagMap.Entry.newObjectEntry("foo", "bar"), - TagMap.Entry.OBJECT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue("bar", entry), - checkEquals("bar", entry::stringValue), - checkFalse(entry::isNumber), - checkTrue(entry::isObject), - checkType(TagMap.Entry.OBJECT, entry))); - } - - @Test - @DisplayName("newAnyEntry: Object") - public void anyEntryObject() { - test( - () -> TagMap.Entry.newAnyEntry("foo", "bar"), - TagMap.Entry.ANY, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue("bar", entry), - checkTrue(entry::isObject), - checkFalse(entry::isNumber), - checkType(TagMap.EntryReader.OBJECT, entry))); - } - - @ParameterizedTest - @DisplayName("TagMap.Entry.create: boolean") - @ValueSource(booleans = {false, true}) - public void createBoolean(boolean value) { - test( - () -> TagMap.Entry.create("foo", value), - TagMap.Entry.BOOLEAN, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkFalse(entry::isNumericPrimitive), - checkFalse(entry::isNumber), - checkFalse(entry::isObject), - checkType(TagMap.Entry.BOOLEAN, entry))); - } - - @ParameterizedTest - @DisplayName("newBooleanEntry: boolean") - @ValueSource(booleans = {false, true}) - public void newBooleanEntry(boolean value) { - test( - () -> TagMap.Entry.create("foo", value), - TagMap.Entry.BOOLEAN, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkFalse(entry::isNumericPrimitive), - checkFalse(entry::isNumber), - checkFalse(entry::isObject), - checkType(TagMap.Entry.BOOLEAN, entry))); - } - - @ParameterizedTest - @DisplayName("newBooleanEntry: Boolean") - @ValueSource(booleans = {false, true}) - public void newBooleanEntryBoxed(boolean value) { - test( - () -> TagMap.Entry.newBooleanEntry("foo", Boolean.valueOf(value)), - TagMap.Entry.BOOLEAN, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkFalse(entry::isNumericPrimitive), - checkFalse(entry::isNumber), - checkFalse(entry::isObject), - checkType(TagMap.Entry.BOOLEAN, entry))); - } - - @ParameterizedTest - @DisplayName("newAnyEntry: Boolean") - @ValueSource(booleans = {false, true}) - public void newAnyEntryBoolean(boolean value) { - test( - () -> TagMap.Entry.newAnyEntry("foo", Boolean.valueOf(value)), - TagMap.Entry.ANY, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkFalse(entry::isNumericPrimitive), - checkFalse(entry::isNumber), - checkFalse(entry::isObject), - checkType(TagMap.Entry.BOOLEAN, entry), - checkValue(value, entry))); - } - - @ParameterizedTest - @DisplayName("TagMap.Entry.create: int") - @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) - public void createInt(int value) { - test( - () -> TagMap.Entry.create("foo", value), - TagMap.Entry.INT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkInstanceOf(Number.class, entry), - checkType(TagMap.Entry.INT, entry))); - } - - @ParameterizedTest - @DisplayName("newIntEntry: int") - @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) - public void newIntEntry(int value) { - test( - () -> TagMap.Entry.newIntEntry("foo", value), - TagMap.Entry.INT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkInstanceOf(Number.class, entry), - checkType(TagMap.Entry.INT, entry))); - } - - @ParameterizedTest - @DisplayName("newIntEntry: Integer") - @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) - public void newIntEntryBoxed(int value) { - test( - () -> TagMap.Entry.newIntEntry("foo", Integer.valueOf(value)), - TagMap.Entry.INT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkInstanceOf(Number.class, entry), - checkType(TagMap.Entry.INT, entry))); - } - - @ParameterizedTest - @DisplayName("newIntEntry: Short") - @ValueSource(shorts = {Short.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Short.MAX_VALUE}) - public void newIntEntryBoxedShort(short value) { - test( - () -> TagMap.Entry.newIntEntry("foo", Short.valueOf(value)), - TagMap.Entry.INT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkInstanceOf(Number.class, entry), - checkType(TagMap.Entry.INT, entry))); - } - - @ParameterizedTest - @DisplayName("newIntEntry: Byte") - @ValueSource(bytes = {Byte.MIN_VALUE, -32, -1, 0, 1, 32, Byte.MAX_VALUE}) - public void newIntEntryBoxedByte(byte value) { - test( - () -> TagMap.Entry.newIntEntry("foo", Byte.valueOf(value)), - TagMap.Entry.INT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkInstanceOf(Number.class, entry), - checkType(TagMap.Entry.INT, entry))); - } - - @ParameterizedTest - @DisplayName("newAnyEntry: Integer") - @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) - public void newAnyEntryInteger(int value) { - test( - () -> TagMap.Entry.newAnyEntry("foo", Integer.valueOf(value)), - TagMap.Entry.ANY, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkInstanceOf(Number.class, entry), - checkType(TagMap.Entry.INT, entry), - checkValue(value, entry))); - } - - @ParameterizedTest - @DisplayName("TagMap.Entry.create: long") - @ValueSource( - longs = { - Long.MIN_VALUE, - Integer.MIN_VALUE, - -1_048_576L, - -256L, - -128L, - -1L, - 0L, - 1L, - 128L, - 256L, - 1_048_576L, - Integer.MAX_VALUE, - Long.MAX_VALUE - }) - public void createLong(long value) { - test( - () -> TagMap.Entry.create("foo", value), - TagMap.Entry.LONG, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkTrue(entry::isNumericPrimitive), - checkType(TagMap.Entry.LONG, entry))); - } - - @ParameterizedTest - @DisplayName("newLongEntry: long") - @ValueSource( - longs = { - Long.MIN_VALUE, - Integer.MIN_VALUE, - -1_048_576L, - -256L, - -128L, - -1L, - 0L, - 1L, - 128L, - 256L, - 1_048_576L, - Integer.MAX_VALUE, - Long.MAX_VALUE - }) - public void newLongEntry(long value) { - test( - () -> TagMap.Entry.newLongEntry("foo", value), - TagMap.Entry.LONG, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkTrue(entry::isNumericPrimitive), - checkType(TagMap.Entry.LONG, entry))); - } - - @ParameterizedTest - @DisplayName("newLongEntry: Long") - @ValueSource( - longs = { - Long.MIN_VALUE, - Integer.MIN_VALUE, - -1_048_576L, - -256L, - -128L, - -1L, - 0L, - 1L, - 128L, - 256L, - 1_048_576L, - Integer.MAX_VALUE, - Long.MAX_VALUE - }) - public void newLongEntryBoxed(long value) { - test( - () -> TagMap.Entry.newLongEntry("foo", Long.valueOf(value)), - TagMap.Entry.LONG, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkType(TagMap.Entry.LONG, entry))); - } - - @ParameterizedTest - @DisplayName("newAnyEntry: Long") - @ValueSource( - longs = { - Long.MIN_VALUE, - Integer.MIN_VALUE, - -1_048_576L, - -256L, - -128L, - -1L, - 0L, - 1L, - 128L, - 256L, - 1_048_576L, - Integer.MAX_VALUE, - Long.MAX_VALUE - }) - public void newAnyEntryLong(long value) { - test( - () -> TagMap.Entry.newAnyEntry("foo", Long.valueOf(value)), - TagMap.Entry.ANY, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkTrue(() -> entry.is(TagMap.Entry.LONG)), - checkValue(value, entry))); - } - - @ParameterizedTest - @DisplayName("TagMap.Entry.create: float") - @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) - public void createFloat(float value) { - test( - () -> TagMap.Entry.create("foo", value), - TagMap.Entry.FLOAT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkTrue(entry::isNumericPrimitive), - checkType(TagMap.Entry.FLOAT, entry))); - } - - @ParameterizedTest - @DisplayName("newFloatEntry: float") - @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) - public void newFloatEntry(float value) { - test( - () -> TagMap.Entry.newFloatEntry("foo", value), - TagMap.Entry.FLOAT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkTrue(entry::isNumericPrimitive), - checkType(TagMap.Entry.FLOAT, entry))); - } - - @ParameterizedTest - @DisplayName("newFloatEntry: Float") - @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) - public void newFloatEntryBoxed(float value) { - test( - () -> TagMap.Entry.newFloatEntry("foo", Float.valueOf(value)), - TagMap.Entry.FLOAT, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkType(TagMap.Entry.FLOAT, entry))); - } - - @ParameterizedTest - @DisplayName("newAnyEntry: Float") - @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) - public void newAnyEntryFloat(float value) { - test( - () -> TagMap.Entry.newAnyEntry("foo", Float.valueOf(value)), - TagMap.Entry.ANY, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkTrue(entry::isNumericPrimitive), - checkType(TagMap.Entry.FLOAT, entry))); - } - - @ParameterizedTest - @DisplayName("TagMap.Entry.create: double") - @ValueSource( - doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) - public void createDouble(double value) { - test( - () -> TagMap.Entry.create("foo", value), - TagMap.Entry.DOUBLE, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkType(TagMap.Entry.DOUBLE, entry))); - } - - @ParameterizedTest - @ValueSource( - doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) - public void newDoubleEntry(double value) { - test( - () -> TagMap.Entry.newDoubleEntry("foo", value), - TagMap.Entry.DOUBLE, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkIsNumericPrimitive(entry), - checkType(TagMap.Entry.DOUBLE, entry))); - } - - @ParameterizedTest - @DisplayName("newDoubleEntry: Double") - @ValueSource( - doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) - public void newDoubleEntryBoxed(double value) { - test( - () -> TagMap.Entry.newDoubleEntry("foo", Double.valueOf(value)), - TagMap.Entry.DOUBLE, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkTrue(entry::isNumericPrimitive), - checkType(TagMap.Entry.DOUBLE, entry))); - } - - @ParameterizedTest - @DisplayName("newAnyEntry: Double") - @ValueSource( - doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) - public void newAnyEntryDouble(double value) { - test( - () -> TagMap.Entry.newAnyEntry("foo", Double.valueOf(value)), - TagMap.Entry.ANY, - (entry) -> - multiCheck( - checkKey("foo", entry), - checkValue(value, entry), - checkTrue(entry::isNumericPrimitive), - checkType(TagMap.Entry.DOUBLE, entry), - checkValue(value, entry))); - } - - @Test - public void removalChange() { - TagMap.EntryChange removalChange = TagMap.EntryChange.newRemoval("foo"); - assertTrue(removalChange.isRemoval()); - } - - static final int NUM_THREADS = 4; - static final ExecutorService EXECUTOR = - Executors.newFixedThreadPool( - NUM_THREADS, - new ThreadFactory() { + @Test + public void isNumericPrimitive() { + assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.ANY)); + assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.BOOLEAN)); + assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.CHAR_RESERVED)); + assertFalse(TagMap.Entry._isNumericPrimitive(TagMap.Entry.OBJECT)); + + assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.BYTE_RESERVED)); + assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.SHORT_RESERVED)); + assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.INT)); + assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.LONG)); + assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.FLOAT)); + assertTrue(TagMap.Entry._isNumericPrimitive(TagMap.Entry.DOUBLE)); + } + + @Test + @DisplayName("TagMap.Entry.create: Object") + public void createObject() { + Map map = new HashMap<>(); + + test( + () -> TagMap.Entry.create("foo", map), + TagMap.Entry.ANY, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(map, entry), + checkTrue(entry::isObject), + checkFalse(entry::isNumber), + checkType(TagMap.Entry.OBJECT, entry))); + } + + @Test + @DisplayName("TagMap.Entry.create: null Object") + public void createEntryNullObject() { + assertNull(TagMap.Entry.create("foo", null)); + } + + @Test + @DisplayName("TagMap.Entry.create: CharSequence") + public void createCharSequence() { + test( + () -> TagMap.Entry.create("foo", "bar"), + TagMap.Entry.OBJECT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue("bar", entry), + checkEquals("bar", entry::stringValue), + checkFalse(entry::isNumber), + checkTrue(entry::isObject), + checkType(TagMap.Entry.OBJECT, entry))); + } + + @Test + @DisplayName("TagMap.Entry.create: null CharSequence") + public void createNullCharSequence() { + assertNull(TagMap.Entry.create("foo", (String) null)); + } + + @Test + @DisplayName("TagMap.Entry.create: empty CharSequence") + public void createEmptyCharSequence() { + assertNull(TagMap.Entry.create("foo", "")); + } + + @Test + public void newObjectEntry() { + test( + () -> TagMap.Entry.newObjectEntry("foo", "bar"), + TagMap.Entry.OBJECT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue("bar", entry), + checkEquals("bar", entry::stringValue), + checkFalse(entry::isNumber), + checkTrue(entry::isObject), + checkType(TagMap.Entry.OBJECT, entry))); + } + + @Test + @DisplayName("newAnyEntry: Object") + public void anyEntryObject() { + test( + () -> TagMap.Entry.newAnyEntry("foo", "bar"), + TagMap.Entry.ANY, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue("bar", entry), + checkTrue(entry::isObject), + checkFalse(entry::isNumber), + checkType(TagMap.EntryReader.OBJECT, entry))); + } + + @ParameterizedTest + @DisplayName("TagMap.Entry.create: boolean") + @ValueSource(booleans = {false, true}) + public void createBoolean(boolean value) { + test( + () -> TagMap.Entry.create("foo", value), + TagMap.Entry.BOOLEAN, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkFalse(entry::isNumericPrimitive), + checkFalse(entry::isNumber), + checkFalse(entry::isObject), + checkType(TagMap.Entry.BOOLEAN, entry))); + } + + @ParameterizedTest + @DisplayName("newBooleanEntry: boolean") + @ValueSource(booleans = {false, true}) + public void newBooleanEntry(boolean value) { + test( + () -> TagMap.Entry.create("foo", value), + TagMap.Entry.BOOLEAN, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkFalse(entry::isNumericPrimitive), + checkFalse(entry::isNumber), + checkFalse(entry::isObject), + checkType(TagMap.Entry.BOOLEAN, entry))); + } + + @ParameterizedTest + @DisplayName("newBooleanEntry: Boolean") + @ValueSource(booleans = {false, true}) + public void newBooleanEntryBoxed(boolean value) { + test( + () -> TagMap.Entry.newBooleanEntry("foo", Boolean.valueOf(value)), + TagMap.Entry.BOOLEAN, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkFalse(entry::isNumericPrimitive), + checkFalse(entry::isNumber), + checkFalse(entry::isObject), + checkType(TagMap.Entry.BOOLEAN, entry))); + } + + @ParameterizedTest + @DisplayName("newAnyEntry: Boolean") + @ValueSource(booleans = {false, true}) + public void newAnyEntryBoolean(boolean value) { + test( + () -> TagMap.Entry.newAnyEntry("foo", Boolean.valueOf(value)), + TagMap.Entry.ANY, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkFalse(entry::isNumericPrimitive), + checkFalse(entry::isNumber), + checkFalse(entry::isObject), + checkType(TagMap.Entry.BOOLEAN, entry), + checkValue(value, entry))); + } + + @ParameterizedTest + @DisplayName("TagMap.Entry.create: int") + @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) + public void createInt(int value) { + test( + () -> TagMap.Entry.create("foo", value), + TagMap.Entry.INT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkInstanceOf(Number.class, entry), + checkType(TagMap.Entry.INT, entry))); + } + + @ParameterizedTest + @DisplayName("newIntEntry: int") + @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) + public void newIntEntry(int value) { + test( + () -> TagMap.Entry.newIntEntry("foo", value), + TagMap.Entry.INT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkInstanceOf(Number.class, entry), + checkType(TagMap.Entry.INT, entry))); + } + + @ParameterizedTest + @DisplayName("newIntEntry: Integer") + @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) + public void newIntEntryBoxed(int value) { + test( + () -> TagMap.Entry.newIntEntry("foo", Integer.valueOf(value)), + TagMap.Entry.INT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkInstanceOf(Number.class, entry), + checkType(TagMap.Entry.INT, entry))); + } + + @ParameterizedTest + @DisplayName("newIntEntry: Short") + @ValueSource(shorts = {Short.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Short.MAX_VALUE}) + public void newIntEntryBoxedShort(short value) { + test( + () -> TagMap.Entry.newIntEntry("foo", Short.valueOf(value)), + TagMap.Entry.INT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkInstanceOf(Number.class, entry), + checkType(TagMap.Entry.INT, entry))); + } + + @ParameterizedTest + @DisplayName("newIntEntry: Byte") + @ValueSource(bytes = {Byte.MIN_VALUE, -32, -1, 0, 1, 32, Byte.MAX_VALUE}) + public void newIntEntryBoxedByte(byte value) { + test( + () -> TagMap.Entry.newIntEntry("foo", Byte.valueOf(value)), + TagMap.Entry.INT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkInstanceOf(Number.class, entry), + checkType(TagMap.Entry.INT, entry))); + } + + @ParameterizedTest + @DisplayName("newAnyEntry: Integer") + @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) + public void newAnyEntryInteger(int value) { + test( + () -> TagMap.Entry.newAnyEntry("foo", Integer.valueOf(value)), + TagMap.Entry.ANY, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkInstanceOf(Number.class, entry), + checkType(TagMap.Entry.INT, entry), + checkValue(value, entry))); + } + + @ParameterizedTest + @DisplayName("TagMap.Entry.create: long") + @ValueSource( + longs = { + Long.MIN_VALUE, + Integer.MIN_VALUE, + -1_048_576L, + -256L, + -128L, + -1L, + 0L, + 1L, + 128L, + 256L, + 1_048_576L, + Integer.MAX_VALUE, + Long.MAX_VALUE + }) + public void createLong(long value) { + test( + () -> TagMap.Entry.create("foo", value), + TagMap.Entry.LONG, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkTrue(entry::isNumericPrimitive), + checkType(TagMap.Entry.LONG, entry))); + } + + @ParameterizedTest + @DisplayName("newLongEntry: long") + @ValueSource( + longs = { + Long.MIN_VALUE, + Integer.MIN_VALUE, + -1_048_576L, + -256L, + -128L, + -1L, + 0L, + 1L, + 128L, + 256L, + 1_048_576L, + Integer.MAX_VALUE, + Long.MAX_VALUE + }) + public void newLongEntry(long value) { + test( + () -> TagMap.Entry.newLongEntry("foo", value), + TagMap.Entry.LONG, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkTrue(entry::isNumericPrimitive), + checkType(TagMap.Entry.LONG, entry))); + } + + @ParameterizedTest + @DisplayName("newLongEntry: Long") + @ValueSource( + longs = { + Long.MIN_VALUE, + Integer.MIN_VALUE, + -1_048_576L, + -256L, + -128L, + -1L, + 0L, + 1L, + 128L, + 256L, + 1_048_576L, + Integer.MAX_VALUE, + Long.MAX_VALUE + }) + public void newLongEntryBoxed(long value) { + test( + () -> TagMap.Entry.newLongEntry("foo", Long.valueOf(value)), + TagMap.Entry.LONG, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkType(TagMap.Entry.LONG, entry))); + } + + @ParameterizedTest + @DisplayName("newAnyEntry: Long") + @ValueSource( + longs = { + Long.MIN_VALUE, + Integer.MIN_VALUE, + -1_048_576L, + -256L, + -128L, + -1L, + 0L, + 1L, + 128L, + 256L, + 1_048_576L, + Integer.MAX_VALUE, + Long.MAX_VALUE + }) + public void newAnyEntryLong(long value) { + test( + () -> TagMap.Entry.newAnyEntry("foo", Long.valueOf(value)), + TagMap.Entry.ANY, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkTrue(() -> entry.is(TagMap.Entry.LONG)), + checkValue(value, entry))); + } + + @ParameterizedTest + @DisplayName("TagMap.Entry.create: float") + @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) + public void createFloat(float value) { + test( + () -> TagMap.Entry.create("foo", value), + TagMap.Entry.FLOAT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkTrue(entry::isNumericPrimitive), + checkType(TagMap.Entry.FLOAT, entry))); + } + + @ParameterizedTest + @DisplayName("newFloatEntry: float") + @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) + public void newFloatEntry(float value) { + test( + () -> TagMap.Entry.newFloatEntry("foo", value), + TagMap.Entry.FLOAT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkTrue(entry::isNumericPrimitive), + checkType(TagMap.Entry.FLOAT, entry))); + } + + @ParameterizedTest + @DisplayName("newFloatEntry: Float") + @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) + public void newFloatEntryBoxed(float value) { + test( + () -> TagMap.Entry.newFloatEntry("foo", Float.valueOf(value)), + TagMap.Entry.FLOAT, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkType(TagMap.Entry.FLOAT, entry))); + } + + @ParameterizedTest + @DisplayName("newAnyEntry: Float") + @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) + public void newAnyEntryFloat(float value) { + test( + () -> TagMap.Entry.newAnyEntry("foo", Float.valueOf(value)), + TagMap.Entry.ANY, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkTrue(entry::isNumericPrimitive), + checkType(TagMap.Entry.FLOAT, entry))); + } + + @ParameterizedTest + @DisplayName("TagMap.Entry.create: double") + @ValueSource(doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) + public void createDouble(double value) { + test( + () -> TagMap.Entry.create("foo", value), + TagMap.Entry.DOUBLE, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkType(TagMap.Entry.DOUBLE, entry))); + } + + @ParameterizedTest + @ValueSource(doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) + public void newDoubleEntry(double value) { + test( + () -> TagMap.Entry.newDoubleEntry("foo", value), + TagMap.Entry.DOUBLE, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkIsNumericPrimitive(entry), + checkType(TagMap.Entry.DOUBLE, entry))); + } + + @ParameterizedTest + @DisplayName("newDoubleEntry: Double") + @ValueSource(doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) + public void newDoubleEntryBoxed(double value) { + test( + () -> TagMap.Entry.newDoubleEntry("foo", Double.valueOf(value)), + TagMap.Entry.DOUBLE, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkTrue(entry::isNumericPrimitive), + checkType(TagMap.Entry.DOUBLE, entry))); + } + + @ParameterizedTest + @DisplayName("newAnyEntry: Double") + @ValueSource(doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) + public void newAnyEntryDouble(double value) { + test( + () -> TagMap.Entry.newAnyEntry("foo", Double.valueOf(value)), + TagMap.Entry.ANY, + (entry) -> multiCheck( + checkKey("foo", entry), + checkValue(value, entry), + checkTrue(entry::isNumericPrimitive), + checkType(TagMap.Entry.DOUBLE, entry), + checkValue(value, entry))); + } + + @Test + public void removalChange() { + TagMap.EntryChange removalChange = TagMap.EntryChange.newRemoval("foo"); + assertTrue(removalChange.isRemoval()); + } + + static final int NUM_THREADS = 4; + static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(NUM_THREADS, new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "multithreaded-test-runner"); + thread.setDaemon(true); + return thread; + } + }); + + static final void test(Supplier entrySupplier, byte rawType, Function checkSupplier) { + + Function combinedCheckSupplier = (entry) -> { + return multiCheck( + checkSupplier.apply(entry), checkSame(entry, entry.entry()), checkSame(entry, entry.mapEntry())); + }; + + // repeat the test several times to exercise different orderings in this thread + for (int i = 0; i < 10; ++i) { + testSingleThreaded(entrySupplier, rawType, combinedCheckSupplier); + } + + // same for multi-threaded + for (int i = 0; i < 5; ++i) { + testMultiThreaded(entrySupplier, rawType, combinedCheckSupplier); + } + } + + static final void testSingleThreaded( + Supplier entrySupplier, byte rawType, Function checkSupplier) { + Entry entry = entrySupplier.get(); + assertEquals(rawType, entry.rawType, "rawType"); + + Check checks = checkSupplier.apply(entry); + checks.check(); + } + + static final void testMultiThreaded( + Supplier entrySupplier, byte rawType, Function checkSupplier) { + Entry sharedEntry = entrySupplier.get(); + assertEquals(rawType, sharedEntry.rawType, "rawType"); + + Check checks = checkSupplier.apply(sharedEntry); + + List> callables = new ArrayList<>(NUM_THREADS); + for (int i = 0; i < NUM_THREADS; ++i) { + // Different shuffle for each thread + Check shuffledChecks = checks.shuffle(); + + callables.add(() -> { + shuffledChecks.check(); + + return null; + }); + } + + List> futures; + try { + futures = EXECUTOR.invokeAll(callables); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + + throw new IllegalStateException(e); + } + + for (Future future : futures) { + try { + future.get(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof Error) { + throw (Error) cause; + } else if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } else { + throw new IllegalStateException(cause); + } + } + } + } + + static final void assertChecks(Check check) { + check.check(); + } + + static final Check checkKey(String expected, TagMap.Entry entry) { + return multiCheck(checkEquals(expected, entry::tag), checkEquals(expected, entry::getKey)); + } + + static final Supplier of(Supplier supplier, String identifier) { + return new Supplier() { @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "multithreaded-test-runner"); - thread.setDaemon(true); - return thread; + public T get() { + return supplier.get(); } - }); - static final void test( - Supplier entrySupplier, byte rawType, Function checkSupplier) { + @Override + public String toString() { + return identifier; + } + }; + } + + static final Function of(Function func, String identifier) { + return new Function() { + @Override + public O apply(I input) { + return func.apply(input); + } - Function combinedCheckSupplier = - (entry) -> { - return multiCheck( - checkSupplier.apply(entry), - checkSame(entry, entry.entry()), - checkSame(entry, entry.mapEntry())); + @Override + public String toString() { + return identifier; + } }; + } - // repeat the test several times to exercise different orderings in this thread - for (int i = 0; i < 10; ++i) { - testSingleThreaded(entrySupplier, rawType, combinedCheckSupplier); + static final Supplier of(Function output, Supplier input) { + return of(output, input, output.toString() + "(" + input.toString() + ")"); } - // same for multi-threaded - for (int i = 0; i < 5; ++i) { - testMultiThreaded(entrySupplier, rawType, combinedCheckSupplier); + static final Supplier of(Function output, Supplier input, String identifier) { + return of(() -> output.apply(input.get()), identifier); } - } - static final void testSingleThreaded( - Supplier entrySupplier, byte rawType, Function checkSupplier) { - Entry entry = entrySupplier.get(); - assertEquals(rawType, entry.rawType, "rawType"); + static final Check checkIsNumericPrimitive(TagMap.Entry entry) { + return multiCheck( + checkTrue(entry::isNumericPrimitive, "Entry::isNumericPrimitive"), + checkTrue(entry::isNumber, "Entry::isNumber"), + checkInstanceOf(Number.class, entry), + checkFalse(entry::isObject, "Entry::isObject"), + checkTrue(of(TagValueConversions::isNumber, entry::objectValue), "isNumber(Object)"), + checkTrue( + of(TagValueConversions::isNumericPrimitive, entry::objectValue), "isNumericPrimitive(Object)"), + checkFalse(of(TagValueConversions::isObject, entry::objectValue), "isObject(Object)")); + } - Check checks = checkSupplier.apply(entry); - checks.check(); - } + static final Check checkIsBigNumber(TagMap.Entry entry) { + return multiCheck( + checkFalse(entry::isNumericPrimitive, "Entry::isNumericPrimitive"), + checkFalse( + of(TagValueConversions::isNumericPrimitive, entry::objectValue), "isNumericPrimitive(Object)"), + checkTrue(entry::isNumber, "Entry::isNumber"), + checkTrue(entry::isObject, "Entry::isObject"), + checkTrue(of(TagValueConversions::isNumber, entry::objectValue), "isNumber(Object)"), + checkInstanceOf(Number.class, entry)); + } - static final void testMultiThreaded( - Supplier entrySupplier, byte rawType, Function checkSupplier) { - Entry sharedEntry = entrySupplier.get(); - assertEquals(rawType, sharedEntry.rawType, "rawType"); + static final Check checkValue(Object expected, TagMap.Entry entry) { + return multiCheck( + checkEquals(expected, entry::objectValue), + checkEquals(expected, entry::getValue), + checkEquals(expected.toString(), entry::stringValue)); + } - Check checks = checkSupplier.apply(sharedEntry); + static final Check checkValue(boolean expected, TagMap.Entry entry) { + return multiCheck( + checkEquals(expected, entry::booleanValue, "Entry::booleanValue"), + checkEquals(Boolean.valueOf(expected), entry::objectValue, "Entry::objectValue"), + checkEquals(expected ? 1 : 0, entry::intValue, "Entry::intValue"), + checkEquals(expected ? 1 : 0, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), + checkEquals(expected ? 1L : 0L, entry::longValue, "Entry::longValue"), + checkEquals(expected ? 1L : 0L, of(TagValueConversions::toLong, entry::objectValue), "toLong(Object)"), + checkEquals(expected ? 1D : 0D, entry::doubleValue, "Entry::doubleValue"), + checkEquals( + expected ? 1D : 0D, of(TagValueConversions::toDouble, entry::objectValue), "toDouble(Object)"), + checkEquals(expected ? 1F : 0F, entry::floatValue, "Entry::floatValue"), + checkEquals( + expected ? 1F : 0F, of(TagValueConversions::toFloat, entry::objectValue), "toFloat(Object)"), + checkEquals(Boolean.toString(expected), entry::stringValue, "Entry::stringValue"), + checkEquals( + Boolean.toString(expected), + of(TagValueConversions::toString, entry::objectValue), + "toString(Object)")); + } - List> callables = new ArrayList<>(NUM_THREADS); - for (int i = 0; i < NUM_THREADS; ++i) { - // Different shuffle for each thread - Check shuffledChecks = checks.shuffle(); + static final Check checkValue(int expected, TagMap.Entry entry) { + return multiCheck( + checkEquals(expected, entry::intValue, "Entry::intValue"), + checkEquals(Integer.valueOf(expected), entry::objectValue, "Entry::objectValue"), + checkEquals((long) expected, entry::longValue, "Entry::longValue"), + checkEquals((long) expected, of(TagValueConversions::toLong, entry::objectValue), "toLong(Object)"), + checkEquals((float) expected, entry::floatValue, "Entry::floatValue"), + checkEquals((float) expected, of(TagValueConversions::toFloat, entry::objectValue), "toFloat(Object)"), + checkEquals((double) expected, entry::doubleValue, "Entry::doubleValue"), + checkEquals( + (double) expected, of(TagValueConversions::toDouble, entry::objectValue), "toDouble(Object)"), + checkEquals(expected != 0, entry::booleanValue, "Entry::booleanValue"), + checkEquals(expected != 0, of(TagValueConversions::toBoolean, entry::objectValue), "toBoolean(Object)"), + checkEquals(Integer.toString(expected), entry::stringValue, "Entry::stringValue"), + checkEquals( + Integer.toString(expected), + of(TagValueConversions::toString, entry::objectValue), + "toString(Object)")); + } - callables.add( - () -> { - shuffledChecks.check(); + static final Check checkValue(long expected, TagMap.Entry entry) { + return multiCheck( + checkEquals(expected, entry::longValue, "Entry::longValue"), + checkEquals(Long.valueOf(expected), entry::objectValue, "Entry::objectValue"), + checkEquals((int) expected, entry::intValue, "Entry::intValue"), + checkEquals((int) expected, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), + checkEquals((float) expected, entry::floatValue, "Entry::floatValue"), + checkEquals((float) expected, of(TagValueConversions::toFloat, entry::objectValue), "toFloat(Object)"), + checkEquals((double) expected, entry::doubleValue, "Entry::doubleValue"), + checkEquals( + (double) expected, of(TagValueConversions::toDouble, entry::objectValue), "toDouble(Object)"), + checkEquals(expected != 0L, entry::booleanValue, "Entry::booleanValue"), + checkEquals( + expected != 0L, of(TagValueConversions::toBoolean, entry::objectValue), "toBoolean(Object)"), + checkEquals(Long.toString(expected), entry::stringValue, "Entry::stringValue"), + checkEquals( + Long.toString(expected), + of(TagValueConversions::toString, entry::objectValue), + "toString(Object)")); + } - return null; - }); + static final Check checkValue(double expected, TagMap.Entry entry) { + return multiCheck( + checkEquals(expected, entry::doubleValue, "Entry::doubleValue"), + checkEquals(Double.valueOf(expected), entry::objectValue, "Entry::objectValue"), + checkEquals((int) expected, entry::intValue, "Entry::intValue"), + checkEquals((int) expected, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), + checkEquals((long) expected, entry::longValue, "Entry::longValue"), + checkEquals((long) expected, of(TagValueConversions::toLong, entry::objectValue), "toLong(Object)"), + checkEquals((float) expected, entry::floatValue, "Entry::floatValue"), + checkEquals((float) expected, of(TagValueConversions::toFloat, entry::objectValue), "toFloat(Object)"), + checkEquals(expected != 0D, entry::booleanValue, "Entry::booleanValue"), + checkEquals( + expected != 0D, of(TagValueConversions::toBoolean, entry::objectValue), "toBoolean(Object)"), + checkEquals(Double.toString(expected), entry::stringValue, "Entry::stringValue"), + checkEquals( + Double.toString(expected), + of(TagValueConversions::toString, entry::objectValue), + "toString(Object)")); } - List> futures; - try { - futures = EXECUTOR.invokeAll(callables); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + static final Check checkValue(float expected, TagMap.Entry entry) { + return multiCheck( + checkEquals(expected, entry::floatValue, "Entry::floatValue"), + checkEquals(Float.valueOf(expected), entry::objectValue, "Entry::objectValue"), + checkEquals((int) expected, entry::intValue, "Entry::intValue"), + checkEquals((int) expected, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), + checkEquals((long) expected, entry::longValue, "Entry::longValue"), + checkEquals((long) expected, of(TagValueConversions::toLong, entry::objectValue), "toLong(Object)"), + checkEquals((double) expected, entry::doubleValue, "Entry::doubleValue"), + checkEquals( + (double) expected, of(TagValueConversions::toDouble, entry::objectValue), "toDouble(Object)"), + checkEquals(expected != 0F, entry::booleanValue, "Entry::booleanValue"), + checkEquals( + expected != 0F, of(TagValueConversions::toBoolean, entry::objectValue), "toBoolean(Object)"), + checkEquals(Float.toString(expected), entry::stringValue, "Entry::stringValue"), + checkEquals( + Float.toString(expected), + of(TagValueConversions::toString, entry::objectValue), + "toString(Object)")); + } - throw new IllegalStateException(e); + public static Check checkNumber(Number number, TagMap.Entry entry) { + return multiCheck( + checkEquals(number, entry::objectValue), + checkEquals(number.intValue(), entry::intValue), + checkEquals(number.intValue(), of(TagValueConversions::toInt, entry::objectValue)), + checkEquals(number.longValue(), entry::longValue), + checkEquals(number.longValue(), of(TagValueConversions::toLong, entry::objectValue)), + checkEquals(number.floatValue(), entry::floatValue), + checkEquals(number.floatValue(), of(TagValueConversions::toFloat, entry::objectValue)), + checkEquals(number.doubleValue(), entry::doubleValue), + checkEquals(number.doubleValue(), of(TagValueConversions::toDouble, entry::objectValue)), + checkEquals(number.toString(), entry::stringValue), + checkEquals(number.toString(), of(TagValueConversions::toString, entry::objectValue))); } - for (Future future : futures) { - try { - future.get(); - } catch (InterruptedException e) { - throw new IllegalStateException(e); - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - if (cause instanceof Error) { - throw (Error) cause; - } else if (cause instanceof RuntimeException) { - throw (RuntimeException) cause; - } else { - throw new IllegalStateException(cause); - } - } - } - } - - static final void assertChecks(Check check) { - check.check(); - } - - static final Check checkKey(String expected, TagMap.Entry entry) { - return multiCheck(checkEquals(expected, entry::tag), checkEquals(expected, entry::getKey)); - } - - static final Supplier of(Supplier supplier, String identifier) { - return new Supplier() { - @Override - public T get() { - return supplier.get(); - } - - @Override - public String toString() { - return identifier; - } - }; - } - - static final Function of(Function func, String identifier) { - return new Function() { - @Override - public O apply(I input) { - return func.apply(input); - } - - @Override - public String toString() { - return identifier; - } - }; - } - - static final Supplier of(Function output, Supplier input) { - return of(output, input, output.toString() + "(" + input.toString() + ")"); - } - - static final Supplier of(Function output, Supplier input, String identifier) { - return of(() -> output.apply(input.get()), identifier); - } - - static final Check checkIsNumericPrimitive(TagMap.Entry entry) { - return multiCheck( - checkTrue(entry::isNumericPrimitive, "Entry::isNumericPrimitive"), - checkTrue(entry::isNumber, "Entry::isNumber"), - checkInstanceOf(Number.class, entry), - checkFalse(entry::isObject, "Entry::isObject"), - checkTrue(of(TagValueConversions::isNumber, entry::objectValue), "isNumber(Object)"), - checkTrue( - of(TagValueConversions::isNumericPrimitive, entry::objectValue), - "isNumericPrimitive(Object)"), - checkFalse(of(TagValueConversions::isObject, entry::objectValue), "isObject(Object)")); - } - - static final Check checkIsBigNumber(TagMap.Entry entry) { - return multiCheck( - checkFalse(entry::isNumericPrimitive, "Entry::isNumericPrimitive"), - checkFalse( - of(TagValueConversions::isNumericPrimitive, entry::objectValue), - "isNumericPrimitive(Object)"), - checkTrue(entry::isNumber, "Entry::isNumber"), - checkTrue(entry::isObject, "Entry::isObject"), - checkTrue(of(TagValueConversions::isNumber, entry::objectValue), "isNumber(Object)"), - checkInstanceOf(Number.class, entry)); - } - - static final Check checkValue(Object expected, TagMap.Entry entry) { - return multiCheck( - checkEquals(expected, entry::objectValue), - checkEquals(expected, entry::getValue), - checkEquals(expected.toString(), entry::stringValue)); - } - - static final Check checkValue(boolean expected, TagMap.Entry entry) { - return multiCheck( - checkEquals(expected, entry::booleanValue, "Entry::booleanValue"), - checkEquals(Boolean.valueOf(expected), entry::objectValue, "Entry::objectValue"), - checkEquals(expected ? 1 : 0, entry::intValue, "Entry::intValue"), - checkEquals( - expected ? 1 : 0, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), - checkEquals(expected ? 1L : 0L, entry::longValue, "Entry::longValue"), - checkEquals( - expected ? 1L : 0L, - of(TagValueConversions::toLong, entry::objectValue), - "toLong(Object)"), - checkEquals(expected ? 1D : 0D, entry::doubleValue, "Entry::doubleValue"), - checkEquals( - expected ? 1D : 0D, - of(TagValueConversions::toDouble, entry::objectValue), - "toDouble(Object)"), - checkEquals(expected ? 1F : 0F, entry::floatValue, "Entry::floatValue"), - checkEquals( - expected ? 1F : 0F, - of(TagValueConversions::toFloat, entry::objectValue), - "toFloat(Object)"), - checkEquals(Boolean.toString(expected), entry::stringValue, "Entry::stringValue"), - checkEquals( - Boolean.toString(expected), - of(TagValueConversions::toString, entry::objectValue), - "toString(Object)")); - } - - static final Check checkValue(int expected, TagMap.Entry entry) { - return multiCheck( - checkEquals(expected, entry::intValue, "Entry::intValue"), - checkEquals(Integer.valueOf(expected), entry::objectValue, "Entry::objectValue"), - checkEquals((long) expected, entry::longValue, "Entry::longValue"), - checkEquals( - (long) expected, of(TagValueConversions::toLong, entry::objectValue), "toLong(Object)"), - checkEquals((float) expected, entry::floatValue, "Entry::floatValue"), - checkEquals( - (float) expected, - of(TagValueConversions::toFloat, entry::objectValue), - "toFloat(Object)"), - checkEquals((double) expected, entry::doubleValue, "Entry::doubleValue"), - checkEquals( - (double) expected, - of(TagValueConversions::toDouble, entry::objectValue), - "toDouble(Object)"), - checkEquals(expected != 0, entry::booleanValue, "Entry::booleanValue"), - checkEquals( - expected != 0, - of(TagValueConversions::toBoolean, entry::objectValue), - "toBoolean(Object)"), - checkEquals(Integer.toString(expected), entry::stringValue, "Entry::stringValue"), - checkEquals( - Integer.toString(expected), - of(TagValueConversions::toString, entry::objectValue), - "toString(Object)")); - } - - static final Check checkValue(long expected, TagMap.Entry entry) { - return multiCheck( - checkEquals(expected, entry::longValue, "Entry::longValue"), - checkEquals(Long.valueOf(expected), entry::objectValue, "Entry::objectValue"), - checkEquals((int) expected, entry::intValue, "Entry::intValue"), - checkEquals( - (int) expected, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), - checkEquals((float) expected, entry::floatValue, "Entry::floatValue"), - checkEquals( - (float) expected, - of(TagValueConversions::toFloat, entry::objectValue), - "toFloat(Object)"), - checkEquals((double) expected, entry::doubleValue, "Entry::doubleValue"), - checkEquals( - (double) expected, - of(TagValueConversions::toDouble, entry::objectValue), - "toDouble(Object)"), - checkEquals(expected != 0L, entry::booleanValue, "Entry::booleanValue"), - checkEquals( - expected != 0L, - of(TagValueConversions::toBoolean, entry::objectValue), - "toBoolean(Object)"), - checkEquals(Long.toString(expected), entry::stringValue, "Entry::stringValue"), - checkEquals( - Long.toString(expected), - of(TagValueConversions::toString, entry::objectValue), - "toString(Object)")); - } - - static final Check checkValue(double expected, TagMap.Entry entry) { - return multiCheck( - checkEquals(expected, entry::doubleValue, "Entry::doubleValue"), - checkEquals(Double.valueOf(expected), entry::objectValue, "Entry::objectValue"), - checkEquals((int) expected, entry::intValue, "Entry::intValue"), - checkEquals( - (int) expected, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), - checkEquals((long) expected, entry::longValue, "Entry::longValue"), - checkEquals( - (long) expected, of(TagValueConversions::toLong, entry::objectValue), "toLong(Object)"), - checkEquals((float) expected, entry::floatValue, "Entry::floatValue"), - checkEquals( - (float) expected, - of(TagValueConversions::toFloat, entry::objectValue), - "toFloat(Object)"), - checkEquals(expected != 0D, entry::booleanValue, "Entry::booleanValue"), - checkEquals( - expected != 0D, - of(TagValueConversions::toBoolean, entry::objectValue), - "toBoolean(Object)"), - checkEquals(Double.toString(expected), entry::stringValue, "Entry::stringValue"), - checkEquals( - Double.toString(expected), - of(TagValueConversions::toString, entry::objectValue), - "toString(Object)")); - } - - static final Check checkValue(float expected, TagMap.Entry entry) { - return multiCheck( - checkEquals(expected, entry::floatValue, "Entry::floatValue"), - checkEquals(Float.valueOf(expected), entry::objectValue, "Entry::objectValue"), - checkEquals((int) expected, entry::intValue, "Entry::intValue"), - checkEquals( - (int) expected, of(TagValueConversions::toInt, entry::objectValue), "toInt(Object)"), - checkEquals((long) expected, entry::longValue, "Entry::longValue"), - checkEquals( - (long) expected, of(TagValueConversions::toLong, entry::objectValue), "toLong(Object)"), - checkEquals((double) expected, entry::doubleValue, "Entry::doubleValue"), - checkEquals( - (double) expected, - of(TagValueConversions::toDouble, entry::objectValue), - "toDouble(Object)"), - checkEquals(expected != 0F, entry::booleanValue, "Entry::booleanValue"), - checkEquals( - expected != 0F, - of(TagValueConversions::toBoolean, entry::objectValue), - "toBoolean(Object)"), - checkEquals(Float.toString(expected), entry::stringValue, "Entry::stringValue"), - checkEquals( - Float.toString(expected), - of(TagValueConversions::toString, entry::objectValue), - "toString(Object)")); - } - - public static Check checkNumber(Number number, TagMap.Entry entry) { - return multiCheck( - checkEquals(number, entry::objectValue), - checkEquals(number.intValue(), entry::intValue), - checkEquals(number.intValue(), of(TagValueConversions::toInt, entry::objectValue)), - checkEquals(number.longValue(), entry::longValue), - checkEquals(number.longValue(), of(TagValueConversions::toLong, entry::objectValue)), - checkEquals(number.floatValue(), entry::floatValue), - checkEquals(number.floatValue(), of(TagValueConversions::toFloat, entry::objectValue)), - checkEquals(number.doubleValue(), entry::doubleValue), - checkEquals(number.doubleValue(), of(TagValueConversions::toDouble, entry::objectValue)), - checkEquals(number.toString(), entry::stringValue), - checkEquals(number.toString(), of(TagValueConversions::toString, entry::objectValue))); - } - - static final Check checkInstanceOf(Class klass, TagMap.Entry entry) { - return checkTrue( - () -> klass.isAssignableFrom(entry.objectValue().getClass()), - "instanceof " + klass.getSimpleName()); - } - - static final Check checkType(byte entryType, TagMap.Entry entry) { - // TODO: TVC checks - return multiCheck( - checkTrue(() -> entry.is(entryType), "Entry::is(type) type=" + entryType), - checkEquals(entryType, entry::type, "Entry::type check")); - } - - static final Check multiCheck(Check... checks) { - return new MultipartCheck(checks); - } - - static final Check checkSame(Object expected, Object actual) { - return () -> assertSame(expected, actual); - } - - static final Check checkFalse(Supplier actual) { - return checkFalse(actual, actual.toString()); - } - - static final Check checkFalse(Supplier actual, String identifier) { - return () -> assertFalse(actual.get(), identifier); - } - - static final Check checkTrue(Supplier actual) { - return checkTrue(actual, actual.toString()); - } - - static final Check checkTrue(Supplier actual, String identifier) { - return () -> assertTrue(actual.get(), identifier); - } - - static final Check checkEquals(float expected, Supplier actual) { - return checkEquals(expected, actual, actual.toString()); - } - - static final Check checkEquals(float expected, Supplier actual, String identifier) { - return () -> assertEquals(expected, actual.get().floatValue(), identifier); - } - - static final Check checkEquals(int expected, Supplier actual) { - return checkEquals(expected, actual, actual.toString()); - } - - static final Check checkEquals(int expected, Supplier actual, String identifier) { - return () -> assertEquals(expected, actual.get().intValue(), identifier); - } - - static final Check checkEquals(double expected, Supplier actual) { - return checkEquals(expected, actual, actual.toString()); - } - - static final Check checkEquals(double expected, Supplier actual, String identifier) { - return () -> assertEquals(expected, actual.get().doubleValue(), identifier); - } - - static final Check checkEquals(long expected, Supplier actual) { - return checkEquals(expected, actual, actual.toString()); - } - - static final Check checkEquals(long expected, Supplier actual, String identifier) { - return () -> assertEquals(expected, actual.get().longValue(), identifier); - } - - static final Check checkEquals(boolean expected, Supplier actual) { - return checkEquals(expected, actual, actual.toString()); - } - - static final Check checkEquals(boolean expected, Supplier actual, String identifier) { - return () -> assertEquals(expected, actual.get().booleanValue(), identifier); - } - - static final Check checkEquals(T expected, Supplier actual) { - return checkEquals(expected, actual, actual.toString()); - } - - static final Check checkEquals(T expected, Supplier actual, String identifier) { - return () -> assertEquals(expected, actual.get(), identifier); - } + static final Check checkInstanceOf(Class klass, TagMap.Entry entry) { + return checkTrue( + () -> klass.isAssignableFrom(entry.objectValue().getClass()), "instanceof " + klass.getSimpleName()); + } - @FunctionalInterface - interface Check { - void check(); + static final Check checkType(byte entryType, TagMap.Entry entry) { + // TODO: TVC checks + return multiCheck( + checkTrue(() -> entry.is(entryType), "Entry::is(type) type=" + entryType), + checkEquals(entryType, entry::type, "Entry::type check")); + } - default Check shuffle() { - return this; + static final Check multiCheck(Check... checks) { + return new MultipartCheck(checks); } - default void flatten(List checkAccumulator) { - checkAccumulator.add(this); + static final Check checkSame(Object expected, Object actual) { + return () -> assertSame(expected, actual); } - } - static final class MultipartCheck implements Check { - private final Check[] checks; + static final Check checkFalse(Supplier actual) { + return checkFalse(actual, actual.toString()); + } - MultipartCheck(Check... checks) { - this.checks = checks; + static final Check checkFalse(Supplier actual, String identifier) { + return () -> assertFalse(actual.get(), identifier); } - private List shuffleChecks() { - List checkAccumulator = new ArrayList<>(); - for (Check check : this.checks) { - check.flatten(checkAccumulator); - } + static final Check checkTrue(Supplier actual) { + return checkTrue(actual, actual.toString()); + } - Collections.shuffle(checkAccumulator); - return checkAccumulator; + static final Check checkTrue(Supplier actual, String identifier) { + return () -> assertTrue(actual.get(), identifier); } - @Override - public void check() { - for (Check check : this.shuffleChecks()) { - check.check(); - } + static final Check checkEquals(float expected, Supplier actual) { + return checkEquals(expected, actual, actual.toString()); } - @Override - public Check shuffle() { - List shuffled = this.shuffleChecks(); + static final Check checkEquals(float expected, Supplier actual, String identifier) { + return () -> assertEquals(expected, actual.get().floatValue(), identifier); + } + + static final Check checkEquals(int expected, Supplier actual) { + return checkEquals(expected, actual, actual.toString()); + } + + static final Check checkEquals(int expected, Supplier actual, String identifier) { + return () -> assertEquals(expected, actual.get().intValue(), identifier); + } + + static final Check checkEquals(double expected, Supplier actual) { + return checkEquals(expected, actual, actual.toString()); + } + + static final Check checkEquals(double expected, Supplier actual, String identifier) { + return () -> assertEquals(expected, actual.get().doubleValue(), identifier); + } + + static final Check checkEquals(long expected, Supplier actual) { + return checkEquals(expected, actual, actual.toString()); + } + + static final Check checkEquals(long expected, Supplier actual, String identifier) { + return () -> assertEquals(expected, actual.get().longValue(), identifier); + } + + static final Check checkEquals(boolean expected, Supplier actual) { + return checkEquals(expected, actual, actual.toString()); + } + + static final Check checkEquals(boolean expected, Supplier actual, String identifier) { + return () -> assertEquals(expected, actual.get().booleanValue(), identifier); + } + + static final Check checkEquals(T expected, Supplier actual) { + return checkEquals(expected, actual, actual.toString()); + } + + static final Check checkEquals(T expected, Supplier actual, String identifier) { + return () -> assertEquals(expected, actual.get(), identifier); + } + + @FunctionalInterface + interface Check { + void check(); + + default Check shuffle() { + return this; + } + + default void flatten(List checkAccumulator) { + checkAccumulator.add(this); + } + } + + static final class MultipartCheck implements Check { + private final Check[] checks; + + MultipartCheck(Check... checks) { + this.checks = checks; + } + + private List shuffleChecks() { + List checkAccumulator = new ArrayList<>(); + for (Check check : this.checks) { + check.flatten(checkAccumulator); + } + + Collections.shuffle(checkAccumulator); + return checkAccumulator; + } - return new Check() { @Override public void check() { - for (Check check : shuffled) { - check.check(); - } + for (Check check : this.shuffleChecks()) { + check.check(); + } + } + + @Override + public Check shuffle() { + List shuffled = this.shuffleChecks(); + + return new Check() { + @Override + public void check() { + for (Check check : shuffled) { + check.check(); + } + } + }; } - }; - } - @Override - public void flatten(List checkAccumulator) { - for (Check check : this.checks) { - check.flatten(checkAccumulator); - } + @Override + public void flatten(List checkAccumulator) { + for (Check check : this.checks) { + check.flatten(checkAccumulator); + } + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java index 685dc1d9cdf..c8c038bbe18 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java @@ -16,1539 +16,1532 @@ import org.junit.jupiter.api.Test; public final class TagMapFuzzTest { - static final int NUM_KEYS = 128; - static final int MAX_NUM_ACTIONS = 32; - static final int MIN_NUM_ACTIONS = 8; - - @Test - void test() { - test(generateTest()); - } - - @Test - void testMerge() { - TestCase mapACase = generateTest(); - TestCase mapBCase = generateTest(); - - TagMap tagMapA = test(mapACase); - TagMap tagMapB = test(mapBCase); - - HashMap hashMapA = new HashMap<>(tagMapA); - HashMap hashMapB = new HashMap<>(tagMapB); - - tagMapA.putAll(tagMapB); - hashMapA.putAll(hashMapB); - - assertMapEquals(hashMapA, tagMapA); - } - - @Test - void priorFailingCase0() { - TagMap map = - makeTagMap( - remove("key-4"), - put("key-71", "values-443049055"), - put("key-2", "values-1227065898"), - put("key-25", "values-696891692"), - put("key-93", "values-763707175"), - put("key-23", "values--1514091210"), - put("key-16", "values--1388742686")); - - MapAction failingAction = - putAllTagMap( - "key-17", - "values--2085338893", - "key-51", - "values-960243765", - "key-33", - "values-1493544499", - "key-46", - "values-697926849", - "key-70", - "values--184054454", - "key-67", - "values-374577326", - "key-9", - "values--742453833", - "key-11", - "values-1606950841", - "key-119", - "values--1914593057", - "key-53", - "values-375236438", - "key-96", - "values--107185569", - "key-47", - "values--1276407408", - "key-125", - "values--1627172151", - "key-110", - "values--1227150283", - "key-15", - "values-380379920", - "key-42", - "values--632271048", - "key-99", - "values--650090786", - "key-8", - "values--1990889145", - "key-103", - "values-1815698254", - "key-120", - "values-279025031", - "key-93", - "values-589795963", - "key-12", - "values--935895941", - "key-105", - "values-94976227", - "key-85", - "values--424609970", - "key-78", - "values-1231948102", - "key-115", - "values-88670282", - "key-26", - "values-733903384", - "key-100", - "values-2102967487", - "key-74", - "values-958598087", - "key-104", - "values-264458254", - "key-125", - "values--1781797927", - "key-27", - "values--562810078", - "key-7", - "values--376776745", - "key-111", - "values-263564677", - "key-50", - "values--859673100", - "key-57", - "values-1585057281", - "key-48", - "values--617889787", - "key-98", - "values--1878108220", - "key-9", - "values--227223375", - "key-59", - "values-1577082288", - "key-94", - "values--268049040", - "key-0", - "values-1708355496", - "key-62", - "values--733451297", - "key-14", - "values-232732747", - "key-4", - "values--406605642", - "key-58", - "values-1772476833", - "key-8", - "values--1155025225", - "key-101", - "values-144480545", - "key-66", - "values-355117269", - "key-121", - "values-1858008722", - "key-33", - "values-1947754079", - "key-1", - "values--1475603838", - "key-125", - "values--2146772243", - "key-117", - "values-852022714", - "key-53", - "values--2039348506", - "key-65", - "values-2011228657", - "key-108", - "values-1581592518", - "key-17", - "values-2129571020", - "key-5", - "values-1106900841", - "key-80", - "values-1791757923", - "key-18", - "values--1992962227", - "key-2", - "values-328863878", - "key-110", - "values-1182949334", - "key-5", - "values-1049403346", - "key-107", - "values-1246502060", - "key-115", - "values-2053931423", - "key-19", - "values--1731179633", - "key-104", - "values--1090790550", - "key-67", - "values--1312759979", - "key-10", - "values-1411135", - "key-109", - "values--1784920248", - "key-20", - "values--827644780", - "key-55", - "values--1610270998", - "key-60", - "values-1287959520", - "key-31", - "values-1686541667", - "key-41", - "values-399844058", - "key-115", - "values-2045201464", - "key-78", - "values-358081227", - "key-57", - "values--1374149269", - "key-65", - "values-1871734555", - "key-124", - "values--211494558", - "key-119", - "values-1757597102", - "key-32", - "values--336988038", - "key-85", - "values-1415155858", - "key-44", - "values-1455425178", - "key-48", - "values--325658059", - "key-68", - "values--793590840", - "key-96", - "values--2010766492", - "key-40", - "values-2007171160", - "key-29", - "values-186945230", - "key-63", - "values-1741962849", - "key-26", - "values-948582805", - "key-31", - "values-47004766", - "key-90", - "values-1304302008", - "key-69", - "values-2120328211", - "key-111", - "values-2053321468", - "key-69", - "values--498524858", - "key-125", - "values--193004619", - "key-30", - "values--1142090845", - "key-15", - "values--1334900170", - "key-33", - "values-1011001500", - "key-55", - "values-452401605", - "key-18", - "values-1260118555", - "key-44", - "values--1109396459", - "key-2", - "values--555647718", - "key-61", - "values-1060742038", - "key-51", - "values--827099230", - "key-62", - "values--1443716296", - "key-16", - "values-534556355", - "key-81", - "values--787910427", - "key-20", - "values-1429697120", - "key-36", - "values--1775988293", - "key-66", - "values-624669635", - "key-25", - "values--684183265", - "key-26", - "values-293626449", - "key-91", - "values--1212867803", - "key-6", - "values-1778251481", - "key-83", - "values-1257370908", - "key-92", - "values--1120490028", - "key-111", - "values-9646496", - "key-90", - "values-1485206899"); - failingAction.applyToTestMap(map); - failingAction.verifyTestMap(map); - } - - @Test - void priorFailingCase1() { - TagMap map = makeTagMap(put("key-68", "values--37178328"), put("key-93", "values--2093086281")); - - MapAction failingAction = - putAllTagMap( - "key-36", - "values--1951535044", - "key-59", - "values--1045985660", - "key-68", - "values-1270827526", - "key-65", - "values-440073158", - "key-91", - "values-954365843", - "key-75", - "values-1014366449", - "key-117", - "values--1306617705", - "key-90", - "values-984567966", - "key-120", - "values--1802603599", - "key-56", - "values-319574488", - "key-78", - "values--711288173", - "key-103", - "values-694279462", - "key-84", - "values-1391260657", - "key-59", - "values--484807195", - "key-67", - "values-1675498322", - "key-91", - "values--227731796", - "key-105", - "values--1471022333", - "key-112", - "values--755617374", - "key-117", - "values--668324524", - "key-65", - "values-1165174761", - "key-13", - "values--1947081814", - "key-72", - "values-2032502631", - "key-106", - "values-256372025", - "key-71", - "values--995163162", - "key-92", - "values-972782926", - "key-116", - "values-25012447", - "key-23", - "values--979671053", - "key-94", - "values-367125724", - "key-48", - "values--2011523144", - "key-14", - "values-578926680", - "key-65", - "values-1325737627", - "key-89", - "values-1539092266", - "key-100", - "values--319629978", - "key-53", - "values-1125496255", - "key-2", - "values-1988036327", - "key-105", - "values--1333468536", - "key-37", - "values-351345678", - "key-4", - "values-683252782", - "key-62", - "values--1466612877", - "key-100", - "values-268100559", - "key-104", - "values-3517495", - "key-48", - "values--1588410835", - "key-42", - "values--180653405", - "key-118", - "values--1181647255", - "key-17", - "values-509279769", - "key-33", - "values-298668287", - "key-76", - "values-2062435628", - "key-18", - "values-287811864", - "key-46", - "values--1337930894", - "key-50", - "values-2089310564", - "key-24", - "values--1870293199", - "key-47", - "values--1155431370", - "key-81", - "values--1507929564", - "key-115", - "values-1149614815", - "key-57", - "values--334611395", - "key-86", - "values-146447703", - "key-107", - "values-938082683", - "key-38", - "values-338654203", - "key-40", - "values--376260149", - "key-20", - "values--860844060", - "key-20", - "values-2003129702", - "key-75", - "values--1787311067", - "key-39", - "values--1988768973", - "key-58", - "values--479797619", - "key-16", - "values-571033631", - "key-65", - "values--1867296166", - "key-56", - "values--2071960469", - "key-12", - "values-821930484", - "key-40", - "values--54692885", - "key-65", - "values-328817493", - "key-121", - "values-1276016318", - "key-33", - "values--2081652233", - "key-31", - "values-381335133", - "key-77", - "values-1486312656", - "key-48", - "values--1058365372", - "key-109", - "values--733344537", - "key-85", - "values-1236864082", - "key-35", - "values-2045087594", - "key-49", - "values-1990762822", - "key-38", - "values--1582706513", - "key-18", - "values--626997990", - "key-80", - "values--1995264473", - "key-126", - "values--558193472", - "key-83", - "values-415016167", - "key-53", - "values-1348674948", - "key-58", - "values-612738550", - "key-12", - "values-417676134", - "key-101", - "values--58098778", - "key-127", - "values-1658306930", - "key-17", - "values-985378289", - "key-68", - "values-686600535", - "key-36", - "values-365513638", - "key-87", - "values--1737233661", - "key-67", - "values--1840935230", - "key-8", - "values-540289596", - "key-11", - "values--2045114386", - "key-38", - "values--786598887", - "key-48", - "values-1877144385", - "key-5", - "values-65838542", - "key-18", - "values-263200779", - "key-120", - "values--1500947489", - "key-65", - "values-769990109", - "key-38", - "values-1886840000", - "key-29", - "values--48760205", - "key-61", - "values--1942966789"); - failingAction.applyToTestMap(map); - failingAction.verifyTestMap(map); - } - - @Test - void priorFailingCase2() { - TestCase testCase = - new TestCase( - remove("key-34"), - put("key-122", "values-1828753938"), - putAll( - "key-123", - "values--118789056", - "key-28", - "values--751841781", - "key-105", - "values-1663318183", - "key-63", - "values--2036414463", - "key-74", - "values-1584612783", - "key-118", - "values--414681411", + static final int NUM_KEYS = 128; + static final int MAX_NUM_ACTIONS = 32; + static final int MIN_NUM_ACTIONS = 8; + + @Test + void test() { + test(generateTest()); + } + + @Test + void testMerge() { + TestCase mapACase = generateTest(); + TestCase mapBCase = generateTest(); + + TagMap tagMapA = test(mapACase); + TagMap tagMapB = test(mapBCase); + + HashMap hashMapA = new HashMap<>(tagMapA); + HashMap hashMapB = new HashMap<>(tagMapB); + + tagMapA.putAll(tagMapB); + hashMapA.putAll(hashMapB); + + assertMapEquals(hashMapA, tagMapA); + } + + @Test + void priorFailingCase0() { + TagMap map = makeTagMap( + remove("key-4"), + put("key-71", "values-443049055"), + put("key-2", "values-1227065898"), + put("key-25", "values-696891692"), + put("key-93", "values-763707175"), + put("key-23", "values--1514091210"), + put("key-16", "values--1388742686")); + + MapAction failingAction = putAllTagMap( + "key-17", + "values--2085338893", + "key-51", + "values-960243765", + "key-33", + "values-1493544499", + "key-46", + "values-697926849", + "key-70", + "values--184054454", "key-67", - "values-1154668404", - "key-1", - "values--1755856616", - "key-89", - "values--344740102", + "values-374577326", + "key-9", + "values--742453833", + "key-11", + "values-1606950841", + "key-119", + "values--1914593057", + "key-53", + "values-375236438", + "key-96", + "values--107185569", + "key-47", + "values--1276407408", + "key-125", + "values--1627172151", "key-110", - "values-1884649283", - "key-1", - "values--1420345075", - "key-22", - "values-1951712698", - "key-103", - "values-488559164", + "values--1227150283", + "key-15", + "values-380379920", + "key-42", + "values--632271048", + "key-99", + "values--650090786", "key-8", - "values-1180668912", - "key-44", - "values-290310046", + "values--1990889145", + "key-103", + "values-1815698254", + "key-120", + "values-279025031", + "key-93", + "values-589795963", + "key-12", + "values--935895941", "key-105", - "values--303926067", + "values-94976227", + "key-85", + "values--424609970", + "key-78", + "values-1231948102", + "key-115", + "values-88670282", "key-26", - "values-910376351", + "values-733903384", + "key-100", + "values-2102967487", + "key-74", + "values-958598087", + "key-104", + "values-264458254", + "key-125", + "values--1781797927", + "key-27", + "values--562810078", + "key-7", + "values--376776745", + "key-111", + "values-263564677", + "key-50", + "values--859673100", + "key-57", + "values-1585057281", + "key-48", + "values--617889787", + "key-98", + "values--1878108220", + "key-9", + "values--227223375", "key-59", - "values-1600204544", - "key-23", - "values-425861746", - "key-76", - "values--1045446587", - "key-21", - "values-453905226", + "values-1577082288", + "key-94", + "values--268049040", + "key-0", + "values-1708355496", + "key-62", + "values--733451297", + "key-14", + "values-232732747", + "key-4", + "values--406605642", + "key-58", + "values-1772476833", + "key-8", + "values--1155025225", + "key-101", + "values-144480545", + "key-66", + "values-355117269", + "key-121", + "values-1858008722", + "key-33", + "values-1947754079", "key-1", - "values-286624672", - "key-69", - "values-934359656", + "values--1475603838", + "key-125", + "values--2146772243", + "key-117", + "values-852022714", + "key-53", + "values--2039348506", + "key-65", + "values-2011228657", + "key-108", + "values-1581592518", + "key-17", + "values-2129571020", + "key-5", + "values-1106900841", + "key-80", + "values-1791757923", + "key-18", + "values--1992962227", + "key-2", + "values-328863878", + "key-110", + "values-1182949334", + "key-5", + "values-1049403346", + "key-107", + "values-1246502060", + "key-115", + "values-2053931423", + "key-19", + "values--1731179633", + "key-104", + "values--1090790550", + "key-67", + "values--1312759979", + "key-10", + "values-1411135", + "key-109", + "values--1784920248", + "key-20", + "values--827644780", + "key-55", + "values--1610270998", + "key-60", + "values-1287959520", + "key-31", + "values-1686541667", + "key-41", + "values-399844058", + "key-115", + "values-2045201464", + "key-78", + "values-358081227", "key-57", - "values--1890465763", - "key-13", - "values--1949062639", + "values--1374149269", + "key-65", + "values-1871734555", + "key-124", + "values--211494558", + "key-119", + "values-1757597102", + "key-32", + "values--336988038", + "key-85", + "values-1415155858", + "key-44", + "values-1455425178", + "key-48", + "values--325658059", "key-68", - "values-242077328", - "key-42", - "values--1584075743", - "key-46", - "values--1306318288", + "values--793590840", + "key-96", + "values--2010766492", + "key-40", + "values-2007171160", + "key-29", + "values-186945230", + "key-63", + "values-1741962849", + "key-26", + "values-948582805", "key-31", - "values--848418043", - "key-71", - "values--1547961101", - "key-121", - "values--1493693636", - "key-24", - "values-330660358", - "key-24", - "values--1466871690", - "key-91", - "values--995064376", + "values-47004766", + "key-90", + "values-1304302008", + "key-69", + "values-2120328211", + "key-111", + "values-2053321468", + "key-69", + "values--498524858", + "key-125", + "values--193004619", + "key-30", + "values--1142090845", + "key-15", + "values--1334900170", + "key-33", + "values-1011001500", + "key-55", + "values-452401605", "key-18", - "values-1615316779", - "key-124", - "values--296191510", - "key-52", - "values-740309054", - "key-8", - "values-1777392898", - "key-73", - "values-92831985", - "key-13", - "values--1711360891", - "key-114", - "values-1960346620", + "values-1260118555", "key-44", - "values--1599497099", - "key-107", - "values-668485357", - "key-116", - "values--1792788504"), - put("key-123", "values--1844485682"), - putAll( - "key-64", - "values--1694520036", - "key-17", - "values--469732912", - "key-79", - "values--1293521097", - "key-11", - "values--2000592955", - "key-98", - "values-517073723", - "key-28", - "values-1085152681", - "key-34", - "values-1943586726", - "key-3", - "values-216087991", - "key-97", - "values-222660872", - "key-41", - "values-90906196", - "key-63", - "values--934208984", - "key-57", - "values-327167184", + "values--1109396459", + "key-2", + "values--555647718", + "key-61", + "values-1060742038", + "key-51", + "values--827099230", + "key-62", + "values--1443716296", + "key-16", + "values-534556355", + "key-81", + "values--787910427", + "key-20", + "values-1429697120", + "key-36", + "values--1775988293", + "key-66", + "values-624669635", + "key-25", + "values--684183265", + "key-26", + "values-293626449", + "key-91", + "values--1212867803", + "key-6", + "values-1778251481", + "key-83", + "values-1257370908", + "key-92", + "values--1120490028", "key-111", - "values--1059115125", + "values-9646496", + "key-90", + "values-1485206899"); + failingAction.applyToTestMap(map); + failingAction.verifyTestMap(map); + } + + @Test + void priorFailingCase1() { + TagMap map = makeTagMap(put("key-68", "values--37178328"), put("key-93", "values--2093086281")); + + MapAction failingAction = putAllTagMap( + "key-36", + "values--1951535044", + "key-59", + "values--1045985660", + "key-68", + "values-1270827526", + "key-65", + "values-440073158", + "key-91", + "values-954365843", "key-75", - "values--2031064209", - "key-8", - "values-1924310140", - "key-69", - "values--362514182", + "values-1014366449", + "key-117", + "values--1306617705", "key-90", - "values-852043703", - "key-98", - "values--998302860", - "key-49", - "values-1658920804", + "values-984567966", + "key-120", + "values--1802603599", + "key-56", + "values-319574488", + "key-78", + "values--711288173", + "key-103", + "values-694279462", + "key-84", + "values-1391260657", + "key-59", + "values--484807195", + "key-67", + "values-1675498322", + "key-91", + "values--227731796", + "key-105", + "values--1471022333", + "key-112", + "values--755617374", + "key-117", + "values--668324524", + "key-65", + "values-1165174761", + "key-13", + "values--1947081814", + "key-72", + "values-2032502631", "key-106", - "values--227162298", - "key-25", - "values-493046373", - "key-52", - "values--555623542", - "key-77", - "values--717275660", - "key-31", - "values-1930766287", - "key-69", - "values--1367213079", - "key-38", - "values--1112081116", + "values-256372025", + "key-71", + "values--995163162", + "key-92", + "values-972782926", + "key-116", + "values-25012447", + "key-23", + "values--979671053", + "key-94", + "values-367125724", + "key-48", + "values--2011523144", + "key-14", + "values-578926680", "key-65", - "values--1916889923", - "key-96", - "values-157036191", - "key-127", - "values--302553995", - "key-38", - "values-485874872", - "key-110", - "values--855874569", - "key-39", - "values--390829775", - "key-7", - "values--452123269", - "key-63", - "values--527204905", - "key-101", - "values-166173307", - "key-126", - "values-1050454498", + "values-1325737627", + "key-89", + "values-1539092266", + "key-100", + "values--319629978", + "key-53", + "values-1125496255", + "key-2", + "values-1988036327", + "key-105", + "values--1333468536", + "key-37", + "values-351345678", "key-4", - "values--215188400", - "key-25", - "values-947961204", + "values-683252782", + "key-62", + "values--1466612877", + "key-100", + "values-268100559", + "key-104", + "values-3517495", + "key-48", + "values--1588410835", "key-42", - "values-145803888", - "key-1", - "values--970532578", - "key-43", - "values--1675493776", - "key-29", - "values-1193328809", - "key-108", - "values-1302659140", - "key-120", - "values--1722764270", + "values--180653405", + "key-118", + "values--1181647255", + "key-17", + "values-509279769", + "key-33", + "values-298668287", + "key-76", + "values-2062435628", + "key-18", + "values-287811864", + "key-46", + "values--1337930894", + "key-50", + "values-2089310564", "key-24", - "values--483238806", - "key-53", - "values-611589672", + "values--1870293199", + "key-47", + "values--1155431370", + "key-81", + "values--1507929564", + "key-115", + "values-1149614815", + "key-57", + "values--334611395", + "key-86", + "values-146447703", + "key-107", + "values-938082683", + "key-38", + "values-338654203", + "key-40", + "values--376260149", + "key-20", + "values--860844060", + "key-20", + "values-2003129702", + "key-75", + "values--1787311067", "key-39", - "values--229429656", - "key-29", - "values--733337788", - "key-9", - "values-736222322", - "key-74", - "values--950770749", - "key-91", - "values-202817768", - "key-95", - "values-500260096", - "key-71", - "values--1798188865", + "values--1988768973", + "key-58", + "values--479797619", + "key-16", + "values-571033631", + "key-65", + "values--1867296166", + "key-56", + "values--2071960469", "key-12", - "values--1936098297", - "key-28", - "values--2116134632", - "key-21", - "values-799594067", - "key-68", - "values--333178107", - "key-50", - "values-445767791", - "key-88", - "values-1307699662", - "key-69", - "values--110615017", - "key-25", - "values-699603233", - "key-101", - "values--2093413536", - "key-91", - "values--2022040839", - "key-45", - "values-888546703", + "values-821930484", "key-40", - "values--2140684954", - "key-1", - "values-371033654", - "key-68", - "values--20293415", - "key-59", - "values-697437101", - "key-43", - "values--1145022834", - "key-62", - "values--2125187195", - "key-15", - "values--1062944166", - "key-103", - "values--889634836", - "key-125", - "values-8694763", - "key-101", - "values--281475498", - "key-13", - "values-1972488719", - "key-32", - "values-1900833863", - "key-119", - "values--926978044", - "key-82", - "values-288820151", - "key-78", - "values--303310027", - "key-25", - "values--1284661437", - "key-47", - "values-1624726045", - "key-14", - "values-1658036950", + "values--54692885", "key-65", - "values-1629683219", - "key-10", - "values-275264679", - "key-126", - "values--592085694", - "key-32", - "values-1844385705", - "key-85", - "values--1815321660", - "key-72", - "values-918231225", - "key-91", - "values-675699466", + "values-328817493", "key-121", - "values--2008685332", - "key-61", - "values--1398921570", - "key-19", - "values-617817427", - "key-122", - "values--793708860", - "key-41", - "values--2027225350", - "key-41", - "values-1194206680", - "key-1", - "values-1116090448", + "values-1276016318", + "key-33", + "values--2081652233", + "key-31", + "values-381335133", + "key-77", + "values-1486312656", + "key-48", + "values--1058365372", + "key-109", + "values--733344537", + "key-85", + "values-1236864082", + "key-35", + "values-2045087594", "key-49", - "values-1662444555", - "key-54", - "values-747436284", - "key-118", - "values--1367237858", - "key-65", - "values-133495093", - "key-73", - "values--1451855551", - "key-43", - "values--357794833", - "key-76", - "values-129403123", - "key-59", - "values--65688873", - "key-22", - "values-480031738", - "key-73", - "values--310815862", - "key-0", - "values--1734944386", - "key-56", - "values--540459893", + "values-1990762822", "key-38", - "values-1308912555", - "key-2", - "values--2073028093", - "key-14", - "values--693713438", - "key-76", - "values-295450436", - "key-113", - "values--2065146687", - "key-0", - "values-2076623027", + "values--1582706513", + "key-18", + "values--626997990", + "key-80", + "values--1995264473", + "key-126", + "values--558193472", + "key-83", + "values-415016167", + "key-53", + "values-1348674948", + "key-58", + "values-612738550", + "key-12", + "values-417676134", + "key-101", + "values--58098778", + "key-127", + "values-1658306930", "key-17", - "values--1394046356", - "key-78", - "values--2014478659", + "values-985378289", + "key-68", + "values-686600535", + "key-36", + "values-365513638", + "key-87", + "values--1737233661", + "key-67", + "values--1840935230", + "key-8", + "values-540289596", + "key-11", + "values--2045114386", + "key-38", + "values--786598887", + "key-48", + "values-1877144385", "key-5", - "values--665180960"), - put("key-124", "values-460160716"), - put("key-112", "values--1828904046"), - put("key-41", "values--904162962")); - - Map expected = makeMap(testCase); - TagMap actual = makeTagMap(testCase); - - MapAction failingAction = remove("key-127"); - failingAction.applyToExpectedMap(expected); - - failingAction.applyToTestMap(actual); - failingAction.verifyTestMap(actual); - - assertMapEquals(expected, actual); - } - - public static final TagMap test(MapAction... actions) { - return test(new TestCase(Arrays.asList(actions))); - } - - public static final Map makeMap(TestCase testCase) { - return makeMap(testCase.actions); - } - - public static final Map makeMap(MapAction... actions) { - return makeMap(Arrays.asList(actions)); - } - - public static final Map makeMap(List actions) { - Map map = new HashMap<>(); - for (MapAction action : actions) { - action.applyToExpectedMap(map); + "values-65838542", + "key-18", + "values-263200779", + "key-120", + "values--1500947489", + "key-65", + "values-769990109", + "key-38", + "values-1886840000", + "key-29", + "values--48760205", + "key-61", + "values--1942966789"); + failingAction.applyToTestMap(map); + failingAction.verifyTestMap(map); + } + + @Test + void priorFailingCase2() { + TestCase testCase = new TestCase( + remove("key-34"), + put("key-122", "values-1828753938"), + putAll( + "key-123", + "values--118789056", + "key-28", + "values--751841781", + "key-105", + "values-1663318183", + "key-63", + "values--2036414463", + "key-74", + "values-1584612783", + "key-118", + "values--414681411", + "key-67", + "values-1154668404", + "key-1", + "values--1755856616", + "key-89", + "values--344740102", + "key-110", + "values-1884649283", + "key-1", + "values--1420345075", + "key-22", + "values-1951712698", + "key-103", + "values-488559164", + "key-8", + "values-1180668912", + "key-44", + "values-290310046", + "key-105", + "values--303926067", + "key-26", + "values-910376351", + "key-59", + "values-1600204544", + "key-23", + "values-425861746", + "key-76", + "values--1045446587", + "key-21", + "values-453905226", + "key-1", + "values-286624672", + "key-69", + "values-934359656", + "key-57", + "values--1890465763", + "key-13", + "values--1949062639", + "key-68", + "values-242077328", + "key-42", + "values--1584075743", + "key-46", + "values--1306318288", + "key-31", + "values--848418043", + "key-71", + "values--1547961101", + "key-121", + "values--1493693636", + "key-24", + "values-330660358", + "key-24", + "values--1466871690", + "key-91", + "values--995064376", + "key-18", + "values-1615316779", + "key-124", + "values--296191510", + "key-52", + "values-740309054", + "key-8", + "values-1777392898", + "key-73", + "values-92831985", + "key-13", + "values--1711360891", + "key-114", + "values-1960346620", + "key-44", + "values--1599497099", + "key-107", + "values-668485357", + "key-116", + "values--1792788504"), + put("key-123", "values--1844485682"), + putAll( + "key-64", + "values--1694520036", + "key-17", + "values--469732912", + "key-79", + "values--1293521097", + "key-11", + "values--2000592955", + "key-98", + "values-517073723", + "key-28", + "values-1085152681", + "key-34", + "values-1943586726", + "key-3", + "values-216087991", + "key-97", + "values-222660872", + "key-41", + "values-90906196", + "key-63", + "values--934208984", + "key-57", + "values-327167184", + "key-111", + "values--1059115125", + "key-75", + "values--2031064209", + "key-8", + "values-1924310140", + "key-69", + "values--362514182", + "key-90", + "values-852043703", + "key-98", + "values--998302860", + "key-49", + "values-1658920804", + "key-106", + "values--227162298", + "key-25", + "values-493046373", + "key-52", + "values--555623542", + "key-77", + "values--717275660", + "key-31", + "values-1930766287", + "key-69", + "values--1367213079", + "key-38", + "values--1112081116", + "key-65", + "values--1916889923", + "key-96", + "values-157036191", + "key-127", + "values--302553995", + "key-38", + "values-485874872", + "key-110", + "values--855874569", + "key-39", + "values--390829775", + "key-7", + "values--452123269", + "key-63", + "values--527204905", + "key-101", + "values-166173307", + "key-126", + "values-1050454498", + "key-4", + "values--215188400", + "key-25", + "values-947961204", + "key-42", + "values-145803888", + "key-1", + "values--970532578", + "key-43", + "values--1675493776", + "key-29", + "values-1193328809", + "key-108", + "values-1302659140", + "key-120", + "values--1722764270", + "key-24", + "values--483238806", + "key-53", + "values-611589672", + "key-39", + "values--229429656", + "key-29", + "values--733337788", + "key-9", + "values-736222322", + "key-74", + "values--950770749", + "key-91", + "values-202817768", + "key-95", + "values-500260096", + "key-71", + "values--1798188865", + "key-12", + "values--1936098297", + "key-28", + "values--2116134632", + "key-21", + "values-799594067", + "key-68", + "values--333178107", + "key-50", + "values-445767791", + "key-88", + "values-1307699662", + "key-69", + "values--110615017", + "key-25", + "values-699603233", + "key-101", + "values--2093413536", + "key-91", + "values--2022040839", + "key-45", + "values-888546703", + "key-40", + "values--2140684954", + "key-1", + "values-371033654", + "key-68", + "values--20293415", + "key-59", + "values-697437101", + "key-43", + "values--1145022834", + "key-62", + "values--2125187195", + "key-15", + "values--1062944166", + "key-103", + "values--889634836", + "key-125", + "values-8694763", + "key-101", + "values--281475498", + "key-13", + "values-1972488719", + "key-32", + "values-1900833863", + "key-119", + "values--926978044", + "key-82", + "values-288820151", + "key-78", + "values--303310027", + "key-25", + "values--1284661437", + "key-47", + "values-1624726045", + "key-14", + "values-1658036950", + "key-65", + "values-1629683219", + "key-10", + "values-275264679", + "key-126", + "values--592085694", + "key-32", + "values-1844385705", + "key-85", + "values--1815321660", + "key-72", + "values-918231225", + "key-91", + "values-675699466", + "key-121", + "values--2008685332", + "key-61", + "values--1398921570", + "key-19", + "values-617817427", + "key-122", + "values--793708860", + "key-41", + "values--2027225350", + "key-41", + "values-1194206680", + "key-1", + "values-1116090448", + "key-49", + "values-1662444555", + "key-54", + "values-747436284", + "key-118", + "values--1367237858", + "key-65", + "values-133495093", + "key-73", + "values--1451855551", + "key-43", + "values--357794833", + "key-76", + "values-129403123", + "key-59", + "values--65688873", + "key-22", + "values-480031738", + "key-73", + "values--310815862", + "key-0", + "values--1734944386", + "key-56", + "values--540459893", + "key-38", + "values-1308912555", + "key-2", + "values--2073028093", + "key-14", + "values--693713438", + "key-76", + "values-295450436", + "key-113", + "values--2065146687", + "key-0", + "values-2076623027", + "key-17", + "values--1394046356", + "key-78", + "values--2014478659", + "key-5", + "values--665180960"), + put("key-124", "values-460160716"), + put("key-112", "values--1828904046"), + put("key-41", "values--904162962")); + + Map expected = makeMap(testCase); + TagMap actual = makeTagMap(testCase); + + MapAction failingAction = remove("key-127"); + failingAction.applyToExpectedMap(expected); + + failingAction.applyToTestMap(actual); + failingAction.verifyTestMap(actual); + + assertMapEquals(expected, actual); + } + + public static final TagMap test(MapAction... actions) { + return test(new TestCase(Arrays.asList(actions))); + } + + public static final Map makeMap(TestCase testCase) { + return makeMap(testCase.actions); + } + + public static final Map makeMap(MapAction... actions) { + return makeMap(Arrays.asList(actions)); + } + + public static final Map makeMap(List actions) { + Map map = new HashMap<>(); + for (MapAction action : actions) { + action.applyToExpectedMap(map); + } + return map; + } + + public static final TagMap makeTagMap(TestCase testCase) { + return makeTagMap(testCase.actions); + } + + public static final TagMap makeTagMap(MapAction... actions) { + return makeTagMap(Arrays.asList(actions)); } - return map; - } - - public static final TagMap makeTagMap(TestCase testCase) { - return makeTagMap(testCase.actions); - } - public static final TagMap makeTagMap(MapAction... actions) { - return makeTagMap(Arrays.asList(actions)); - } - - public static final TagMap makeTagMap(List actions) { - TagMap map = new TagMap(); - for (MapAction action : actions) { - action.applyToTestMap(map); + public static final TagMap makeTagMap(List actions) { + TagMap map = new TagMap(); + for (MapAction action : actions) { + action.applyToTestMap(map); + } + return map; } - return map; - } - public static final TagMap test(TestCase test) { - List actions = test.actions(); + public static final TagMap test(TestCase test) { + List actions = test.actions(); - Map hashMap = new HashMap<>(); - TagMap tagMap = new TagMap(); + Map hashMap = new HashMap<>(); + TagMap tagMap = new TagMap(); - int actionIndex = 0; - try { - for (actionIndex = 0; actionIndex < actions.size(); ++actionIndex) { - MapAction action = actions.get(actionIndex); + int actionIndex = 0; + try { + for (actionIndex = 0; actionIndex < actions.size(); ++actionIndex) { + MapAction action = actions.get(actionIndex); - Object expected = action.applyToExpectedMap(hashMap); - Object actual = action.applyToTestMap(tagMap); + Object expected = action.applyToExpectedMap(hashMap); + Object actual = action.applyToTestMap(tagMap); - action.verifyResults(expected, actual); + action.verifyResults(expected, actual); - action.verifyTestMap(tagMap); + action.verifyTestMap(tagMap); - assertMapEquals(hashMap, tagMap); - } - } catch (Error e) { - System.err.println(new TestCase(actions.subList(0, actionIndex + 1))); + assertMapEquals(hashMap, tagMap); + } + } catch (Error e) { + System.err.println(new TestCase(actions.subList(0, actionIndex + 1))); - throw e; - } - return tagMap; - } - - public static final TestCase generateTest() { - int numActions = - ThreadLocalRandom.current().nextInt(MAX_NUM_ACTIONS - MIN_NUM_ACTIONS) + MIN_NUM_ACTIONS; - return generateTest(numActions); - } - - public static final TestCase generateTest(int size) { - List actions = new ArrayList<>(size); - for (int i = 0; i < size; ++i) { - actions.add(randomAction()); - } - return new TestCase(actions); - } - - public static final MapAction randomAction() { - float actionSelector = ThreadLocalRandom.current().nextFloat(); - - switch (randomChoice(0.02, 0.1, 0.2)) { - case 0: - return clear(); - - case 1: - return randomChoice( - () -> putAll(randomKeysAndValues()), - () -> putAllTagMap(randomKeysAndValues()), - () -> putAllLedger(randomKeysAndValues())); - - case 2: - return randomChoice( - () -> remove(randomKey()), - () -> removeLight(randomKey()), - () -> getAndRemove(randomKey())); - - default: - return randomChoice( - () -> put(randomKey(), randomValue()), - () -> set(randomKey(), randomValue()), - () -> getAndSet(randomKey(), randomValue())); + throw e; + } + return tagMap; } - } - - public static final MapAction put(String key, String value) { - return new Put(key, value); - } - - public static final MapAction set(String key, String value) { - return new Set(key, value); - } - - public static final MapAction getAndSet(String key, String value) { - return new GetAndSet(key, value); - } - - public static final MapAction putAll(String... keysAndValues) { - return new PutAll(keysAndValues); - } - - public static final MapAction putAllTagMap(String... keysAndValues) { - return new PutAllTagMap(keysAndValues); - } - - public static final MapAction putAllLedger(String... keysAndValues) { - return new PutAllLedger(keysAndValues); - } - - public static final MapAction clear() { - return Clear.INSTANCE; - } - - public static final MapAction remove(String key) { - return new Remove(key); - } - public static final MapAction removeLight(String key) { - return new RemoveLight(key); - } - - public static final MapAction getAndRemove(String key) { - return new GetAndRemove(key); - } - - static final void assertMapEquals(Map expected, TagMap actual) { - // checks entries in both directions to make sure there's full intersection - - for (Map.Entry expectedEntry : expected.entrySet()) { - TagMap.Entry actualEntry = actual.getEntry(expectedEntry.getKey()); - assertNotNull(actualEntry); - assertEquals(expectedEntry.getValue(), actualEntry.getValue()); + public static final TestCase generateTest() { + int numActions = ThreadLocalRandom.current().nextInt(MAX_NUM_ACTIONS - MIN_NUM_ACTIONS) + MIN_NUM_ACTIONS; + return generateTest(numActions); } - for (TagMap.EntryReader actualEntry : actual) { - Object expectedValue = expected.get(actualEntry.tag()); - assertEquals(expectedValue, actualEntry.objectValue()); + public static final TestCase generateTest(int size) { + List actions = new ArrayList<>(size); + for (int i = 0; i < size; ++i) { + actions.add(randomAction()); + } + return new TestCase(actions); } - actual.checkIntegrity(); - } - - static final float randomFloat() { - return ThreadLocalRandom.current().nextFloat(); - } - - static final int randomChoice(int numChoices) { - return ThreadLocalRandom.current().nextInt(numChoices); - } - - static final T randomChoice(Supplier... choiceSuppliers) { - int choice = randomChoice(choiceSuppliers.length); + public static final MapAction randomAction() { + float actionSelector = ThreadLocalRandom.current().nextFloat(); - return choiceSuppliers[choice].get(); - } + switch (randomChoice(0.02, 0.1, 0.2)) { + case 0: + return clear(); - static final int randomChoice(double... proportions) { - double selector = ThreadLocalRandom.current().nextDouble(); + case 1: + return randomChoice( + () -> putAll(randomKeysAndValues()), + () -> putAllTagMap(randomKeysAndValues()), + () -> putAllLedger(randomKeysAndValues())); - for (int i = 0; i < proportions.length; ++i) { - if (selector < proportions[i]) return i; + case 2: + return randomChoice( + () -> remove(randomKey()), () -> removeLight(randomKey()), () -> getAndRemove(randomKey())); - selector -= proportions[i]; + default: + return randomChoice( + () -> put(randomKey(), randomValue()), + () -> set(randomKey(), randomValue()), + () -> getAndSet(randomKey(), randomValue())); + } } - return proportions.length; - } - static final String randomKey() { - return "key-" + ThreadLocalRandom.current().nextInt(NUM_KEYS); - } - - static final String randomValue() { - return "values-" + ThreadLocalRandom.current().nextInt(); - } - - static final String[] randomKeysAndValues() { - int numEntries = ThreadLocalRandom.current().nextInt(NUM_KEYS); - - String[] keysAndValues = new String[numEntries << 1]; - for (int i = 0; i < keysAndValues.length; i += 2) { - keysAndValues[i] = randomKey(); - keysAndValues[i + 1] = randomValue(); + public static final MapAction put(String key, String value) { + return new Put(key, value); } - return keysAndValues; - } - - static final String literal(String str) { - return "\"" + str + "\""; - } - - static final String literalVarArgs(String... strs) { - StringBuilder builder = new StringBuilder(); - for (String str : strs) { - if (builder.length() != 0) builder.append(','); - builder.append(literal(str)); - } - return builder.toString(); - } - - static final Map mapOf(String... keysAndValues) { - HashMap map = new HashMap<>(keysAndValues.length >> 1); - for (int i = 0; i < keysAndValues.length; i += 2) { - String key = keysAndValues[i]; - String value = keysAndValues[i + 1]; - map.put(key, value); + public static final MapAction set(String key, String value) { + return new Set(key, value); } - return map; - } - static final TagMap tagMapOf(String... keysAndValues) { - TagMap map = new TagMap(); - for (int i = 0; i < keysAndValues.length; i += 2) { - String key = keysAndValues[i]; - String value = keysAndValues[i + 1]; - - map.set(key, value); + public static final MapAction getAndSet(String key, String value) { + return new GetAndSet(key, value); } - map.checkIntegrity(); - - return map; - } - - static final TagMap.Ledger ledgerOf(String... keysAndValues) { - TagMap.Ledger ledger = TagMap.ledger(); - for (int i = 0; i < keysAndValues.length; i += 2) { - String key = keysAndValues[i]; - String value = keysAndValues[i + 1]; - ledger.set(key, value); + public static final MapAction putAll(String... keysAndValues) { + return new PutAll(keysAndValues); } - return ledger; - } - static final class TestCase { - final List actions; - - TestCase(MapAction... actions) { - this.actions = Arrays.asList(actions); + public static final MapAction putAllTagMap(String... keysAndValues) { + return new PutAllTagMap(keysAndValues); } - TestCase(List actions) { - this.actions = actions; + public static final MapAction putAllLedger(String... keysAndValues) { + return new PutAllLedger(keysAndValues); } - public final List actions() { - return this.actions; + public static final MapAction clear() { + return Clear.INSTANCE; } - @Override - public String toString() { - StringBuilder builder = new StringBuilder(); - for (MapAction action : this.actions) { - builder.append(action).append(',').append('\n'); - } - return builder.toString(); + public static final MapAction remove(String key) { + return new Remove(key); } - } - - abstract static class MapAction { - public abstract Object applyToTestMap(TagMap testMap); - - public abstract Object applyToExpectedMap(Map expectedMap); - public abstract void verifyResults(Object expected, Object actual); - - public abstract void verifyTestMap(TagMap testMap); - - public abstract String toString(); - } - - abstract static class BasicAction extends MapAction { - @Override - public final Object applyToTestMap(TagMap testMap) { - _applyToTestMap(testMap); - - return void.class; + public static final MapAction removeLight(String key) { + return new RemoveLight(key); } - protected abstract void _applyToTestMap(TagMap testMap); - - @Override - public final Object applyToExpectedMap(Map expectedMap) { - _applyToExpectedMap(expectedMap); - - return void.class; - } - - protected abstract void _applyToExpectedMap(Map expectedMap); - - public final void verifyResults(Object expected, Object actual) {} - } - - abstract static class BasicReturningAction extends MapAction { - @Override - public final TResult applyToTestMap(TagMap testMap) { - return _applyToTestMap(testMap); + public static final MapAction getAndRemove(String key) { + return new GetAndRemove(key); } - protected abstract TResult _applyToTestMap(TagMap testMap); + static final void assertMapEquals(Map expected, TagMap actual) { + // checks entries in both directions to make sure there's full intersection - @Override - public final TResult applyToExpectedMap(Map expectedMap) { - return _applyToExpectedMap(expectedMap); - } + for (Map.Entry expectedEntry : expected.entrySet()) { + TagMap.Entry actualEntry = actual.getEntry(expectedEntry.getKey()); + assertNotNull(actualEntry); + assertEquals(expectedEntry.getValue(), actualEntry.getValue()); + } - protected abstract TResult _applyToExpectedMap(Map expectedMap); + for (TagMap.EntryReader actualEntry : actual) { + Object expectedValue = expected.get(actualEntry.tag()); + assertEquals(expectedValue, actualEntry.objectValue()); + } - @SuppressWarnings("unchecked") - public final void verifyResults(Object expected, Object actual) { - assertEquals(expected, actual); + actual.checkIntegrity(); } - } - abstract static class ReturningAction extends MapAction { - @Override - public final TActualResult applyToTestMap(TagMap testMap) { - return _applyToTestMap(testMap); + static final float randomFloat() { + return ThreadLocalRandom.current().nextFloat(); } - protected abstract TActualResult _applyToTestMap(TagMap testMap); - - @Override - public final TExpectedResult applyToExpectedMap(Map expectedMap) { - return _applyToExpectedMap(expectedMap); + static final int randomChoice(int numChoices) { + return ThreadLocalRandom.current().nextInt(numChoices); } - protected abstract TExpectedResult _applyToExpectedMap(Map expectedMap); + static final T randomChoice(Supplier... choiceSuppliers) { + int choice = randomChoice(choiceSuppliers.length); - @SuppressWarnings("unchecked") - public final void verifyResults(Object expected, Object actual) { - _verifyResults((TExpectedResult) expected, (TActualResult) actual); + return choiceSuppliers[choice].get(); } - protected abstract void _verifyResults(TExpectedResult expected, TActualResult actual); - } + static final int randomChoice(double... proportions) { + double selector = ThreadLocalRandom.current().nextDouble(); - static final class Put extends BasicReturningAction { - final String key; - final String value; + for (int i = 0; i < proportions.length; ++i) { + if (selector < proportions[i]) return i; - Put(String key, String value) { - this.key = key; - this.value = value; + selector -= proportions[i]; + } + return proportions.length; } - @Override - protected Object _applyToTestMap(TagMap testMap) { - return testMap.put(this.key, this.value); + static final String randomKey() { + return "key-" + ThreadLocalRandom.current().nextInt(NUM_KEYS); } - @Override - protected Object _applyToExpectedMap(Map expectedMap) { - return expectedMap.put(this.key, this.value); + static final String randomValue() { + return "values-" + ThreadLocalRandom.current().nextInt(); } - @Override - public void verifyTestMap(TagMap testMap) { - assertEquals(this.value, testMap.get(this.key)); - } + static final String[] randomKeysAndValues() { + int numEntries = ThreadLocalRandom.current().nextInt(NUM_KEYS); - @Override - public String toString() { - return String.format("put(%s,%s)", literal(this.key), literal(this.value)); + String[] keysAndValues = new String[numEntries << 1]; + for (int i = 0; i < keysAndValues.length; i += 2) { + keysAndValues[i] = randomKey(); + keysAndValues[i + 1] = randomValue(); + } + return keysAndValues; } - } - static final class Set extends BasicAction { - final String key; - final String value; - - Set(String key, String value) { - this.key = key; - this.value = value; + static final String literal(String str) { + return "\"" + str + "\""; } - @Override - protected void _applyToTestMap(TagMap testMap) { - testMap.set(this.key, this.value); + static final String literalVarArgs(String... strs) { + StringBuilder builder = new StringBuilder(); + for (String str : strs) { + if (builder.length() != 0) builder.append(','); + builder.append(literal(str)); + } + return builder.toString(); } - @Override - protected void _applyToExpectedMap(Map expectedMap) { - expectedMap.put(this.key, this.value); - } + static final Map mapOf(String... keysAndValues) { + HashMap map = new HashMap<>(keysAndValues.length >> 1); + for (int i = 0; i < keysAndValues.length; i += 2) { + String key = keysAndValues[i]; + String value = keysAndValues[i + 1]; - @Override - public void verifyTestMap(TagMap testMap) { - assertEquals(this.value, testMap.get(this.key)); + map.put(key, value); + } + return map; } - @Override - public String toString() { - return String.format("set(%s,%s)", literal(this.key), literal(this.value)); - } - } + static final TagMap tagMapOf(String... keysAndValues) { + TagMap map = new TagMap(); + for (int i = 0; i < keysAndValues.length; i += 2) { + String key = keysAndValues[i]; + String value = keysAndValues[i + 1]; - static final class GetAndSet extends ReturningAction { - final String key; - final String value; + map.set(key, value); + } + map.checkIntegrity(); - GetAndSet(String key, String value) { - this.key = key; - this.value = value; + return map; } - @Override - protected TagMap.Entry _applyToTestMap(TagMap testMap) { - return testMap.getAndSet(this.key, this.value); - } + static final TagMap.Ledger ledgerOf(String... keysAndValues) { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < keysAndValues.length; i += 2) { + String key = keysAndValues[i]; + String value = keysAndValues[i + 1]; - @Override - protected Object _applyToExpectedMap(Map expectedMap) { - return expectedMap.put(this.key, this.value); + ledger.set(key, value); + } + return ledger; } - @Override - protected void _verifyResults(Object expected, TagMap.Entry actual) { - if (expected == null) { - assertNull(actual); - } else { - assertEquals(expected, actual.objectValue()); - } - } + static final class TestCase { + final List actions; - @Override - public void verifyTestMap(TagMap testMap) { - assertEquals(this.value, testMap.get(this.key)); - } + TestCase(MapAction... actions) { + this.actions = Arrays.asList(actions); + } - @Override - public String toString() { - return String.format("getAndSet(%s,%s)", literal(this.key), literal(this.value)); - } - } + TestCase(List actions) { + this.actions = actions; + } - static final class PutAll extends BasicAction { - final String[] keysAndValues; - final Map map; + public final List actions() { + return this.actions; + } - PutAll(String... keysAndValues) { - this.keysAndValues = keysAndValues; - this.map = mapOf(keysAndValues); + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + for (MapAction action : this.actions) { + builder.append(action).append(',').append('\n'); + } + return builder.toString(); + } } - @Override - protected void _applyToTestMap(TagMap testMap) { - testMap.putAll(this.map); - } + abstract static class MapAction { + public abstract Object applyToTestMap(TagMap testMap); - @Override - public void _applyToExpectedMap(Map expectedMap) { - expectedMap.putAll(this.map); - } + public abstract Object applyToExpectedMap(Map expectedMap); - @Override - public void verifyTestMap(TagMap expectedMap) { - for (Map.Entry entry : this.map.entrySet()) { - assertEquals(entry.getValue(), expectedMap.get(entry.getKey())); - } - } + public abstract void verifyResults(Object expected, Object actual); - @Override - public String toString() { - return String.format("putAll(%s)", literalVarArgs(this.keysAndValues)); - } - } + public abstract void verifyTestMap(TagMap testMap); - static final class PutAllTagMap extends BasicAction { - final String[] keysAndValues; - final TagMap tagMap; - - PutAllTagMap(String... keysAndValues) { - this.keysAndValues = keysAndValues; - this.tagMap = tagMapOf(keysAndValues); + public abstract String toString(); } - @Override - protected void _applyToTestMap(TagMap testMap) { - testMap.putAll(this.tagMap); - } + abstract static class BasicAction extends MapAction { + @Override + public final Object applyToTestMap(TagMap testMap) { + _applyToTestMap(testMap); - @Override - protected void _applyToExpectedMap(Map expectedMap) { - expectedMap.putAll(this.tagMap); - } - - @Override - public void verifyTestMap(TagMap expectedMap) { - for (TagMap.EntryReader entry : this.tagMap) { - assertEquals(entry.objectValue(), expectedMap.get(entry.tag()), "key=" + entry.tag()); - } - } - - @Override - public String toString() { - return String.format("putAllTagMap(%s)", literalVarArgs(this.keysAndValues)); - } - } + return void.class; + } - static final class PutAllLedger extends BasicAction { - final String[] keysAndValues; - final TagMap.Ledger ledger; + protected abstract void _applyToTestMap(TagMap testMap); - PutAllLedger(String... keysAndValues) { - this.keysAndValues = keysAndValues; - this.ledger = ledgerOf(keysAndValues); - } - - @Override - protected void _applyToTestMap(TagMap testMap) { - this.ledger.fill(testMap); - } - - @Override - protected void _applyToExpectedMap(Map expectedMap) { - for (TagMap.EntryChange change : this.ledger) { - // ledgerOf - doesn't produce / removes, so this is safe - TagMap.Entry entry = (TagMap.Entry) change; - expectedMap.put(entry.tag(), entry.objectValue()); - } - } + @Override + public final Object applyToExpectedMap(Map expectedMap) { + _applyToExpectedMap(expectedMap); - @Override - public void verifyTestMap(TagMap expectedMap) { - // ledger may contain multiple updates of the same key - // easier to produce a TagMap and check against it + return void.class; + } - for (TagMap.EntryReader entry : this.ledger.buildImmutable()) { - assertEquals(entry.objectValue(), expectedMap.get(entry.tag()), "key=" + entry.tag()); - } - } + protected abstract void _applyToExpectedMap(Map expectedMap); - @Override - public String toString() { - return String.format("putAllLedger(%s)", literalVarArgs(this.keysAndValues)); + public final void verifyResults(Object expected, Object actual) {} } - } - static final class Remove extends BasicReturningAction { - final String key; + abstract static class BasicReturningAction extends MapAction { + @Override + public final TResult applyToTestMap(TagMap testMap) { + return _applyToTestMap(testMap); + } - Remove(String key) { - this.key = key; - } + protected abstract TResult _applyToTestMap(TagMap testMap); - @Override - protected Object _applyToTestMap(TagMap testMap) { - return testMap.remove((Object) this.key); - } + @Override + public final TResult applyToExpectedMap(Map expectedMap) { + return _applyToExpectedMap(expectedMap); + } - @Override - protected Object _applyToExpectedMap(Map expectedMap) { - return expectedMap.remove(this.key); - } + protected abstract TResult _applyToExpectedMap(Map expectedMap); - @Override - public void verifyTestMap(TagMap testMap) { - assertFalse(testMap.containsKey(this.key)); + @SuppressWarnings("unchecked") + public final void verifyResults(Object expected, Object actual) { + assertEquals(expected, actual); + } } - @Override - public String toString() { - return String.format("remove(%s)", literal(this.key)); - } - } + abstract static class ReturningAction extends MapAction { + @Override + public final TActualResult applyToTestMap(TagMap testMap) { + return _applyToTestMap(testMap); + } - static final class RemoveLight extends ReturningAction { - final String key; + protected abstract TActualResult _applyToTestMap(TagMap testMap); - RemoveLight(String key) { - this.key = key; - } + @Override + public final TExpectedResult applyToExpectedMap(Map expectedMap) { + return _applyToExpectedMap(expectedMap); + } - @Override - protected Boolean _applyToTestMap(TagMap testMap) { - return testMap.remove(this.key); - } + protected abstract TExpectedResult _applyToExpectedMap(Map expectedMap); - @Override - protected Object _applyToExpectedMap(Map expectedMap) { - return expectedMap.remove(this.key); - } + @SuppressWarnings("unchecked") + public final void verifyResults(Object expected, Object actual) { + _verifyResults((TExpectedResult) expected, (TActualResult) actual); + } - @Override - protected void _verifyResults(Object expected, Boolean actual) { - assertEquals((expected != null), actual); + protected abstract void _verifyResults(TExpectedResult expected, TActualResult actual); } - @Override - public void verifyTestMap(TagMap testMap) { - assertFalse(testMap.containsKey(this.key)); - } + static final class Put extends BasicReturningAction { + final String key; + final String value; - @Override - public String toString() { - return String.format("removeLight(%s)", literal(this.key)); - } - } + Put(String key, String value) { + this.key = key; + this.value = value; + } - static final class GetAndRemove extends ReturningAction { - final String key; + @Override + protected Object _applyToTestMap(TagMap testMap) { + return testMap.put(this.key, this.value); + } - GetAndRemove(String key) { - this.key = key; - } + @Override + protected Object _applyToExpectedMap(Map expectedMap) { + return expectedMap.put(this.key, this.value); + } - @Override - protected TagMap.Entry _applyToTestMap(TagMap testMap) { - return testMap.getAndRemove(this.key); - } - - @Override - protected Object _applyToExpectedMap(Map expectedMap) { - return expectedMap.remove(this.key); - } - - @Override - protected void _verifyResults(Object expected, TagMap.Entry actual) { - if (expected == null) { - assertNull(actual); - } else { - assertEquals(expected, actual.objectValue()); - } - } + @Override + public void verifyTestMap(TagMap testMap) { + assertEquals(this.value, testMap.get(this.key)); + } - @Override - public void verifyTestMap(TagMap testMap) { - assertFalse(testMap.containsKey(this.key)); + @Override + public String toString() { + return String.format("put(%s,%s)", literal(this.key), literal(this.value)); + } } - @Override - public String toString() { - return String.format("getAndRemove(%s)", literal(this.key)); - } - } - - static final class Clear extends BasicAction { - static final Clear INSTANCE = new Clear(); + static final class Set extends BasicAction { + final String key; + final String value; - private Clear() {} + Set(String key, String value) { + this.key = key; + this.value = value; + } - @Override - protected void _applyToTestMap(TagMap testMap) { - testMap.clear(); - } - - @Override - protected void _applyToExpectedMap(Map mapUnderTest) { - mapUnderTest.clear(); - } + @Override + protected void _applyToTestMap(TagMap testMap) { + testMap.set(this.key, this.value); + } - @Override - public void verifyTestMap(TagMap testMap) { - assertTrue(testMap.isEmpty()); + @Override + protected void _applyToExpectedMap(Map expectedMap) { + expectedMap.put(this.key, this.value); + } + + @Override + public void verifyTestMap(TagMap testMap) { + assertEquals(this.value, testMap.get(this.key)); + } + + @Override + public String toString() { + return String.format("set(%s,%s)", literal(this.key), literal(this.value)); + } + } + + static final class GetAndSet extends ReturningAction { + final String key; + final String value; + + GetAndSet(String key, String value) { + this.key = key; + this.value = value; + } + + @Override + protected TagMap.Entry _applyToTestMap(TagMap testMap) { + return testMap.getAndSet(this.key, this.value); + } + + @Override + protected Object _applyToExpectedMap(Map expectedMap) { + return expectedMap.put(this.key, this.value); + } + + @Override + protected void _verifyResults(Object expected, TagMap.Entry actual) { + if (expected == null) { + assertNull(actual); + } else { + assertEquals(expected, actual.objectValue()); + } + } + + @Override + public void verifyTestMap(TagMap testMap) { + assertEquals(this.value, testMap.get(this.key)); + } + + @Override + public String toString() { + return String.format("getAndSet(%s,%s)", literal(this.key), literal(this.value)); + } + } + + static final class PutAll extends BasicAction { + final String[] keysAndValues; + final Map map; + + PutAll(String... keysAndValues) { + this.keysAndValues = keysAndValues; + this.map = mapOf(keysAndValues); + } + + @Override + protected void _applyToTestMap(TagMap testMap) { + testMap.putAll(this.map); + } + + @Override + public void _applyToExpectedMap(Map expectedMap) { + expectedMap.putAll(this.map); + } + + @Override + public void verifyTestMap(TagMap expectedMap) { + for (Map.Entry entry : this.map.entrySet()) { + assertEquals(entry.getValue(), expectedMap.get(entry.getKey())); + } + } + + @Override + public String toString() { + return String.format("putAll(%s)", literalVarArgs(this.keysAndValues)); + } + } + + static final class PutAllTagMap extends BasicAction { + final String[] keysAndValues; + final TagMap tagMap; + + PutAllTagMap(String... keysAndValues) { + this.keysAndValues = keysAndValues; + this.tagMap = tagMapOf(keysAndValues); + } + + @Override + protected void _applyToTestMap(TagMap testMap) { + testMap.putAll(this.tagMap); + } + + @Override + protected void _applyToExpectedMap(Map expectedMap) { + expectedMap.putAll(this.tagMap); + } + + @Override + public void verifyTestMap(TagMap expectedMap) { + for (TagMap.EntryReader entry : this.tagMap) { + assertEquals(entry.objectValue(), expectedMap.get(entry.tag()), "key=" + entry.tag()); + } + } + + @Override + public String toString() { + return String.format("putAllTagMap(%s)", literalVarArgs(this.keysAndValues)); + } + } + + static final class PutAllLedger extends BasicAction { + final String[] keysAndValues; + final TagMap.Ledger ledger; + + PutAllLedger(String... keysAndValues) { + this.keysAndValues = keysAndValues; + this.ledger = ledgerOf(keysAndValues); + } + + @Override + protected void _applyToTestMap(TagMap testMap) { + this.ledger.fill(testMap); + } + + @Override + protected void _applyToExpectedMap(Map expectedMap) { + for (TagMap.EntryChange change : this.ledger) { + // ledgerOf - doesn't produce / removes, so this is safe + TagMap.Entry entry = (TagMap.Entry) change; + expectedMap.put(entry.tag(), entry.objectValue()); + } + } + + @Override + public void verifyTestMap(TagMap expectedMap) { + // ledger may contain multiple updates of the same key + // easier to produce a TagMap and check against it + + for (TagMap.EntryReader entry : this.ledger.buildImmutable()) { + assertEquals(entry.objectValue(), expectedMap.get(entry.tag()), "key=" + entry.tag()); + } + } + + @Override + public String toString() { + return String.format("putAllLedger(%s)", literalVarArgs(this.keysAndValues)); + } + } + + static final class Remove extends BasicReturningAction { + final String key; + + Remove(String key) { + this.key = key; + } + + @Override + protected Object _applyToTestMap(TagMap testMap) { + return testMap.remove((Object) this.key); + } + + @Override + protected Object _applyToExpectedMap(Map expectedMap) { + return expectedMap.remove(this.key); + } + + @Override + public void verifyTestMap(TagMap testMap) { + assertFalse(testMap.containsKey(this.key)); + } + + @Override + public String toString() { + return String.format("remove(%s)", literal(this.key)); + } + } + + static final class RemoveLight extends ReturningAction { + final String key; + + RemoveLight(String key) { + this.key = key; + } + + @Override + protected Boolean _applyToTestMap(TagMap testMap) { + return testMap.remove(this.key); + } + + @Override + protected Object _applyToExpectedMap(Map expectedMap) { + return expectedMap.remove(this.key); + } + + @Override + protected void _verifyResults(Object expected, Boolean actual) { + assertEquals((expected != null), actual); + } + + @Override + public void verifyTestMap(TagMap testMap) { + assertFalse(testMap.containsKey(this.key)); + } + + @Override + public String toString() { + return String.format("removeLight(%s)", literal(this.key)); + } + } + + static final class GetAndRemove extends ReturningAction { + final String key; + + GetAndRemove(String key) { + this.key = key; + } + + @Override + protected TagMap.Entry _applyToTestMap(TagMap testMap) { + return testMap.getAndRemove(this.key); + } + + @Override + protected Object _applyToExpectedMap(Map expectedMap) { + return expectedMap.remove(this.key); + } + + @Override + protected void _verifyResults(Object expected, TagMap.Entry actual) { + if (expected == null) { + assertNull(actual); + } else { + assertEquals(expected, actual.objectValue()); + } + } + + @Override + public void verifyTestMap(TagMap testMap) { + assertFalse(testMap.containsKey(this.key)); + } + + @Override + public String toString() { + return String.format("getAndRemove(%s)", literal(this.key)); + } } - @Override - public String toString() { - return "clear()"; + static final class Clear extends BasicAction { + static final Clear INSTANCE = new Clear(); + + private Clear() {} + + @Override + protected void _applyToTestMap(TagMap testMap) { + testMap.clear(); + } + + @Override + protected void _applyToExpectedMap(Map mapUnderTest) { + mapUnderTest.clear(); + } + + @Override + public void verifyTestMap(TagMap testMap) { + assertTrue(testMap.isEmpty()); + } + + @Override + public String toString() { + return "clear()"; + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java index ddd7dccf2b4..e6a0e5f494a 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapLedgerTest.java @@ -11,227 +11,227 @@ import org.junit.jupiter.api.Test; public class TagMapLedgerTest { - static final int SIZE = 32; + static final int SIZE = 32; - @Test - public void inOrder() { - TagMap.Ledger ledger = TagMap.ledger(); - for (int i = 0; i < SIZE; ++i) { - ledger.set(key(i), value(i)); - } - - assertEquals(SIZE, ledger.estimateSize()); + @Test + public void inOrder() { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < SIZE; ++i) { + ledger.set(key(i), value(i)); + } - int i = 0; - for (TagMap.EntryChange entryChange : ledger) { - TagMap.Entry entry = (TagMap.Entry) entryChange; + assertEquals(SIZE, ledger.estimateSize()); - assertEquals(key(i), entry.tag()); - assertEquals(value(i), entry.stringValue()); + int i = 0; + for (TagMap.EntryChange entryChange : ledger) { + TagMap.Entry entry = (TagMap.Entry) entryChange; - ++i; - } - } - - @Test - public void testTypes() { - TagMap.Ledger ledger = TagMap.ledger(); - ledger.set("bool", true); - ledger.set("int", 1); - ledger.set("long", 1L); - ledger.set("float", 1F); - ledger.set("double", 1D); - ledger.set("object", (Object) "string"); - ledger.set("string", "string"); - - assertEntryRawType(TagMap.Entry.BOOLEAN, ledger, "bool"); - assertEntryRawType(TagMap.Entry.INT, ledger, "int"); - assertEntryRawType(TagMap.Entry.LONG, ledger, "long"); - assertEntryRawType(TagMap.Entry.FLOAT, ledger, "float"); - assertEntryRawType(TagMap.Entry.DOUBLE, ledger, "double"); - assertEntryRawType(TagMap.Entry.ANY, ledger, "object"); - assertEntryRawType(TagMap.Entry.OBJECT, ledger, "string"); - } + assertEquals(key(i), entry.tag()); + assertEquals(value(i), entry.stringValue()); - @Test - public void buildMutable() { - TagMap.Ledger ledger = TagMap.ledger(); - for (int i = 0; i < SIZE; ++i) { - ledger.set(key(i), value(i)); + ++i; + } } - assertEquals(SIZE, ledger.estimateSize()); - - TagMap map = ledger.build(); - for (int i = 0; i < SIZE; ++i) { - assertEquals(value(i), map.getString(key(i))); + @Test + public void testTypes() { + TagMap.Ledger ledger = TagMap.ledger(); + ledger.set("bool", true); + ledger.set("int", 1); + ledger.set("long", 1L); + ledger.set("float", 1F); + ledger.set("double", 1D); + ledger.set("object", (Object) "string"); + ledger.set("string", "string"); + + assertEntryRawType(TagMap.Entry.BOOLEAN, ledger, "bool"); + assertEntryRawType(TagMap.Entry.INT, ledger, "int"); + assertEntryRawType(TagMap.Entry.LONG, ledger, "long"); + assertEntryRawType(TagMap.Entry.FLOAT, ledger, "float"); + assertEntryRawType(TagMap.Entry.DOUBLE, ledger, "double"); + assertEntryRawType(TagMap.Entry.ANY, ledger, "object"); + assertEntryRawType(TagMap.Entry.OBJECT, ledger, "string"); } - assertEquals(SIZE, map.size()); - // just proving that the map is mutable - map.set(key(1000), value(1000)); - } + @Test + public void buildMutable() { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < SIZE; ++i) { + ledger.set(key(i), value(i)); + } - @Test - public void buildImmutable() { - TagMap.Ledger ledger = TagMap.ledger(); - for (int i = 0; i < SIZE; ++i) { - ledger.set(key(i), value(i)); - } + assertEquals(SIZE, ledger.estimateSize()); - assertEquals(SIZE, ledger.estimateSize()); + TagMap map = ledger.build(); + for (int i = 0; i < SIZE; ++i) { + assertEquals(value(i), map.getString(key(i))); + } + assertEquals(SIZE, map.size()); - TagMap map = ledger.buildImmutable(); - for (int i = 0; i < SIZE; ++i) { - assertEquals(value(i), map.getString(key(i))); + // just proving that the map is mutable + map.set(key(1000), value(1000)); } - assertEquals(SIZE, map.size()); - assertFrozen(map); - } + @Test + public void buildImmutable() { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < SIZE; ++i) { + ledger.set(key(i), value(i)); + } - @Test - public void build_empty() { - TagMap.Ledger ledger = TagMap.ledger(); - assertTrue(ledger.isDefinitelyEmpty()); - assertNotSame(TagMap.EMPTY, ledger.build()); - } + assertEquals(SIZE, ledger.estimateSize()); - @Test - public void buildImmutable_empty() { - TagMap.Ledger ledger = TagMap.ledger(); - assertTrue(ledger.isDefinitelyEmpty()); - assertSame(TagMap.EMPTY, ledger.buildImmutable()); - } + TagMap map = ledger.buildImmutable(); + for (int i = 0; i < SIZE; ++i) { + assertEquals(value(i), map.getString(key(i))); + } + assertEquals(SIZE, map.size()); - @Test - public void isDefinitelyEmpty_emptyMap() { - TagMap.Ledger ledger = TagMap.ledger(); - ledger.set("foo", "bar"); - ledger.remove("foo"); - - assertFalse(ledger.isDefinitelyEmpty()); - TagMap map = ledger.build(); - assertTrue(map.isEmpty()); - } - - @Test - public void builderExpansion() { - TagMap.Ledger ledger = TagMap.ledger(); - for (int i = 0; i < 100; ++i) { - ledger.set(key(i), value(i)); + assertFrozen(map); } - TagMap map = ledger.build(); - for (int i = 0; i < 100; ++i) { - assertEquals(value(i), map.getString(key(i))); + @Test + public void build_empty() { + TagMap.Ledger ledger = TagMap.ledger(); + assertTrue(ledger.isDefinitelyEmpty()); + assertNotSame(TagMap.EMPTY, ledger.build()); } - } - @Test - public void builderPresized() { - TagMap.Ledger ledger = TagMap.ledger(100); - for (int i = 0; i < 100; ++i) { - ledger.set(key(i), value(i)); + @Test + public void buildImmutable_empty() { + TagMap.Ledger ledger = TagMap.ledger(); + assertTrue(ledger.isDefinitelyEmpty()); + assertSame(TagMap.EMPTY, ledger.buildImmutable()); } - TagMap map = ledger.build(); - for (int i = 0; i < 100; ++i) { - assertEquals(value(i), map.getString(key(i))); + @Test + public void isDefinitelyEmpty_emptyMap() { + TagMap.Ledger ledger = TagMap.ledger(); + ledger.set("foo", "bar"); + ledger.remove("foo"); + + assertFalse(ledger.isDefinitelyEmpty()); + TagMap map = ledger.build(); + assertTrue(map.isEmpty()); } - } - @Test - public void buildWithRemoves() { - TagMap.Ledger ledger = TagMap.ledger(); - for (int i = 0; i < SIZE; ++i) { - ledger.set(key(i), value(i)); + @Test + public void builderExpansion() { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < 100; ++i) { + ledger.set(key(i), value(i)); + } + + TagMap map = ledger.build(); + for (int i = 0; i < 100; ++i) { + assertEquals(value(i), map.getString(key(i))); + } } - for (int i = 0; i < SIZE; i += 2) { - ledger.remove(key(i)); + @Test + public void builderPresized() { + TagMap.Ledger ledger = TagMap.ledger(100); + for (int i = 0; i < 100; ++i) { + ledger.set(key(i), value(i)); + } + + TagMap map = ledger.build(); + for (int i = 0; i < 100; ++i) { + assertEquals(value(i), map.getString(key(i))); + } } - assertTrue(ledger.containsRemovals()); + @Test + public void buildWithRemoves() { + TagMap.Ledger ledger = TagMap.ledger(); + for (int i = 0; i < SIZE; ++i) { + ledger.set(key(i), value(i)); + } - TagMap map = ledger.build(); - for (int i = 0; i < SIZE; ++i) { - if ((i % 2) == 0) { - assertNull(map.getString(key(i))); - } else { - assertEquals(value(i), map.getString(key(i))); - } - } + for (int i = 0; i < SIZE; i += 2) { + ledger.remove(key(i)); + } - ledger.reset(); + assertTrue(ledger.containsRemovals()); - assertFalse(ledger.containsRemovals()); - } + TagMap map = ledger.build(); + for (int i = 0; i < SIZE; ++i) { + if ((i % 2) == 0) { + assertNull(map.getString(key(i))); + } else { + assertEquals(value(i), map.getString(key(i))); + } + } - @Test - public void smartRemoval_existingCase() { - TagMap.Ledger ledger = TagMap.ledger(); - ledger.set("foo", "bar"); - ledger.smartRemove("foo"); + ledger.reset(); - assertTrue(ledger.containsRemovals()); + assertFalse(ledger.containsRemovals()); + } - ledger.reset(); + @Test + public void smartRemoval_existingCase() { + TagMap.Ledger ledger = TagMap.ledger(); + ledger.set("foo", "bar"); + ledger.smartRemove("foo"); - assertFalse(ledger.containsRemovals()); - } + assertTrue(ledger.containsRemovals()); - @Test - public void smartRemoval_missingCase() { - TagMap.Ledger ledger = TagMap.ledger(); - ledger.smartRemove("foo"); + ledger.reset(); - assertFalse(ledger.containsRemovals()); + assertFalse(ledger.containsRemovals()); + } - ledger.reset(); + @Test + public void smartRemoval_missingCase() { + TagMap.Ledger ledger = TagMap.ledger(); + ledger.smartRemove("foo"); - assertFalse(ledger.containsRemovals()); - } + assertFalse(ledger.containsRemovals()); - @Test - public void reset() { - TagMap.Ledger ledger = TagMap.ledger(2); + ledger.reset(); - ledger.set(key(0), value(0)); - TagMap map0 = ledger.build(); + assertFalse(ledger.containsRemovals()); + } - ledger.reset(); + @Test + public void reset() { + TagMap.Ledger ledger = TagMap.ledger(2); - ledger.set(key(1), value(1)); - TagMap map1 = ledger.build(); + ledger.set(key(0), value(0)); + TagMap map0 = ledger.build(); - assertEquals(value(0), map0.getString(key(0))); - assertNull(map1.getString(key(0))); + ledger.reset(); - assertNull(map0.getString(key(1))); - assertEquals(value(1), map1.getString(key(1))); - } + ledger.set(key(1), value(1)); + TagMap map1 = ledger.build(); - static final String key(int i) { - return "key-" + i; - } + assertEquals(value(0), map0.getString(key(0))); + assertNull(map1.getString(key(0))); - static final String value(int i) { - return "value-" + i; - } + assertNull(map0.getString(key(1))); + assertEquals(value(1), map1.getString(key(1))); + } - static final void assertEntryRawType(byte expectedType, TagMap.Ledger ledger, String tag) { - TagMap.Entry entry = ledger.findLastEntry(tag); - assertEquals(expectedType, entry.rawType); - } + static final String key(int i) { + return "key-" + i; + } + + static final String value(int i) { + return "value-" + i; + } + + static final void assertEntryRawType(byte expectedType, TagMap.Ledger ledger, String tag) { + TagMap.Entry entry = ledger.findLastEntry(tag); + assertEquals(expectedType, entry.rawType); + } - static final void assertFrozen(TagMap map) { - IllegalStateException ex = null; - try { - map.set("foo", "bar"); - } catch (IllegalStateException e) { - ex = e; + static final void assertFrozen(TagMap map) { + IllegalStateException ex = null; + try { + map.set("foo", "bar"); + } catch (IllegalStateException e) { + ex = e; + } + assertNotNull(ex); } - assertNotNull(ex); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java index b8764472847..34c7e63063a 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java @@ -15,52 +15,52 @@ */ class TagMapNullToleranceTest { - @Test - void createReturnsNullForNullOrEmptyValue() { - // CharSequence overload: null or empty -> null - assertNull(TagMap.Entry.create("k", (CharSequence) null)); - assertNull(TagMap.Entry.create("k", "")); + @Test + void createReturnsNullForNullOrEmptyValue() { + // CharSequence overload: null or empty -> null + assertNull(TagMap.Entry.create("k", (CharSequence) null)); + assertNull(TagMap.Entry.create("k", "")); - // Object overload: null -> null, and an empty String typed as Object -> null (runtime check, - // so the convention holds regardless of the static type at the call site) - assertNull(TagMap.Entry.create("k", (Object) null)); - assertNull(TagMap.Entry.create("k", (Object) "")); + // Object overload: null -> null, and an empty String typed as Object -> null (runtime check, + // so the convention holds regardless of the static type at the call site) + assertNull(TagMap.Entry.create("k", (Object) null)); + assertNull(TagMap.Entry.create("k", (Object) "")); - // Non-empty values still produce an entry, whatever the static type - assertNotNull(TagMap.Entry.create("k", "v")); - assertNotNull(TagMap.Entry.create("k", (Object) "v")); - // Non-CharSequence Object has no notion of "empty" -> always an entry - assertNotNull(TagMap.Entry.create("k", (Object) Integer.valueOf(0))); - } + // Non-empty values still produce an entry, whatever the static type + assertNotNull(TagMap.Entry.create("k", "v")); + assertNotNull(TagMap.Entry.create("k", (Object) "v")); + // Non-CharSequence Object has no notion of "empty" -> always an entry + assertNotNull(TagMap.Entry.create("k", (Object) Integer.valueOf(0))); + } - @Test - void getAndSetToleratesNullEntry() { - TagMap map = TagMap.create(); - assertNull(map.getAndSet((TagMap.Entry) null), "null entry is a no-op returning null"); - assertEquals(0, map.size(), "no tag added"); - } + @Test + void getAndSetToleratesNullEntry() { + TagMap map = TagMap.create(); + assertNull(map.getAndSet((TagMap.Entry) null), "null entry is a no-op returning null"); + assertEquals(0, map.size(), "no tag added"); + } - @Test - void setToleratesNullReader() { - TagMap map = TagMap.create(); - map.set((TagMap.EntryReader) null); // must not throw - assertEquals(0, map.size(), "no tag added"); - } + @Test + void setToleratesNullReader() { + TagMap map = TagMap.create(); + map.set((TagMap.EntryReader) null); // must not throw + assertEquals(0, map.size(), "no tag added"); + } - @Test - void nullOrEmptyFlowsThroughTheEntryPathwayAsNoTag() { - TagMap map = TagMap.create(); + @Test + void nullOrEmptyFlowsThroughTheEntryPathwayAsNoTag() { + TagMap map = TagMap.create(); - // The seamless case: create(...) -> set(Entry) with a null/empty value, no caller guard. - map.set(TagMap.Entry.create("empty", "")); - map.set(TagMap.Entry.create("emptyObj", (Object) "")); - map.set(TagMap.Entry.create("nul", (CharSequence) null)); - assertEquals(0, map.size(), "null/empty values leave no tags behind"); - assertFalse(map.containsKey("empty")); + // The seamless case: create(...) -> set(Entry) with a null/empty value, no caller guard. + map.set(TagMap.Entry.create("empty", "")); + map.set(TagMap.Entry.create("emptyObj", (Object) "")); + map.set(TagMap.Entry.create("nul", (CharSequence) null)); + assertEquals(0, map.size(), "null/empty values leave no tags behind"); + assertFalse(map.containsKey("empty")); - // A real value still lands. - map.set(TagMap.Entry.create("present", "v")); - assertEquals(1, map.size()); - assertTrue(map.containsKey("present")); - } + // A real value still lands. + map.set(TagMap.Entry.create("present", "v")); + assertEquals(1, map.size()); + assertTrue(map.containsKey("present")); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java index b424c5793ce..9b5b81fe1ed 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java @@ -20,699 +20,699 @@ */ class TagMapReadThroughTest { - private static TagMap frozenParent() { - TagMap parent = TagMap.create(); - parent.set("a", "parent-a"); - parent.set("b", "parent-b"); - parent.freeze(); - return parent; - } - - @Test - void readsThroughToParentOnMiss() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("c", "child-c"); - - assertEquals("parent-a", child.getString("a")); // miss locally -> read through - assertEquals("parent-b", child.getString("b")); - assertEquals("child-c", child.getString("c")); // local - assertNull(child.getString("missing")); - assertTrue(child.containsKey("a")); - assertFalse(child.containsKey("missing")); - } - - @Test - void localEntryShadowsParent() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); // same key as parent - - assertEquals("child-b", child.getString("b")); // local wins - assertEquals("parent-a", child.getString("a")); // parent still visible - } - - @Test - void estimateSizeIsUpperBound() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); // shadows parent "b" - child.set("c", "child-c"); - - // true union = {a, b, c} = 3; estimate over-counts the shadowed "b": local 2 + parent 2 = 4 - assertEquals(4, child.estimateSize()); - assertTrue(child.estimateSize() >= 3, "estimateSize must be an upper bound on the true size"); - } - - @Test - void emptinessSemantics() { - TagMap emptyOverEmpty = TagMap.createFromParent(TagMap.create().freeze()); - assertTrue(emptyOverEmpty.isEmpty()); - assertTrue(emptyOverEmpty.isDefinitelyEmpty()); - - TagMap emptyOverNonEmpty = TagMap.createFromParent(frozenParent()); - assertFalse(emptyOverNonEmpty.isEmpty(), "a non-empty parent makes the map non-empty"); - assertFalse(emptyOverNonEmpty.isDefinitelyEmpty()); - - assertTrue((TagMap.create()).isDefinitelyEmpty()); - } - - @Test - void parentMustBeFrozen() { - TagMap mutableParent = TagMap.create(); - assertThrows(IllegalStateException.class, () -> TagMap.createFromParent(mutableParent)); - } - - @Test - void emptyParentIsDroppedNotAttached() { - TagMap emptyFrozen = TagMap.create(); - emptyFrozen.freeze(); - - TagMap overEmpty = TagMap.createFromParent(emptyFrozen); - // an empty frozen parent contributes nothing and never will -> dropped, no read-through cost - assertNull(overEmpty.parent, "empty parent should be dropped"); - assertTrue(overEmpty.isDefinitelyEmpty()); - overEmpty.set("x", "x-val"); // still a normal mutable map - assertEquals("x-val", overEmpty.getString("x")); - - // a non-empty parent is still attached - TagMap overNonEmpty = TagMap.createFromParent(frozenParent()); - assertNotNull(overNonEmpty.parent, "non-empty parent must be attached"); - assertEquals("parent-a", overNonEmpty.getString("a")); - } - - // --- slice 2: removal / tombstones --- - - @Test - void removingParentKeyHidesItFromChildButNotFromParent() { - TagMap parent = frozenParent(); - TagMap child = TagMap.createFromParent(parent); - - assertEquals("parent-a", child.getString("a")); // visible before removal - child.remove("a"); - - assertNull(child.getString("a")); // tombstoned: no longer reads through - assertFalse(child.containsKey("a")); - assertEquals("parent-b", child.getString("b")); // other parent keys unaffected - assertEquals("parent-a", parent.getString("a")); // frozen parent untouched - } - - @Test - void removeReturnsPriorVisibleValueViaParent() { - TagMap child = TagMap.createFromParent(frozenParent()); - - // Map.remove contract: the key was present (via read-through), so removal reports it. - assertTrue(child.remove("a"), "removing a parent-exposed key should report it was present"); - assertNull(child.getString("a")); - } - - @Test - void reSettingARemovedKeyRestoresVisibility() { - TagMap child = TagMap.createFromParent(frozenParent()); - - child.remove("a"); - assertNull(child.getString("a")); - - child.set("a", "child-a"); // re-set clears the tombstone - assertEquals("child-a", child.getString("a")); - } - - @Test - void removingAKeyThatIsBothLocalAndParentHidesBoth() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); // shadows parent "b" - - assertEquals("child-b", child.getString("b")); - child.remove("b"); - - assertNull(child.getString("b"), "removal must hide both the local entry and the parent's"); - assertEquals("parent-b", frozenParent().getString("b")); // parent still has it - } - - // --- slice 3a: bulk forEach union + exact size/isEmpty --- - - private static Map collect(TagMap map) { - Map out = new HashMap<>(); - map.forEach(e -> out.put(e.tag(), e.objectValue())); - return out; - } - - @Test - void forEachEmitsDedupedUnionLocalWins() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} - child.set("b", "child-b"); // shadows parent "b" - child.set("c", "child-c"); - - Map u = collect(child); - assertEquals(3, u.size(), "union {a, b, c} with b deduped"); - assertEquals("parent-a", u.get("a")); // read-through - assertEquals("child-b", u.get("b")); // local wins (no duplicate emit) - assertEquals("child-c", u.get("c")); - } - - @Test - void forEachSkipsTombstonedParentKeys() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("c", "child-c"); - child.remove("a"); // tombstone parent's "a" - - Map u = collect(child); - assertEquals(2, u.size()); - assertFalse(u.containsKey("a")); - assertEquals("parent-b", u.get("b")); - assertEquals("child-c", u.get("c")); - } - - @Test - void biConsumerForEachAlsoEmitsUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("c", "child-c"); - - Map out = new HashMap<>(); - child.forEach(out, (m, e) -> m.put(e.tag(), e.objectValue())); // non-capturing: alloc-free path - assertEquals(3, out.size()); - assertEquals("parent-a", out.get("a")); - assertEquals("child-c", out.get("c")); - } - - @Test - void sizeIsExactUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); // shadows - child.set("c", "child-c"); - assertEquals(3, child.size()); // {a, b, c} — b deduped, not 4 - - child.remove("a"); - assertEquals(2, child.size()); // {b, c} - } - - @Test - void isEmptyExactWhenAllParentKeysTombstonedAndNoLocal() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} - assertFalse(child.isEmpty()); - - child.remove("a"); - child.remove("b"); - assertTrue(child.isEmpty(), "all parent keys tombstoned and no local entries -> empty"); - assertEquals(0, child.size()); - } - - // --- slice 3b: pull-based iterators / collection views --- - - @Test - void iteratorEmitsDedupedUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); // shadows parent "b" - child.set("c", "child-c"); - - Map u = new HashMap<>(); - Iterator it = child.iterator(); - while (it.hasNext()) { - TagMap.EntryReader e = it.next(); - u.put(e.tag(), e.objectValue()); - } - assertEquals(3, u.size()); - assertEquals("parent-a", u.get("a")); - assertEquals("child-b", u.get("b")); // local wins, emitted once - assertEquals("child-c", u.get("c")); - } - - @Test - void keySetReflectsUnionAndTombstones() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("c", "child-c"); - - Set keys = child.keySet(); - assertEquals(3, keys.size()); // a, b, c - assertTrue(keys.contains("a")); - assertTrue(keys.contains("c")); - - child.remove("a"); - assertEquals(2, child.keySet().size()); - assertFalse(child.keySet().contains("a")); - } - - @Test - void valuesAndEntrySetReflectUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); // shadows parent "b" - - assertEquals(2, child.entrySet().size()); // {a, b} — b deduped - assertTrue(child.values().contains("child-b")); // local-won value - assertTrue(child.values().contains("parent-a")); - assertFalse(child.values().contains("parent-b"), "shadowed parent value must not appear"); - } - - // --- slice 3c: putAll from a read-through source copies the visible union, not just locals --- - - @Test - void putAllFromReadThroughSourceCopiesFullVisibleUnion() { - TagMap source = TagMap.createFromParent(frozenParent()); // parent {a, b} - source.set("b", "child-b"); // shadows parent b - source.set("c", "child-c"); - - TagMap dest = TagMap.create(); // empty -> putAllIntoEmptyMap path - dest.putAll(source); - - // the parent-visible "a" must land too, not just source's local entries - assertEquals(3, dest.size()); - assertEquals("parent-a", dest.getString("a")); - assertEquals("child-b", dest.getString("b")); // local-won value, deduped - assertEquals("child-c", dest.getString("c")); - } - - @Test - void putAllMergeFromReadThroughSourceCopiesVisibleUnion() { - TagMap source = TagMap.createFromParent(frozenParent()); // {a, b} - - TagMap dest = TagMap.create(); - dest.set("z", "dest-z"); // dest non-empty -> putAllMerge path - - dest.putAll(source); - assertEquals(3, dest.size()); // {a, b, z} - assertEquals("parent-a", dest.getString("a")); - assertEquals("parent-b", dest.getString("b")); - assertEquals("dest-z", dest.getString("z")); - } - - @Test - void putAllFromReadThroughSourceHonorsTombstones() { - TagMap source = TagMap.createFromParent(frozenParent()); - source.remove("a"); // tombstone parent's "a" - - TagMap dest = TagMap.create(); - dest.putAll(source); - - assertEquals(1, dest.size()); - assertFalse(dest.containsKey("a"), "tombstoned key must not be copied"); - assertEquals("parent-b", dest.getString("b")); - } - - // --- slice 4: behavior-identical to a copy-down / flat map --- - - @Test - void copyIsObservationallyIdentical() { - TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} - child.set("b", "child-b"); // shadows parent "b" - child.set("c", "child-c"); - - TagMap copy = child.copy(); - assertEquals(child.size(), copy.size()); - assertEquals("parent-a", copy.getString("a")); // copy still reads through - assertEquals("child-b", copy.getString("b")); - assertEquals("child-c", copy.getString("c")); - assertEquals(collect(child), collect(copy)); // same union - } - - @Test - void copyIsIndependentlyMutable() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("c", "child-c"); - - TagMap copy = child.copy(); - copy.set("c", "copy-c"); // mutate copy's local - copy.remove("a"); // tombstone on copy only - - assertEquals("child-c", child.getString("c"), "original unaffected by copy mutation"); - assertEquals("parent-a", child.getString("a"), "original still reads through a"); - assertEquals("copy-c", copy.getString("c")); - assertNull(copy.getString("a")); - } - - @Test - void copyPreservesTombstones() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.remove("a"); // tombstone "a" - - TagMap copy = child.copy(); - assertNull(copy.getString("a"), "tombstone must carry into the copy"); - assertEquals("parent-b", copy.getString("b")); - } - - /** The contract that lets the consumer flip mergedTracerTags to a parent. */ - @Test - void readThroughMatchesAnEquivalentFlatMap() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); - child.set("c", "child-c"); - - TagMap flat = TagMap.create(); - flat.set("a", "parent-a"); - flat.set("b", "child-b"); - flat.set("c", "child-c"); - - assertEquals(flat.size(), child.size()); - assertEquals(collect(flat), collect(child)); - assertEquals(flat.keySet(), child.keySet()); - for (String k : new String[] {"a", "b", "c", "missing"}) { - assertEquals(flat.getString(k), child.getString(k), "mismatch for key " + k); - } - } - - @Test - void immutableCopyOfReadThroughIsFrozenAndStillReadsThrough() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("c", "child-c"); - - TagMap frozen = child.immutableCopy(); - assertTrue(frozen.isFrozen()); - assertEquals("parent-a", frozen.getString("a")); // union preserved - assertEquals("child-c", frozen.getString("c")); - assertThrows(IllegalStateException.class, () -> frozen.set("x", "y")); // frozen blocks writes - } - - // --- slice 5: multi-level chains (baggage-style layering over more than one frozen parent) --- - - /** - * Builds a 3-level chain leaf -> mid -> grandparent (both ancestors frozen) and returns the - * leaf. Visible union, nearest-level-wins: {a=gp-a, b=mid-b, c=leaf-c, d=mid-d, e=leaf-e}. - */ - private static TagMap threeLevelLeaf() { - TagMap grandparent = TagMap.create(); - grandparent.set("a", "gp-a"); - grandparent.set("b", "gp-b"); - grandparent.set("c", "gp-c"); - grandparent.freeze(); - - TagMap mid = TagMap.createFromParent(grandparent); - mid.set("b", "mid-b"); // shadows grandparent b - mid.set("d", "mid-d"); - mid.freeze(); - - TagMap leaf = TagMap.createFromParent(mid); - leaf.set("c", "leaf-c"); // shadows grandparent c (mid doesn't define c) - leaf.set("e", "leaf-e"); - return leaf; - } - - @Test - void getWalksTheWholeChainNearestWins() { - TagMap leaf = threeLevelLeaf(); - assertEquals("gp-a", leaf.getString("a")); // only in grandparent (two levels up) - assertEquals("mid-b", leaf.getString("b")); // mid shadows grandparent - assertEquals("leaf-c", leaf.getString("c")); // leaf shadows grandparent - assertEquals("mid-d", leaf.getString("d")); - assertEquals("leaf-e", leaf.getString("e")); - assertNull(leaf.getString("missing")); - } - - @Test - void sizeIsExactUnionAcrossChain() { - assertEquals(5, threeLevelLeaf().size()); // {a, b, c, d, e}, shadowed duplicates deduped - } - - @Test - void forEachEmitsDedupedUnionAcrossChain() { - Map u = collect(threeLevelLeaf()); - assertEquals(5, u.size()); - assertEquals("gp-a", u.get("a")); - assertEquals("mid-b", u.get("b")); // nearest ancestor wins over grandparent - assertEquals("leaf-c", u.get("c")); // leaf wins - assertEquals("mid-d", u.get("d")); - assertEquals("leaf-e", u.get("e")); - } - - @Test - void iteratorEmitsDedupedUnionAcrossChain() { - Map u = new HashMap<>(); - Iterator it = threeLevelLeaf().iterator(); - while (it.hasNext()) { - TagMap.EntryReader e = it.next(); - u.put(e.tag(), e.objectValue()); - } - assertEquals(5, u.size()); - assertEquals("gp-a", u.get("a")); - assertEquals("mid-b", u.get("b")); - assertEquals("leaf-c", u.get("c")); - } - - @Test - void keySetReflectsChainUnion() { - Set keys = threeLevelLeaf().keySet(); - assertEquals(5, keys.size()); - for (String k : new String[] {"a", "b", "c", "d", "e"}) { - assertTrue(keys.contains(k), "missing key " + k); - } - } - - @Test - void leafTombstoneHidesGrandparentOnlyKey() { - TagMap leaf = threeLevelLeaf(); - leaf.remove("a"); // "a" lives only in the grandparent, two levels up - assertNull(leaf.getString("a")); - assertFalse(leaf.containsKey("a")); - assertFalse(leaf.keySet().contains("a")); - assertEquals(4, leaf.size()); // {b, c, d, e} - } - - @Test - void intermediateAncestorTombstoneIsHonoredByBulkViews() { - // mid removes an inherited grandparent key, THEN is frozen and reused as a parent. A non-leaf - // level can therefore carry its own tombstones. Point lookups recurse through mid's tombstone - // (correct); the bulk views must agree and not re-emit the key. - TagMap grandparent = TagMap.create(); - grandparent.set("a", "gp-a"); - grandparent.set("b", "gp-b"); - grandparent.freeze(); - - TagMap mid = TagMap.createFromParent(grandparent); - mid.remove("a"); // tombstone an inherited key before freezing - mid.freeze(); - - TagMap leaf = TagMap.createFromParent(mid); - - // point lookups (recurse -> already correct) - assertNull(leaf.getString("a")); - assertFalse(leaf.containsKey("a")); - assertEquals("gp-b", leaf.getString("b")); - - // bulk views must agree: mid's tombstone hides grandparent's "a" - assertEquals(1, leaf.size()); - assertFalse(leaf.keySet().contains("a")); - - Map viaForEach = collect(leaf); - assertEquals(1, viaForEach.size()); - assertFalse(viaForEach.containsKey("a")); - assertEquals("gp-b", viaForEach.get("b")); - - Map viaIterator = new HashMap<>(); - Iterator it = leaf.iterator(); - while (it.hasNext()) { - TagMap.EntryReader e = it.next(); - viaIterator.put(e.tag(), e.objectValue()); - } - assertEquals(1, viaIterator.size()); - assertFalse(viaIterator.containsKey("a")); - } - - @Test - void observationallyEmptyIntermediateParentIsDroppedNotAttached() { - // A frozen mid level with no local entries whose every inherited key is tombstoned is - // observationally empty even though a local (grandparent) level still holds entries. - // createFromParent must drop it (exact isEmpty), so the leaf stays consistent: isEmpty() must - // agree with size()/lookup/iteration. - TagMap grandparent = TagMap.create(); - grandparent.set("a", "gp-a"); - grandparent.freeze(); - - TagMap mid = TagMap.createFromParent(grandparent); - mid.remove("a"); // tombstone the only inherited key -> mid is observationally empty - mid.freeze(); - assertTrue(mid.isEmpty()); - - TagMap leaf = TagMap.createFromParent(mid); - assertEquals(0, leaf.size()); - assertTrue(leaf.isEmpty(), "isEmpty() must agree with size()==0"); - assertNull(leaf.getString("a")); - assertTrue(collect(leaf).isEmpty()); - assertFalse(leaf.iterator().hasNext()); - } - - @Test - void chainReadThroughMatchesEquivalentFlatMap() { - TagMap leaf = threeLevelLeaf(); - - TagMap flat = TagMap.create(); - flat.set("a", "gp-a"); - flat.set("b", "mid-b"); - flat.set("c", "leaf-c"); - flat.set("d", "mid-d"); - flat.set("e", "leaf-e"); - - assertEquals(flat.size(), leaf.size()); - assertEquals(collect(flat), collect(leaf)); - assertEquals(flat.keySet(), leaf.keySet()); - for (String k : new String[] {"a", "b", "c", "d", "e", "missing"}) { - assertEquals(flat.getString(k), leaf.getString(k), "mismatch for key " + k); - } - } - - // --- slice 6: put/getAndSet report the prior visible value, including inherited --- - - @Test - void putReturnsInheritedParentValueAsPrior() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} - Object prior = child.put("a", "child-a"); // "a" exists only in the parent - assertEquals("parent-a", prior, "put must report the inherited value as the previous mapping"); - assertEquals("child-a", child.getString("a")); // new value stored locally - } - - @Test - void putReturnsNullForAKeyInNeitherLocalNorParent() { - TagMap child = TagMap.createFromParent(frozenParent()); - assertNull(child.put("brand-new", "v")); - } - - @Test - void putReturnsLocalPriorWhenShadowingParent() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("a", "local-a"); // local now shadows the parent - assertEquals("local-a", child.put("a", "local-a2"), "the local prior wins over the parent's"); - } - - @Test - void putAfterRemoveReportsNoPriorNotTheParentValue() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.remove("a"); // tombstone the parent's "a": no longer visible - assertNull(child.put("a", "child-a"), "a tombstoned key had no visible prior value"); - assertEquals("child-a", child.getString("a")); - } - - @Test - void getAndSetReturnsInheritedEntryAsPrior() { - TagMap child = TagMap.createFromParent(frozenParent()); - TagMap.Entry prior = child.getAndSet("b", "child-b"); - assertEquals("parent-b", prior.objectValue()); - } - - @Test - void setDoesNotReportPriorButStillClearsTombstone() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.remove("b"); // tombstone - child.set("b", "child-b"); // void set: no prior lookup, but must clear the tombstone - assertEquals("child-b", child.getString("b")); - } - - // --- slice 7: clear() removes inherited mappings too (detaches the parent) --- - - @Test - void clearRemovesInheritedMappingsAndDetachesParent() { - TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} - child.set("c", "child-c"); - - child.clear(); - - assertTrue(child.isEmpty(), "clear must remove local AND inherited mappings"); - assertEquals(0, child.size()); - assertNull(child.getString("a")); // inherited no longer visible - assertNull(child.getString("c")); - assertFalse(child.containsKey("a")); - } - - @Test - void clearDoesNotAffectTheFrozenParent() { - TagMap parent = frozenParent(); - TagMap child = TagMap.createFromParent(parent); - child.clear(); - assertEquals("parent-a", parent.getString("a"), "the shared frozen parent is untouched"); - } - - @Test - void putAfterClearBehavesAsAPlainMap() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.clear(); - child.set("x", "x-val"); - assertEquals(1, child.size()); - assertEquals("x-val", child.getString("x")); - assertNull(child.getString("a"), "no read-through after clear detached the parent"); - } - - // --- slice 8: fillMap/fillStringMap materialize the visible union, not just local entries --- - - private static Map fillMapCollect(TagMap map) { - Map out = new HashMap<>(); - map.fillMap(out); - return out; - } - - private static Map fillStringMapCollect(TagMap map) { - Map out = new HashMap<>(); - map.fillStringMap(out); - return out; - } - - @Test - void fillMapMaterializesDedupedUnionLocalWins() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} - child.set("b", "child-b"); // shadows parent "b" - child.set("c", "child-c"); - - Map out = fillMapCollect(child); - assertEquals(3, out.size(), "union {a, b, c} with b deduped"); - assertEquals("parent-a", out.get("a")); // inherited tag must not be dropped - assertEquals("child-b", out.get("b")); // local wins - assertEquals("child-c", out.get("c")); - } - - @Test - void fillMapHonorsTombstonedParentKeys() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("c", "child-c"); - child.remove("a"); // tombstone parent's "a" - - Map out = fillMapCollect(child); - assertEquals(2, out.size()); - assertFalse(out.containsKey("a")); - assertEquals("parent-b", out.get("b")); - assertEquals("child-c", out.get("c")); - } - - @Test - void fillStringMapMaterializesDedupedUnionLocalWins() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.set("b", "child-b"); // shadows parent "b" - child.set("c", "child-c"); - - Map out = fillStringMapCollect(child); - assertEquals(3, out.size()); - assertEquals("parent-a", out.get("a")); // inherited tag preserved - assertEquals("child-b", out.get("b")); - assertEquals("child-c", out.get("c")); - } - - @Test - void fillStringMapHonorsTombstonedParentKeys() { - TagMap child = TagMap.createFromParent(frozenParent()); - child.remove("a"); // tombstone parent's "a" - - Map out = fillStringMapCollect(child); - assertEquals(1, out.size()); - assertFalse(out.containsKey("a")); - assertEquals("parent-b", out.get("b")); - } - - @Test - void fillMapAcrossChainMatchesEquivalentFlatMap() { - TagMap leaf = threeLevelLeaf(); - - TagMap flat = TagMap.create(); - flat.set("a", "gp-a"); - flat.set("b", "mid-b"); - flat.set("c", "leaf-c"); - flat.set("d", "mid-d"); - flat.set("e", "leaf-e"); - - assertEquals(fillMapCollect(flat), fillMapCollect(leaf)); - assertEquals(fillStringMapCollect(flat), fillStringMapCollect(leaf)); - } - - @Test - void fillMapWithoutParentFillsOnlyLocalEntries() { - // regression: the no-parent fast path is unchanged - TagMap flat = TagMap.create(); - flat.set("x", "x-val"); - flat.set("y", "y-val"); - - Map out = fillMapCollect(flat); - assertEquals(2, out.size()); - assertEquals("x-val", out.get("x")); - assertEquals("y-val", out.get("y")); - } + private static TagMap frozenParent() { + TagMap parent = TagMap.create(); + parent.set("a", "parent-a"); + parent.set("b", "parent-b"); + parent.freeze(); + return parent; + } + + @Test + void readsThroughToParentOnMiss() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + assertEquals("parent-a", child.getString("a")); // miss locally -> read through + assertEquals("parent-b", child.getString("b")); + assertEquals("child-c", child.getString("c")); // local + assertNull(child.getString("missing")); + assertTrue(child.containsKey("a")); + assertFalse(child.containsKey("missing")); + } + + @Test + void localEntryShadowsParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // same key as parent + + assertEquals("child-b", child.getString("b")); // local wins + assertEquals("parent-a", child.getString("a")); // parent still visible + } + + @Test + void estimateSizeIsUpperBound() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + // true union = {a, b, c} = 3; estimate over-counts the shadowed "b": local 2 + parent 2 = 4 + assertEquals(4, child.estimateSize()); + assertTrue(child.estimateSize() >= 3, "estimateSize must be an upper bound on the true size"); + } + + @Test + void emptinessSemantics() { + TagMap emptyOverEmpty = TagMap.createFromParent(TagMap.create().freeze()); + assertTrue(emptyOverEmpty.isEmpty()); + assertTrue(emptyOverEmpty.isDefinitelyEmpty()); + + TagMap emptyOverNonEmpty = TagMap.createFromParent(frozenParent()); + assertFalse(emptyOverNonEmpty.isEmpty(), "a non-empty parent makes the map non-empty"); + assertFalse(emptyOverNonEmpty.isDefinitelyEmpty()); + + assertTrue((TagMap.create()).isDefinitelyEmpty()); + } + + @Test + void parentMustBeFrozen() { + TagMap mutableParent = TagMap.create(); + assertThrows(IllegalStateException.class, () -> TagMap.createFromParent(mutableParent)); + } + + @Test + void emptyParentIsDroppedNotAttached() { + TagMap emptyFrozen = TagMap.create(); + emptyFrozen.freeze(); + + TagMap overEmpty = TagMap.createFromParent(emptyFrozen); + // an empty frozen parent contributes nothing and never will -> dropped, no read-through cost + assertNull(overEmpty.parent, "empty parent should be dropped"); + assertTrue(overEmpty.isDefinitelyEmpty()); + overEmpty.set("x", "x-val"); // still a normal mutable map + assertEquals("x-val", overEmpty.getString("x")); + + // a non-empty parent is still attached + TagMap overNonEmpty = TagMap.createFromParent(frozenParent()); + assertNotNull(overNonEmpty.parent, "non-empty parent must be attached"); + assertEquals("parent-a", overNonEmpty.getString("a")); + } + + // --- slice 2: removal / tombstones --- + + @Test + void removingParentKeyHidesItFromChildButNotFromParent() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + + assertEquals("parent-a", child.getString("a")); // visible before removal + child.remove("a"); + + assertNull(child.getString("a")); // tombstoned: no longer reads through + assertFalse(child.containsKey("a")); + assertEquals("parent-b", child.getString("b")); // other parent keys unaffected + assertEquals("parent-a", parent.getString("a")); // frozen parent untouched + } + + @Test + void removeReturnsPriorVisibleValueViaParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + + // Map.remove contract: the key was present (via read-through), so removal reports it. + assertTrue(child.remove("a"), "removing a parent-exposed key should report it was present"); + assertNull(child.getString("a")); + } + + @Test + void reSettingARemovedKeyRestoresVisibility() { + TagMap child = TagMap.createFromParent(frozenParent()); + + child.remove("a"); + assertNull(child.getString("a")); + + child.set("a", "child-a"); // re-set clears the tombstone + assertEquals("child-a", child.getString("a")); + } + + @Test + void removingAKeyThatIsBothLocalAndParentHidesBoth() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + + assertEquals("child-b", child.getString("b")); + child.remove("b"); + + assertNull(child.getString("b"), "removal must hide both the local entry and the parent's"); + assertEquals("parent-b", frozenParent().getString("b")); // parent still has it + } + + // --- slice 3a: bulk forEach union + exact size/isEmpty --- + + private static Map collect(TagMap map) { + Map out = new HashMap<>(); + map.forEach(e -> out.put(e.tag(), e.objectValue())); + return out; + } + + @Test + void forEachEmitsDedupedUnionLocalWins() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map u = collect(child); + assertEquals(3, u.size(), "union {a, b, c} with b deduped"); + assertEquals("parent-a", u.get("a")); // read-through + assertEquals("child-b", u.get("b")); // local wins (no duplicate emit) + assertEquals("child-c", u.get("c")); + } + + @Test + void forEachSkipsTombstonedParentKeys() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + child.remove("a"); // tombstone parent's "a" + + Map u = collect(child); + assertEquals(2, u.size()); + assertFalse(u.containsKey("a")); + assertEquals("parent-b", u.get("b")); + assertEquals("child-c", u.get("c")); + } + + @Test + void biConsumerForEachAlsoEmitsUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + Map out = new HashMap<>(); + child.forEach(out, (m, e) -> m.put(e.tag(), e.objectValue())); // non-capturing: alloc-free path + assertEquals(3, out.size()); + assertEquals("parent-a", out.get("a")); + assertEquals("child-c", out.get("c")); + } + + @Test + void sizeIsExactUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows + child.set("c", "child-c"); + assertEquals(3, child.size()); // {a, b, c} — b deduped, not 4 + + child.remove("a"); + assertEquals(2, child.size()); // {b, c} + } + + @Test + void isEmptyExactWhenAllParentKeysTombstonedAndNoLocal() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + assertFalse(child.isEmpty()); + + child.remove("a"); + child.remove("b"); + assertTrue(child.isEmpty(), "all parent keys tombstoned and no local entries -> empty"); + assertEquals(0, child.size()); + } + + // --- slice 3b: pull-based iterators / collection views --- + + @Test + void iteratorEmitsDedupedUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map u = new HashMap<>(); + Iterator it = child.iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + u.put(e.tag(), e.objectValue()); + } + assertEquals(3, u.size()); + assertEquals("parent-a", u.get("a")); + assertEquals("child-b", u.get("b")); // local wins, emitted once + assertEquals("child-c", u.get("c")); + } + + @Test + void keySetReflectsUnionAndTombstones() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + Set keys = child.keySet(); + assertEquals(3, keys.size()); // a, b, c + assertTrue(keys.contains("a")); + assertTrue(keys.contains("c")); + + child.remove("a"); + assertEquals(2, child.keySet().size()); + assertFalse(child.keySet().contains("a")); + } + + @Test + void valuesAndEntrySetReflectUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + + assertEquals(2, child.entrySet().size()); // {a, b} — b deduped + assertTrue(child.values().contains("child-b")); // local-won value + assertTrue(child.values().contains("parent-a")); + assertFalse(child.values().contains("parent-b"), "shadowed parent value must not appear"); + } + + // --- slice 3c: putAll from a read-through source copies the visible union, not just locals --- + + @Test + void putAllFromReadThroughSourceCopiesFullVisibleUnion() { + TagMap source = TagMap.createFromParent(frozenParent()); // parent {a, b} + source.set("b", "child-b"); // shadows parent b + source.set("c", "child-c"); + + TagMap dest = TagMap.create(); // empty -> putAllIntoEmptyMap path + dest.putAll(source); + + // the parent-visible "a" must land too, not just source's local entries + assertEquals(3, dest.size()); + assertEquals("parent-a", dest.getString("a")); + assertEquals("child-b", dest.getString("b")); // local-won value, deduped + assertEquals("child-c", dest.getString("c")); + } + + @Test + void putAllMergeFromReadThroughSourceCopiesVisibleUnion() { + TagMap source = TagMap.createFromParent(frozenParent()); // {a, b} + + TagMap dest = TagMap.create(); + dest.set("z", "dest-z"); // dest non-empty -> putAllMerge path + + dest.putAll(source); + assertEquals(3, dest.size()); // {a, b, z} + assertEquals("parent-a", dest.getString("a")); + assertEquals("parent-b", dest.getString("b")); + assertEquals("dest-z", dest.getString("z")); + } + + @Test + void putAllFromReadThroughSourceHonorsTombstones() { + TagMap source = TagMap.createFromParent(frozenParent()); + source.remove("a"); // tombstone parent's "a" + + TagMap dest = TagMap.create(); + dest.putAll(source); + + assertEquals(1, dest.size()); + assertFalse(dest.containsKey("a"), "tombstoned key must not be copied"); + assertEquals("parent-b", dest.getString("b")); + } + + // --- slice 4: behavior-identical to a copy-down / flat map --- + + @Test + void copyIsObservationallyIdentical() { + TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + TagMap copy = child.copy(); + assertEquals(child.size(), copy.size()); + assertEquals("parent-a", copy.getString("a")); // copy still reads through + assertEquals("child-b", copy.getString("b")); + assertEquals("child-c", copy.getString("c")); + assertEquals(collect(child), collect(copy)); // same union + } + + @Test + void copyIsIndependentlyMutable() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap copy = child.copy(); + copy.set("c", "copy-c"); // mutate copy's local + copy.remove("a"); // tombstone on copy only + + assertEquals("child-c", child.getString("c"), "original unaffected by copy mutation"); + assertEquals("parent-a", child.getString("a"), "original still reads through a"); + assertEquals("copy-c", copy.getString("c")); + assertNull(copy.getString("a")); + } + + @Test + void copyPreservesTombstones() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone "a" + + TagMap copy = child.copy(); + assertNull(copy.getString("a"), "tombstone must carry into the copy"); + assertEquals("parent-b", copy.getString("b")); + } + + /** The contract that lets the consumer flip mergedTracerTags to a parent. */ + @Test + void readThroughMatchesAnEquivalentFlatMap() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); + child.set("c", "child-c"); + + TagMap flat = TagMap.create(); + flat.set("a", "parent-a"); + flat.set("b", "child-b"); + flat.set("c", "child-c"); + + assertEquals(flat.size(), child.size()); + assertEquals(collect(flat), collect(child)); + assertEquals(flat.keySet(), child.keySet()); + for (String k : new String[] {"a", "b", "c", "missing"}) { + assertEquals(flat.getString(k), child.getString(k), "mismatch for key " + k); + } + } + + @Test + void immutableCopyOfReadThroughIsFrozenAndStillReadsThrough() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap frozen = child.immutableCopy(); + assertTrue(frozen.isFrozen()); + assertEquals("parent-a", frozen.getString("a")); // union preserved + assertEquals("child-c", frozen.getString("c")); + assertThrows(IllegalStateException.class, () -> frozen.set("x", "y")); // frozen blocks writes + } + + // --- slice 5: multi-level chains (baggage-style layering over more than one frozen parent) --- + + /** + * Builds a 3-level chain leaf -> mid -> grandparent (both ancestors frozen) and returns the + * leaf. Visible union, nearest-level-wins: {a=gp-a, b=mid-b, c=leaf-c, d=mid-d, e=leaf-e}. + */ + private static TagMap threeLevelLeaf() { + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.set("b", "gp-b"); + grandparent.set("c", "gp-c"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.set("b", "mid-b"); // shadows grandparent b + mid.set("d", "mid-d"); + mid.freeze(); + + TagMap leaf = TagMap.createFromParent(mid); + leaf.set("c", "leaf-c"); // shadows grandparent c (mid doesn't define c) + leaf.set("e", "leaf-e"); + return leaf; + } + + @Test + void getWalksTheWholeChainNearestWins() { + TagMap leaf = threeLevelLeaf(); + assertEquals("gp-a", leaf.getString("a")); // only in grandparent (two levels up) + assertEquals("mid-b", leaf.getString("b")); // mid shadows grandparent + assertEquals("leaf-c", leaf.getString("c")); // leaf shadows grandparent + assertEquals("mid-d", leaf.getString("d")); + assertEquals("leaf-e", leaf.getString("e")); + assertNull(leaf.getString("missing")); + } + + @Test + void sizeIsExactUnionAcrossChain() { + assertEquals(5, threeLevelLeaf().size()); // {a, b, c, d, e}, shadowed duplicates deduped + } + + @Test + void forEachEmitsDedupedUnionAcrossChain() { + Map u = collect(threeLevelLeaf()); + assertEquals(5, u.size()); + assertEquals("gp-a", u.get("a")); + assertEquals("mid-b", u.get("b")); // nearest ancestor wins over grandparent + assertEquals("leaf-c", u.get("c")); // leaf wins + assertEquals("mid-d", u.get("d")); + assertEquals("leaf-e", u.get("e")); + } + + @Test + void iteratorEmitsDedupedUnionAcrossChain() { + Map u = new HashMap<>(); + Iterator it = threeLevelLeaf().iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + u.put(e.tag(), e.objectValue()); + } + assertEquals(5, u.size()); + assertEquals("gp-a", u.get("a")); + assertEquals("mid-b", u.get("b")); + assertEquals("leaf-c", u.get("c")); + } + + @Test + void keySetReflectsChainUnion() { + Set keys = threeLevelLeaf().keySet(); + assertEquals(5, keys.size()); + for (String k : new String[] {"a", "b", "c", "d", "e"}) { + assertTrue(keys.contains(k), "missing key " + k); + } + } + + @Test + void leafTombstoneHidesGrandparentOnlyKey() { + TagMap leaf = threeLevelLeaf(); + leaf.remove("a"); // "a" lives only in the grandparent, two levels up + assertNull(leaf.getString("a")); + assertFalse(leaf.containsKey("a")); + assertFalse(leaf.keySet().contains("a")); + assertEquals(4, leaf.size()); // {b, c, d, e} + } + + @Test + void intermediateAncestorTombstoneIsHonoredByBulkViews() { + // mid removes an inherited grandparent key, THEN is frozen and reused as a parent. A non-leaf + // level can therefore carry its own tombstones. Point lookups recurse through mid's tombstone + // (correct); the bulk views must agree and not re-emit the key. + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.set("b", "gp-b"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.remove("a"); // tombstone an inherited key before freezing + mid.freeze(); + + TagMap leaf = TagMap.createFromParent(mid); + + // point lookups (recurse -> already correct) + assertNull(leaf.getString("a")); + assertFalse(leaf.containsKey("a")); + assertEquals("gp-b", leaf.getString("b")); + + // bulk views must agree: mid's tombstone hides grandparent's "a" + assertEquals(1, leaf.size()); + assertFalse(leaf.keySet().contains("a")); + + Map viaForEach = collect(leaf); + assertEquals(1, viaForEach.size()); + assertFalse(viaForEach.containsKey("a")); + assertEquals("gp-b", viaForEach.get("b")); + + Map viaIterator = new HashMap<>(); + Iterator it = leaf.iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + viaIterator.put(e.tag(), e.objectValue()); + } + assertEquals(1, viaIterator.size()); + assertFalse(viaIterator.containsKey("a")); + } + + @Test + void observationallyEmptyIntermediateParentIsDroppedNotAttached() { + // A frozen mid level with no local entries whose every inherited key is tombstoned is + // observationally empty even though a local (grandparent) level still holds entries. + // createFromParent must drop it (exact isEmpty), so the leaf stays consistent: isEmpty() must + // agree with size()/lookup/iteration. + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.remove("a"); // tombstone the only inherited key -> mid is observationally empty + mid.freeze(); + assertTrue(mid.isEmpty()); + + TagMap leaf = TagMap.createFromParent(mid); + assertEquals(0, leaf.size()); + assertTrue(leaf.isEmpty(), "isEmpty() must agree with size()==0"); + assertNull(leaf.getString("a")); + assertTrue(collect(leaf).isEmpty()); + assertFalse(leaf.iterator().hasNext()); + } + + @Test + void chainReadThroughMatchesEquivalentFlatMap() { + TagMap leaf = threeLevelLeaf(); + + TagMap flat = TagMap.create(); + flat.set("a", "gp-a"); + flat.set("b", "mid-b"); + flat.set("c", "leaf-c"); + flat.set("d", "mid-d"); + flat.set("e", "leaf-e"); + + assertEquals(flat.size(), leaf.size()); + assertEquals(collect(flat), collect(leaf)); + assertEquals(flat.keySet(), leaf.keySet()); + for (String k : new String[] {"a", "b", "c", "d", "e", "missing"}) { + assertEquals(flat.getString(k), leaf.getString(k), "mismatch for key " + k); + } + } + + // --- slice 6: put/getAndSet report the prior visible value, including inherited --- + + @Test + void putReturnsInheritedParentValueAsPrior() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + Object prior = child.put("a", "child-a"); // "a" exists only in the parent + assertEquals("parent-a", prior, "put must report the inherited value as the previous mapping"); + assertEquals("child-a", child.getString("a")); // new value stored locally + } + + @Test + void putReturnsNullForAKeyInNeitherLocalNorParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + assertNull(child.put("brand-new", "v")); + } + + @Test + void putReturnsLocalPriorWhenShadowingParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("a", "local-a"); // local now shadows the parent + assertEquals("local-a", child.put("a", "local-a2"), "the local prior wins over the parent's"); + } + + @Test + void putAfterRemoveReportsNoPriorNotTheParentValue() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone the parent's "a": no longer visible + assertNull(child.put("a", "child-a"), "a tombstoned key had no visible prior value"); + assertEquals("child-a", child.getString("a")); + } + + @Test + void getAndSetReturnsInheritedEntryAsPrior() { + TagMap child = TagMap.createFromParent(frozenParent()); + TagMap.Entry prior = child.getAndSet("b", "child-b"); + assertEquals("parent-b", prior.objectValue()); + } + + @Test + void setDoesNotReportPriorButStillClearsTombstone() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("b"); // tombstone + child.set("b", "child-b"); // void set: no prior lookup, but must clear the tombstone + assertEquals("child-b", child.getString("b")); + } + + // --- slice 7: clear() removes inherited mappings too (detaches the parent) --- + + @Test + void clearRemovesInheritedMappingsAndDetachesParent() { + TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + child.set("c", "child-c"); + + child.clear(); + + assertTrue(child.isEmpty(), "clear must remove local AND inherited mappings"); + assertEquals(0, child.size()); + assertNull(child.getString("a")); // inherited no longer visible + assertNull(child.getString("c")); + assertFalse(child.containsKey("a")); + } + + @Test + void clearDoesNotAffectTheFrozenParent() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + child.clear(); + assertEquals("parent-a", parent.getString("a"), "the shared frozen parent is untouched"); + } + + @Test + void putAfterClearBehavesAsAPlainMap() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.clear(); + child.set("x", "x-val"); + assertEquals(1, child.size()); + assertEquals("x-val", child.getString("x")); + assertNull(child.getString("a"), "no read-through after clear detached the parent"); + } + + // --- slice 8: fillMap/fillStringMap materialize the visible union, not just local entries --- + + private static Map fillMapCollect(TagMap map) { + Map out = new HashMap<>(); + map.fillMap(out); + return out; + } + + private static Map fillStringMapCollect(TagMap map) { + Map out = new HashMap<>(); + map.fillStringMap(out); + return out; + } + + @Test + void fillMapMaterializesDedupedUnionLocalWins() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map out = fillMapCollect(child); + assertEquals(3, out.size(), "union {a, b, c} with b deduped"); + assertEquals("parent-a", out.get("a")); // inherited tag must not be dropped + assertEquals("child-b", out.get("b")); // local wins + assertEquals("child-c", out.get("c")); + } + + @Test + void fillMapHonorsTombstonedParentKeys() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + child.remove("a"); // tombstone parent's "a" + + Map out = fillMapCollect(child); + assertEquals(2, out.size()); + assertFalse(out.containsKey("a")); + assertEquals("parent-b", out.get("b")); + assertEquals("child-c", out.get("c")); + } + + @Test + void fillStringMapMaterializesDedupedUnionLocalWins() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map out = fillStringMapCollect(child); + assertEquals(3, out.size()); + assertEquals("parent-a", out.get("a")); // inherited tag preserved + assertEquals("child-b", out.get("b")); + assertEquals("child-c", out.get("c")); + } + + @Test + void fillStringMapHonorsTombstonedParentKeys() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone parent's "a" + + Map out = fillStringMapCollect(child); + assertEquals(1, out.size()); + assertFalse(out.containsKey("a")); + assertEquals("parent-b", out.get("b")); + } + + @Test + void fillMapAcrossChainMatchesEquivalentFlatMap() { + TagMap leaf = threeLevelLeaf(); + + TagMap flat = TagMap.create(); + flat.set("a", "gp-a"); + flat.set("b", "mid-b"); + flat.set("c", "leaf-c"); + flat.set("d", "mid-d"); + flat.set("e", "leaf-e"); + + assertEquals(fillMapCollect(flat), fillMapCollect(leaf)); + assertEquals(fillStringMapCollect(flat), fillStringMapCollect(leaf)); + } + + @Test + void fillMapWithoutParentFillsOnlyLocalEntries() { + // regression: the no-parent fast path is unchanged + TagMap flat = TagMap.create(); + flat.set("x", "x-val"); + flat.set("y", "y-val"); + + Map out = fillMapCollect(flat); + assertEquals(2, out.size()); + assertEquals("x-val", out.get("x")); + assertEquals("y-val", out.get("y")); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java b/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java index 1c07b31c563..56bf99f06d0 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java @@ -1,27 +1,27 @@ package datadog.trace.api; public enum TagMapScenario { - OPTIMIZED_EMPTY(0), - OPTIMIZED_XSMALL(5), - OPTIMIZED_SMALL(10), - OPTIMIZED_MEDIUM(25), - OPTIMIZED_LARGE(125); + OPTIMIZED_EMPTY(0), + OPTIMIZED_XSMALL(5), + OPTIMIZED_SMALL(10), + OPTIMIZED_MEDIUM(25), + OPTIMIZED_LARGE(125); - final int size; + final int size; - TagMapScenario(int size) { - this.size = size; - } + TagMapScenario(int size) { + this.size = size; + } - public final int size() { - return this.size; - } + public final int size() { + return this.size; + } - public final TagMap create() { - TagMap map = TagMap.create(); - for (int i = 0; i < this.size; ++i) { - map.put("filler-key-" + i, "filler-value-" + i); + public final TagMap create() { + TagMap map = TagMap.create(); + for (int i = 0; i < this.size; ++i) { + map.put("filler-key-" + i, "filler-value-" + i); + } + return map; } - return map; - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapTest.java index 9a72b7bdd60..30600bcde8c 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapTest.java @@ -22,1086 +22,1082 @@ import org.junit.jupiter.params.provider.ValueSource; public class TagMapTest { - // size is chosen to make sure to stress all types of collisions in the Map - static final int MANY_SIZE = 256; + // size is chosen to make sure to stress all types of collisions in the Map + static final int MANY_SIZE = 256; - // static function tests - mostly exist to satisfy coverage checker - @Test - public void fromMap_emptyMap() { - Map emptyMap = Collections.emptyMap(); + // static function tests - mostly exist to satisfy coverage checker + @Test + public void fromMap_emptyMap() { + Map emptyMap = Collections.emptyMap(); - TagMap tagMap = TagMap.fromMap(emptyMap); - assertEquals(0, tagMap.size()); - assertTrue(tagMap.isEmpty()); + TagMap tagMap = TagMap.fromMap(emptyMap); + assertEquals(0, tagMap.size()); + assertTrue(tagMap.isEmpty()); - assertFalse(tagMap.isFrozen()); - } + assertFalse(tagMap.isFrozen()); + } - @Test - public void fromMap_nonEmptyMap() { - // mostly exists to satisfy coverage checker - HashMap origMap = new HashMap<>(); - origMap.put("foo", "bar"); - origMap.put("baz", "quux"); + @Test + public void fromMap_nonEmptyMap() { + // mostly exists to satisfy coverage checker + HashMap origMap = new HashMap<>(); + origMap.put("foo", "bar"); + origMap.put("baz", "quux"); - TagMap tagMap = TagMap.fromMap(origMap); - assertEquals(tagMap.size(), origMap.size()); - - assertEquals(tagMap.get("foo"), origMap.get("foo")); - assertEquals(tagMap.get("baz"), origMap.get("baz")); - - assertFalse(tagMap.isFrozen()); - } + TagMap tagMap = TagMap.fromMap(origMap); + assertEquals(tagMap.size(), origMap.size()); - @Test - public void fromMapImmutable_empty() { - Map emptyMap = Collections.emptyMap(); - - TagMap tagMap = TagMap.fromMapImmutable(emptyMap); - assertEquals(0, tagMap.size()); - assertTrue(tagMap.isEmpty()); - - assertTrue(tagMap.isFrozen()); - } - - @Test - public void fromMapImmutable_nonEmptyMap() { - // mostly exists to satisfy coverage checker - HashMap origMap = new HashMap<>(); - origMap.put("foo", "bar"); - origMap.put("baz", "quux"); - - TagMap tagMap = TagMap.fromMapImmutable(origMap); - assertEquals(tagMap.size(), origMap.size()); - - assertEquals(tagMap.get("foo"), origMap.get("foo")); - assertEquals(tagMap.get("baz"), origMap.get("baz")); - - assertTrue(tagMap.isFrozen()); - } - - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_put(TagMapScenario scenario) { - TagMap map = scenario.create(); - - Object prev = map.put("foo", "bar"); - assertNull(prev); - - assertEntry("foo", "bar", map); - - assertSize(scenario.size() + 1, map); - assertNotEmpty(map); - - checkIntegrity(map); - } - - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void booleanEntry(TagMapScenario scenario) { - String BOOL = "bool"; - String UNSET = "unset"; - - boolean first = false; - boolean second = true; - - TagMap map = scenario.create(); - map.set(BOOL, first); - - TagMap.Entry firstEntry = map.getEntry(BOOL); - assertEquals(TagMap.Entry.BOOLEAN, firstEntry.rawType); - - assertEquals(first, firstEntry.booleanValue()); - assertEquals(first, map.getBoolean(BOOL)); - - TagMap.Entry priorEntry = map.getAndSet(BOOL, second); - assertSame(priorEntry, firstEntry); - assertEquals(first, priorEntry.booleanValue()); - - TagMap.Entry newEntry = map.getEntry(BOOL); - assertEquals(second, newEntry.booleanValue()); - - assertEquals(false, map.getBoolean(UNSET)); - assertEquals(true, map.getBooleanOrDefault(UNSET, true)); - - checkIntegrity(map); - } - - @Test - public void numericZeroToBooleanCoercion() { - TagMap map = - TagMap.ledger() - .set("int", 0) - .set("intObj", Integer.valueOf(0)) - .set("long", 0L) - .set("longObj", Long.valueOf(0L)) - .set("float", 0F) - .set("floatObj", Float.valueOf(0F)) - .set("double", 0D) - .set("doubleObj", Double.valueOf(0D)) - .build(); - - assertBoolean(false, map, "int"); - assertBoolean(false, map, "intObj"); - assertBoolean(false, map, "long"); - assertBoolean(false, map, "longObj"); - assertBoolean(false, map, "float"); - assertBoolean(false, map, "floatObj"); - assertBoolean(false, map, "double"); - assertBoolean(false, map, "doubleObj"); - - checkIntegrity(map); - } - - @Test - public void numericNonZeroToBooleanCoercion() { - TagMap map = - TagMap.ledger() - .set("int", 1) - .set("intObj", Integer.valueOf(1)) - .set("long", 1L) - .set("longObj", Long.valueOf(1L)) - .set("float", 1F) - .set("floatObj", Float.valueOf(1F)) - .set("double", 1D) - .set("doubleObj", Double.valueOf(1D)) - .build(); - - assertBoolean(true, map, "int"); - assertBoolean(true, map, "intObj"); - assertBoolean(true, map, "long"); - assertBoolean(true, map, "longObj"); - assertBoolean(true, map, "float"); - assertBoolean(true, map, "floatObj"); - assertBoolean(true, map, "double"); - assertBoolean(true, map, "doubleObj"); - - checkIntegrity(map); - } - - @Test - public void objectToBooleanCoercion() { - TagMap map = - TagMap.ledger() - .set("obj", new Object()) - .set("trueStr", "true") - .set("falseStr", "false") - .build(); - - assertBoolean(true, map, "obj"); - assertBoolean(true, map, "trueStr"); - assertBoolean(true, map, "falseStr"); - - checkIntegrity(map); - } - - @Test - public void booleanToNumericCoercion_true() { - TagMap map = TagMap.ledger().set("true", true).build(); - - assertInt(1, map, "true"); - assertLong(1L, map, "true"); - assertFloat(1F, map, "true"); - assertDouble(1D, map, "true"); - - checkIntegrity(map); - } - - @Test - public void booleanToNumericCoercion_false() { - TagMap map = TagMap.ledger().set("false", false).build(); - - assertInt(0, map, "false"); - assertLong(0L, map, "false"); - assertFloat(0F, map, "false"); - assertDouble(0D, map, "false"); - - checkIntegrity(map); - } - - @Test - public void emptyToPrimitiveCoercion() { - TagMap map = TagMap.EMPTY; - - // DQH - assert helpers also check getOrDefault, so they don't work here - assertEquals(false, map.getBoolean("dne")); - assertEquals(0, map.getInt("dne")); - assertEquals(0L, map.getLong("dne")); - assertEquals(0F, map.getFloat("dne")); - assertEquals(0D, map.getDouble("dne")); - - checkIntegrity(map); - } - - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void intEntry(TagMapScenario scenario) { - String INT = "int"; - String UNSET = "unset"; - - int first = 3142; - int second = 2718; - - TagMap map = scenario.create(); - map.set(INT, first); - - TagMap.Entry firstEntry = map.getEntry("int"); - assertEquals(TagMap.Entry.INT, firstEntry.rawType); - - assertEquals(first, firstEntry.intValue()); - assertEquals(first, map.getInt("int")); - - TagMap.Entry priorEntry = map.getAndSet("int", second); - assertSame(priorEntry, firstEntry); - assertEquals(first, priorEntry.intValue()); - - TagMap.Entry newEntry = map.getEntry("int"); - assertEquals(second, newEntry.intValue()); - assertEquals(0, map.getInt(UNSET)); - assertEquals(21, map.getIntOrDefault(UNSET, 21)); + assertEquals(tagMap.get("foo"), origMap.get("foo")); + assertEquals(tagMap.get("baz"), origMap.get("baz")); - checkIntegrity(map); - } + assertFalse(tagMap.isFrozen()); + } - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void longEntry(TagMapScenario scenario) { - String LONG = "long"; - String UNSET = "unset"; + @Test + public void fromMapImmutable_empty() { + Map emptyMap = Collections.emptyMap(); - long first = 3142L; - long second = 2718L; + TagMap tagMap = TagMap.fromMapImmutable(emptyMap); + assertEquals(0, tagMap.size()); + assertTrue(tagMap.isEmpty()); - TagMap map = scenario.create(); - map.set(LONG, first); + assertTrue(tagMap.isFrozen()); + } - TagMap.Entry firstEntry = map.getEntry("long"); - assertEquals(TagMap.Entry.LONG, firstEntry.rawType); + @Test + public void fromMapImmutable_nonEmptyMap() { + // mostly exists to satisfy coverage checker + HashMap origMap = new HashMap<>(); + origMap.put("foo", "bar"); + origMap.put("baz", "quux"); - assertEquals(first, firstEntry.longValue()); - assertEquals(first, map.getLong("long")); + TagMap tagMap = TagMap.fromMapImmutable(origMap); + assertEquals(tagMap.size(), origMap.size()); - TagMap.Entry priorEntry = map.getAndSet("long", second); - assertSame(priorEntry, firstEntry); - assertEquals(first, priorEntry.longValue()); + assertEquals(tagMap.get("foo"), origMap.get("foo")); + assertEquals(tagMap.get("baz"), origMap.get("baz")); - TagMap.Entry newEntry = map.getEntry("long"); - assertEquals(second, newEntry.longValue()); + assertTrue(tagMap.isFrozen()); + } - assertEquals(0L, map.getLong(UNSET)); - assertEquals(21L, map.getLongOrDefault(UNSET, 21L)); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void map_put(TagMapScenario scenario) { + TagMap map = scenario.create(); - checkIntegrity(map); - } + Object prev = map.put("foo", "bar"); + assertNull(prev); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void floatEntry(TagMapScenario scenario) { - String FLOAT = "float"; - String UNSET = "unset"; + assertEntry("foo", "bar", map); - float first = 3.14F; - float second = 2.718F; + assertSize(scenario.size() + 1, map); + assertNotEmpty(map); - TagMap map = scenario.create(); - map.set(FLOAT, first); + checkIntegrity(map); + } - TagMap.Entry firstEntry = map.getEntry(FLOAT); - assertEquals(TagMap.Entry.FLOAT, firstEntry.rawType); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void booleanEntry(TagMapScenario scenario) { + String BOOL = "bool"; + String UNSET = "unset"; - assertEquals(first, firstEntry.floatValue()); - assertEquals(first, map.getFloat(FLOAT)); + boolean first = false; + boolean second = true; - TagMap.Entry priorEntry = map.getAndSet(FLOAT, second); - assertSame(priorEntry, firstEntry); - assertEquals(first, priorEntry.floatValue()); + TagMap map = scenario.create(); + map.set(BOOL, first); - TagMap.Entry newEntry = map.getEntry(FLOAT); - assertEquals(second, newEntry.floatValue()); + TagMap.Entry firstEntry = map.getEntry(BOOL); + assertEquals(TagMap.Entry.BOOLEAN, firstEntry.rawType); - assertEquals(0F, map.getFloat(UNSET)); - assertEquals(2.718F, map.getFloatOrDefault(UNSET, 2.718F)); + assertEquals(first, firstEntry.booleanValue()); + assertEquals(first, map.getBoolean(BOOL)); - checkIntegrity(map); - } + TagMap.Entry priorEntry = map.getAndSet(BOOL, second); + assertSame(priorEntry, firstEntry); + assertEquals(first, priorEntry.booleanValue()); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void doubleEntry(TagMapScenario scenario) { - String DOUBLE = "double"; - String UNSET = "unset"; + TagMap.Entry newEntry = map.getEntry(BOOL); + assertEquals(second, newEntry.booleanValue()); - double first = Math.PI; - double second = Math.E; + assertEquals(false, map.getBoolean(UNSET)); + assertEquals(true, map.getBooleanOrDefault(UNSET, true)); - TagMap map = scenario.create(); - map.set(DOUBLE, Math.PI); + checkIntegrity(map); + } - TagMap.Entry firstEntry = map.getEntry(DOUBLE); - assertEquals(TagMap.Entry.DOUBLE, firstEntry.rawType); + @Test + public void numericZeroToBooleanCoercion() { + TagMap map = TagMap.ledger() + .set("int", 0) + .set("intObj", Integer.valueOf(0)) + .set("long", 0L) + .set("longObj", Long.valueOf(0L)) + .set("float", 0F) + .set("floatObj", Float.valueOf(0F)) + .set("double", 0D) + .set("doubleObj", Double.valueOf(0D)) + .build(); + + assertBoolean(false, map, "int"); + assertBoolean(false, map, "intObj"); + assertBoolean(false, map, "long"); + assertBoolean(false, map, "longObj"); + assertBoolean(false, map, "float"); + assertBoolean(false, map, "floatObj"); + assertBoolean(false, map, "double"); + assertBoolean(false, map, "doubleObj"); + + checkIntegrity(map); + } - assertEquals(first, firstEntry.doubleValue()); - assertEquals(first, map.getDouble(DOUBLE)); + @Test + public void numericNonZeroToBooleanCoercion() { + TagMap map = TagMap.ledger() + .set("int", 1) + .set("intObj", Integer.valueOf(1)) + .set("long", 1L) + .set("longObj", Long.valueOf(1L)) + .set("float", 1F) + .set("floatObj", Float.valueOf(1F)) + .set("double", 1D) + .set("doubleObj", Double.valueOf(1D)) + .build(); + + assertBoolean(true, map, "int"); + assertBoolean(true, map, "intObj"); + assertBoolean(true, map, "long"); + assertBoolean(true, map, "longObj"); + assertBoolean(true, map, "float"); + assertBoolean(true, map, "floatObj"); + assertBoolean(true, map, "double"); + assertBoolean(true, map, "doubleObj"); + + checkIntegrity(map); + } - TagMap.Entry priorEntry = map.getAndSet(DOUBLE, second); - assertSame(priorEntry, firstEntry); - assertEquals(first, priorEntry.doubleValue()); + @Test + public void objectToBooleanCoercion() { + TagMap map = TagMap.ledger() + .set("obj", new Object()) + .set("trueStr", "true") + .set("falseStr", "false") + .build(); - TagMap.Entry newEntry = map.getEntry(DOUBLE); - assertEquals(second, newEntry.doubleValue()); + assertBoolean(true, map, "obj"); + assertBoolean(true, map, "trueStr"); + assertBoolean(true, map, "falseStr"); - assertEquals(0D, map.getDouble(UNSET)); - assertEquals(2.718D, map.getDoubleOrDefault(UNSET, 2.718D)); + checkIntegrity(map); + } - checkIntegrity(map); - } + @Test + public void booleanToNumericCoercion_true() { + TagMap map = TagMap.ledger().set("true", true).build(); - @Test - public void empty() { - TagMap empty = TagMap.EMPTY; - assertFrozen(empty); + assertInt(1, map, "true"); + assertLong(1L, map, "true"); + assertFloat(1F, map, "true"); + assertDouble(1D, map, "true"); - assertNull(empty.getEntry("foo")); - assertSize(0, empty); - assertEmpty(empty); + checkIntegrity(map); + } - checkIntegrity(empty); - } + @Test + public void booleanToNumericCoercion_false() { + TagMap map = TagMap.ledger().set("false", false).build(); - @Test - public void putAll_empty() { - // TagMap.EMPTY breaks the rules and uses a different size bucket array - // This test is just to verify that the common use of putAll still works with EMPTY - TagMap newMap = TagMap.create(); - newMap.putAll(TagMap.EMPTY); + assertInt(0, map, "false"); + assertLong(0L, map, "false"); + assertFloat(0F, map, "false"); + assertDouble(0D, map, "false"); - assertSize(0, newMap); - assertEmpty(newMap); + checkIntegrity(map); + } - checkIntegrity(newMap); - } + @Test + public void emptyToPrimitiveCoercion() { + TagMap map = TagMap.EMPTY; - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void clear(TagMapScenario scenario) { - TagMap map = scenario.create(); - assertSize(scenario.size(), map); + // DQH - assert helpers also check getOrDefault, so they don't work here + assertEquals(false, map.getBoolean("dne")); + assertEquals(0, map.getInt("dne")); + assertEquals(0L, map.getLong("dne")); + assertEquals(0F, map.getFloat("dne")); + assertEquals(0D, map.getDouble("dne")); - assertEmptiness(scenario.size() == 0, map); + checkIntegrity(map); + } - map.clear(); - assertSize(0, map); - assertEmpty(map); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void intEntry(TagMapScenario scenario) { + String INT = "int"; + String UNSET = "unset"; - checkIntegrity(map); - } + int first = 3142; + int second = 2718; - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_put_replacement(TagMapScenario scenario) { - TagMap map = scenario.create(); - Object prev1 = map.put("foo", "bar"); - assertNull(prev1); + TagMap map = scenario.create(); + map.set(INT, first); - assertEntry("foo", "bar", map); - assertSize(scenario.size() + 1, map); - assertNotEmpty(map); + TagMap.Entry firstEntry = map.getEntry("int"); + assertEquals(TagMap.Entry.INT, firstEntry.rawType); - Object prev2 = map.put("foo", "baz"); - assertSize(scenario.size() + 1, map); - assertEquals("bar", prev2); + assertEquals(first, firstEntry.intValue()); + assertEquals(first, map.getInt("int")); - assertEntry("foo", "baz", map); + TagMap.Entry priorEntry = map.getAndSet("int", second); + assertSame(priorEntry, firstEntry); + assertEquals(first, priorEntry.intValue()); - checkIntegrity(map); - } + TagMap.Entry newEntry = map.getEntry("int"); + assertEquals(second, newEntry.intValue()); + assertEquals(0, map.getInt(UNSET)); + assertEquals(21, map.getIntOrDefault(UNSET, 21)); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_remove_Object(TagMapScenario scenario) { - TagMap map = scenario.create(); + checkIntegrity(map); + } - Object prev = map.remove((Object) "foo"); - assertNull(prev); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void longEntry(TagMapScenario scenario) { + String LONG = "long"; + String UNSET = "unset"; - map.put("foo", "bar"); - assertEntry("foo", "bar", map); - assertSize(scenario.size() + 1, map); - assertNotEmpty(map); + long first = 3142L; + long second = 2718L; - Object prev2 = map.remove((Object) "foo"); - assertEquals("bar", prev2); - assertSize(scenario.size(), map); - assertEmptiness(scenario, map); + TagMap map = scenario.create(); + map.set(LONG, first); - checkIntegrity(map); - } + TagMap.Entry firstEntry = map.getEntry("long"); + assertEquals(TagMap.Entry.LONG, firstEntry.rawType); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_remove_String(TagMapScenario scenario) { - TagMap map = scenario.create(); + assertEquals(first, firstEntry.longValue()); + assertEquals(first, map.getLong("long")); - boolean hadPrev1 = map.remove("foo"); - assertFalse(hadPrev1); + TagMap.Entry priorEntry = map.getAndSet("long", second); + assertSame(priorEntry, firstEntry); + assertEquals(first, priorEntry.longValue()); - map.put("foo", "bar"); - assertEntry("foo", "bar", map); - assertSize(scenario.size() + 1, map); - assertNotEmpty(map); + TagMap.Entry newEntry = map.getEntry("long"); + assertEquals(second, newEntry.longValue()); - boolean hadPrev2 = map.remove("foo"); - assertTrue(hadPrev2); - assertSize(scenario.size(), map); - assertEmptiness(scenario, map); + assertEquals(0L, map.getLong(UNSET)); + assertEquals(21L, map.getLongOrDefault(UNSET, 21L)); - checkIntegrity(map); - } + checkIntegrity(map); + } - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_getAndRemove(TagMapScenario scenario) { - TagMap map = scenario.create(); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void floatEntry(TagMapScenario scenario) { + String FLOAT = "float"; + String UNSET = "unset"; - TagMap.Entry prevEntry1 = map.getAndRemove("foo"); - assertNull(prevEntry1); + float first = 3.14F; + float second = 2.718F; - map.put("foo", "bar"); - assertEntry("foo", "bar", map); - assertSize(scenario.size() + 1, map); - assertNotEmpty(map); + TagMap map = scenario.create(); + map.set(FLOAT, first); - TagMap.Entry prevEntry2 = map.getAndRemove("foo"); - assertEquals("bar", prevEntry2.objectValue()); - assertSize(scenario.size(), map); - assertEmptiness(scenario, map); + TagMap.Entry firstEntry = map.getEntry(FLOAT); + assertEquals(TagMap.Entry.FLOAT, firstEntry.rawType); - checkIntegrity(map); - } + assertEquals(first, firstEntry.floatValue()); + assertEquals(first, map.getFloat(FLOAT)); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void freeze(TagMapScenario scenario) { - TagMap map = scenario.create(); - map.put("foo", "bar"); + TagMap.Entry priorEntry = map.getAndSet(FLOAT, second); + assertSame(priorEntry, firstEntry); + assertEquals(first, priorEntry.floatValue()); - assertEntry("foo", "bar", map); + TagMap.Entry newEntry = map.getEntry(FLOAT); + assertEquals(second, newEntry.floatValue()); - map.freeze(); + assertEquals(0F, map.getFloat(UNSET)); + assertEquals(2.718F, map.getFloatOrDefault(UNSET, 2.718F)); - assertFrozen( - () -> { - map.remove("foo"); - }); + checkIntegrity(map); + } - assertEntry("foo", "bar", map); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void doubleEntry(TagMapScenario scenario) { + String DOUBLE = "double"; + String UNSET = "unset"; - checkIntegrity(map); - } + double first = Math.PI; + double second = Math.E; - @Test - public void emptyMap() { - TagMap map = TagMap.EMPTY; + TagMap map = scenario.create(); + map.set(DOUBLE, Math.PI); - assertSize(0, map); - assertEmpty(map); + TagMap.Entry firstEntry = map.getEntry(DOUBLE); + assertEquals(TagMap.Entry.DOUBLE, firstEntry.rawType); - assertFrozen(map); + assertEquals(first, firstEntry.doubleValue()); + assertEquals(first, map.getDouble(DOUBLE)); - checkIntegrity(map); - } + TagMap.Entry priorEntry = map.getAndSet(DOUBLE, second); + assertSame(priorEntry, firstEntry); + assertEquals(first, priorEntry.doubleValue()); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void putMany(TagMapScenario scenario) { - TagMap map = scenario.create(); + TagMap.Entry newEntry = map.getEntry(DOUBLE); + assertEquals(second, newEntry.doubleValue()); - int size = scenario.size(); - fillMap(map, size); + assertEquals(0D, map.getDouble(UNSET)); + assertEquals(2.718D, map.getDoubleOrDefault(UNSET, 2.718D)); - for (int i = 0; i < size; ++i) { - assertEntry(key(i), value(i), map); + checkIntegrity(map); } - assertEmptiness(scenario, map); - assertSize(scenario.size() + size, map); + @Test + public void empty() { + TagMap empty = TagMap.EMPTY; + assertFrozen(empty); - checkIntegrity(map); - } + assertNull(empty.getEntry("foo")); + assertSize(0, empty); + assertEmpty(empty); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void copyMany(TagMapScenario scenario) { - int size = scenario.size(); - TagMap orig = scenario.create(); - fillMap(orig, size); + checkIntegrity(empty); + } - assertSize(scenario.size() + size, orig); + @Test + public void putAll_empty() { + // TagMap.EMPTY breaks the rules and uses a different size bucket array + // This test is just to verify that the common use of putAll still works with EMPTY + TagMap newMap = TagMap.create(); + newMap.putAll(TagMap.EMPTY); - TagMap copy = orig.copy(); - orig.clear(); // doing this to make sure that copied isn't modified + assertSize(0, newMap); + assertEmpty(newMap); - for (int i = 0; i < size; ++i) { - assertEntry(key(i), value(i), copy); + checkIntegrity(newMap); } - assertSize(scenario.size() + size, copy); - checkIntegrity(orig); - checkIntegrity(copy); - } + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void clear(TagMapScenario scenario) { + TagMap map = scenario.create(); + assertSize(scenario.size(), map); - @Test - public void immutableCopy() { - int size = randomSize(); - TagMap orig = createTagMap(size); + assertEmptiness(scenario.size() == 0, map); - TagMap immutableCopy = orig.immutableCopy(); - orig.clear(); // doing this to make sure that copied isn't modified + map.clear(); + assertSize(0, map); + assertEmpty(map); - for (int i = 0; i < size; ++i) { - assertEntry(key(i), value(i), immutableCopy); + checkIntegrity(map); } - assertSize(size, immutableCopy); - assertFrozen(immutableCopy); - checkIntegrity(orig); - checkIntegrity(immutableCopy); - } + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void map_put_replacement(TagMapScenario scenario) { + TagMap map = scenario.create(); + Object prev1 = map.put("foo", "bar"); + assertNull(prev1); - @Test - public void replaceALot() { - int size = randomSize(); - TagMap map = createTagMap(size); + assertEntry("foo", "bar", map); + assertSize(scenario.size() + 1, map); + assertNotEmpty(map); - for (int i = 0; i < size; ++i) { - int index = ThreadLocalRandom.current().nextInt(size); + Object prev2 = map.put("foo", "baz"); + assertSize(scenario.size() + 1, map); + assertEquals("bar", prev2); - map.put(key(index), altValue(index)); - assertEquals(altValue(index), map.get(key(index))); + assertEntry("foo", "baz", map); + + checkIntegrity(map); } - checkIntegrity(map); - } + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void map_remove_Object(TagMapScenario scenario) { + TagMap map = scenario.create(); + + Object prev = map.remove((Object) "foo"); + assertNull(prev); - @Test - public void shareEntry() { - TagMap orig = TagMap.create(); - orig.set("foo", "bar"); + map.put("foo", "bar"); + assertEntry("foo", "bar", map); + assertSize(scenario.size() + 1, map); + assertNotEmpty(map); + + Object prev2 = map.remove((Object) "foo"); + assertEquals("bar", prev2); + assertSize(scenario.size(), map); + assertEmptiness(scenario, map); + + checkIntegrity(map); + } - TagMap dest = TagMap.create(); - dest.set(orig.getEntry("foo")); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void map_remove_String(TagMapScenario scenario) { + TagMap map = scenario.create(); - assertEquals(orig.getEntry("foo"), dest.getEntry("foo")); - assertSame(orig.getEntry("foo"), dest.getEntry("foo")); + boolean hadPrev1 = map.remove("foo"); + assertFalse(hadPrev1); - checkIntegrity(orig); - checkIntegrity(dest); - } + map.put("foo", "bar"); + assertEntry("foo", "bar", map); + assertSize(scenario.size() + 1, map); + assertNotEmpty(map); - @Test - public void putAll_clobberAll() { - int size = randomSize(); - TagMap orig = createTagMap(size); - assertSize(size, orig); + boolean hadPrev2 = map.remove("foo"); + assertTrue(hadPrev2); + assertSize(scenario.size(), map); + assertEmptiness(scenario, map); - TagMap dest = TagMap.create(); - for (int i = size - 1; i >= 0; --i) { - dest.set(key(i), altValue(i)); + checkIntegrity(map); } - // This should clobber all the values in dest - dest.putAll(orig); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void map_getAndRemove(TagMapScenario scenario) { + TagMap map = scenario.create(); - for (int i = 0; i < size; ++i) { - assertEntry(key(i), value(i), dest); + TagMap.Entry prevEntry1 = map.getAndRemove("foo"); + assertNull(prevEntry1); + + map.put("foo", "bar"); + assertEntry("foo", "bar", map); + assertSize(scenario.size() + 1, map); + assertNotEmpty(map); + + TagMap.Entry prevEntry2 = map.getAndRemove("foo"); + assertEquals("bar", prevEntry2.objectValue()); + assertSize(scenario.size(), map); + assertEmptiness(scenario, map); + + checkIntegrity(map); } - assertSize(size, dest); - checkIntegrity(orig); - checkIntegrity(dest); - } + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void freeze(TagMapScenario scenario) { + TagMap map = scenario.create(); + map.put("foo", "bar"); - @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void putAll_cloberAll(TagMapScenario scenario) { - int size = scenario.size(); - TagMap orig = scenario.create(); - assertSize(size, orig); + assertEntry("foo", "bar", map); - TagMap dest = scenario.create(); - assertSize(size, dest); + map.freeze(); - dest.putAll(orig); + assertFrozen(() -> { + map.remove("foo"); + }); + + assertEntry("foo", "bar", map); + + checkIntegrity(map); + } - assertSize(size, orig); - assertSize(size, dest); + @Test + public void emptyMap() { + TagMap map = TagMap.EMPTY; - checkIntegrity(orig); - checkIntegrity(dest); - } + assertSize(0, map); + assertEmpty(map); - @Test - public void putAll_clobberAndExtras() { - int size = randomSize(); - TagMap orig = createTagMap(size); - assertSize(size, orig); + assertFrozen(map); - TagMap dest = TagMap.create(); - for (int i = size / 2 - 1; i >= 0; --i) { - dest.set(key(i), altValue(i)); + checkIntegrity(map); } - // This should clobber all the values in dest - dest.putAll(orig); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void putMany(TagMapScenario scenario) { + TagMap map = scenario.create(); - for (int i = 0; i < size; ++i) { - assertEntry(key(i), value(i), dest); + int size = scenario.size(); + fillMap(map, size); + + for (int i = 0; i < size; ++i) { + assertEntry(key(i), value(i), map); + } + + assertEmptiness(scenario, map); + assertSize(scenario.size() + size, map); + + checkIntegrity(map); } - assertSize(size, dest); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void copyMany(TagMapScenario scenario) { + int size = scenario.size(); + TagMap orig = scenario.create(); + fillMap(orig, size); + + assertSize(scenario.size() + size, orig); - checkIntegrity(orig); - checkIntegrity(dest); - } + TagMap copy = orig.copy(); + orig.clear(); // doing this to make sure that copied isn't modified - @Test - public void removeMany() { - int size = randomSize(); - TagMap map = createTagMap(size); + for (int i = 0; i < size; ++i) { + assertEntry(key(i), value(i), copy); + } + assertSize(scenario.size() + size, copy); - for (int i = 0; i < size; ++i) { - assertEntry(key(i), value(i), map); + checkIntegrity(orig); + checkIntegrity(copy); } - assertNotEmpty(map); - assertSize(size, map); + @Test + public void immutableCopy() { + int size = randomSize(); + TagMap orig = createTagMap(size); - for (int i = 0; i < size; ++i) { - Object removedValue = map.remove((Object) key(i)); - assertEquals(value(i), removedValue); + TagMap immutableCopy = orig.immutableCopy(); + orig.clear(); // doing this to make sure that copied isn't modified - // not doing exhaustive size checks - assertEquals(size - i - 1, map.size()); + for (int i = 0; i < size; ++i) { + assertEntry(key(i), value(i), immutableCopy); + } + assertSize(size, immutableCopy); + assertFrozen(immutableCopy); + + checkIntegrity(orig); + checkIntegrity(immutableCopy); } - assertEmpty(map); + @Test + public void replaceALot() { + int size = randomSize(); + TagMap map = createTagMap(size); + + for (int i = 0; i < size; ++i) { + int index = ThreadLocalRandom.current().nextInt(size); - checkIntegrity(map); - } + map.put(key(index), altValue(index)); + assertEquals(altValue(index), map.get(key(index))); + } - @Test - public void fillMap() { - int size = randomSize(); - TagMap map = TagMap.create(); - for (int i = 0; i < size; ++i) { - map.set(key(i), i); + checkIntegrity(map); } - HashMap hashMap = new HashMap<>(); - map.fillMap(hashMap); + @Test + public void shareEntry() { + TagMap orig = TagMap.create(); + orig.set("foo", "bar"); + + TagMap dest = TagMap.create(); + dest.set(orig.getEntry("foo")); - for (int i = 0; i < size; ++i) { - assertEquals(Integer.valueOf(i), hashMap.remove(key(i))); + assertEquals(orig.getEntry("foo"), dest.getEntry("foo")); + assertSame(orig.getEntry("foo"), dest.getEntry("foo")); + + checkIntegrity(orig); + checkIntegrity(dest); } - assertTrue(hashMap.isEmpty()); - checkIntegrity(map); - } + @Test + public void putAll_clobberAll() { + int size = randomSize(); + TagMap orig = createTagMap(size); + assertSize(size, orig); + + TagMap dest = TagMap.create(); + for (int i = size - 1; i >= 0; --i) { + dest.set(key(i), altValue(i)); + } + + // This should clobber all the values in dest + dest.putAll(orig); + + for (int i = 0; i < size; ++i) { + assertEntry(key(i), value(i), dest); + } + assertSize(size, dest); - @Test - public void fillStringMap() { - int size = randomSize(); - TagMap map = TagMap.create(); - for (int i = 0; i < size; ++i) { - map.set(key(i), i); + checkIntegrity(orig); + checkIntegrity(dest); } - HashMap hashMap = new HashMap<>(); - map.fillStringMap(hashMap); + @ParameterizedTest + @EnumSource(TagMapScenario.class) + public void putAll_cloberAll(TagMapScenario scenario) { + int size = scenario.size(); + TagMap orig = scenario.create(); + assertSize(size, orig); + + TagMap dest = scenario.create(); + assertSize(size, dest); + + dest.putAll(orig); + + assertSize(size, orig); + assertSize(size, dest); - for (int i = 0; i < size; ++i) { - assertEquals(Integer.toString(i), hashMap.remove(key(i))); + checkIntegrity(orig); + checkIntegrity(dest); } - assertTrue(hashMap.isEmpty()); - checkIntegrity(map); - } + @Test + public void putAll_clobberAndExtras() { + int size = randomSize(); + TagMap orig = createTagMap(size); + assertSize(size, orig); - @Test - public void iterator() { - int size = randomSize(); - TagMap map = createTagMap(size); + TagMap dest = TagMap.create(); + for (int i = size / 2 - 1; i >= 0; --i) { + dest.set(key(i), altValue(i)); + } - Set keys = new HashSet<>(); - for (TagMap.EntryReader entry : map) { - // makes sure that each key is visited once and only once - assertTrue(keys.add(entry.tag())); + // This should clobber all the values in dest + dest.putAll(orig); + + for (int i = 0; i < size; ++i) { + assertEntry(key(i), value(i), dest); + } + + assertSize(size, dest); + + checkIntegrity(orig); + checkIntegrity(dest); } - for (int i = 0; i < size; ++i) { - // make sure the key was present - assertTrue(keys.remove(key(i))); + @Test + public void removeMany() { + int size = randomSize(); + TagMap map = createTagMap(size); + + for (int i = 0; i < size; ++i) { + assertEntry(key(i), value(i), map); + } + + assertNotEmpty(map); + assertSize(size, map); + + for (int i = 0; i < size; ++i) { + Object removedValue = map.remove((Object) key(i)); + assertEquals(value(i), removedValue); + + // not doing exhaustive size checks + assertEquals(size - i - 1, map.size()); + } + + assertEmpty(map); + + checkIntegrity(map); } - // no extraneous keys - assertTrue(keys.isEmpty()); + @Test + public void fillMap() { + int size = randomSize(); + TagMap map = TagMap.create(); + for (int i = 0; i < size; ++i) { + map.set(key(i), i); + } - checkIntegrity(map); - } + HashMap hashMap = new HashMap<>(); + map.fillMap(hashMap); - @Test - public void forEachConsumer() { - int size = randomSize(); - TagMap map = createTagMap(size); + for (int i = 0; i < size; ++i) { + assertEquals(Integer.valueOf(i), hashMap.remove(key(i))); + } + assertTrue(hashMap.isEmpty()); + + checkIntegrity(map); + } - Set keys = new HashSet<>(size); - map.forEach((entry) -> keys.add(entry.tag())); + @Test + public void fillStringMap() { + int size = randomSize(); + TagMap map = TagMap.create(); + for (int i = 0; i < size; ++i) { + map.set(key(i), i); + } - for (int i = 0; i < size; ++i) { - // make sure the key was present - assertTrue(keys.remove(key(i))); + HashMap hashMap = new HashMap<>(); + map.fillStringMap(hashMap); + + for (int i = 0; i < size; ++i) { + assertEquals(Integer.toString(i), hashMap.remove(key(i))); + } + assertTrue(hashMap.isEmpty()); + + checkIntegrity(map); } - // no extraneous keys - assertTrue(keys.isEmpty()); + @Test + public void iterator() { + int size = randomSize(); + TagMap map = createTagMap(size); - checkIntegrity(map); - } + Set keys = new HashSet<>(); + for (TagMap.EntryReader entry : map) { + // makes sure that each key is visited once and only once + assertTrue(keys.add(entry.tag())); + } - @Test - public void forEachBiConsumer() { - int size = randomSize(); - TagMap map = createTagMap(size); + for (int i = 0; i < size; ++i) { + // make sure the key was present + assertTrue(keys.remove(key(i))); + } - Set keys = new HashSet<>(size); - map.forEach(keys, (k, entry) -> k.add(entry.tag())); + // no extraneous keys + assertTrue(keys.isEmpty()); - for (int i = 0; i < size; ++i) { - // make sure the key was present - assertTrue(keys.remove(key(i))); + checkIntegrity(map); } - // no extraneous keys - assertTrue(keys.isEmpty()); + @Test + public void forEachConsumer() { + int size = randomSize(); + TagMap map = createTagMap(size); - checkIntegrity(map); - } + Set keys = new HashSet<>(size); + map.forEach((entry) -> keys.add(entry.tag())); - @Test - public void forEachTriConsumer() { - int size = randomSize(); - TagMap map = createTagMap(size); + for (int i = 0; i < size; ++i) { + // make sure the key was present + assertTrue(keys.remove(key(i))); + } - Set keys = new HashSet<>(size); - map.forEach(keys, "hi", (k, msg, entry) -> keys.add(entry.tag())); + // no extraneous keys + assertTrue(keys.isEmpty()); - for (int i = 0; i < size; ++i) { - // make sure the key was present - assertTrue(keys.remove(key(i))); + checkIntegrity(map); } - // no extraneous keys - assertTrue(keys.isEmpty()); + @Test + public void forEachBiConsumer() { + int size = randomSize(); + TagMap map = createTagMap(size); - checkIntegrity(map); - } + Set keys = new HashSet<>(size); + map.forEach(keys, (k, entry) -> k.add(entry.tag())); - @Test - public void entrySet() { - int size = randomSize(); - TagMap map = createTagMap(size); + for (int i = 0; i < size; ++i) { + // make sure the key was present + assertTrue(keys.remove(key(i))); + } - Set> actualEntries = map.entrySet(); - assertEquals(size, actualEntries.size()); - assertFalse(actualEntries.isEmpty()); + // no extraneous keys + assertTrue(keys.isEmpty()); - Set expectedKeys = expectedKeys(size); - for (Map.Entry entry : actualEntries) { - assertTrue(expectedKeys.remove(entry.getKey())); + checkIntegrity(map); } - assertTrue(expectedKeys.isEmpty()); - checkIntegrity(map); - } + @Test + public void forEachTriConsumer() { + int size = randomSize(); + TagMap map = createTagMap(size); + + Set keys = new HashSet<>(size); + map.forEach(keys, "hi", (k, msg, entry) -> keys.add(entry.tag())); - @Test - public void keySet() { - int size = randomSize(); - TagMap map = createTagMap(size); + for (int i = 0; i < size; ++i) { + // make sure the key was present + assertTrue(keys.remove(key(i))); + } - Set actualKeys = map.keySet(); - assertEquals(size, actualKeys.size()); - assertFalse(actualKeys.isEmpty()); + // no extraneous keys + assertTrue(keys.isEmpty()); - Set expectedKeys = expectedKeys(size); - for (String key : actualKeys) { - assertTrue(expectedKeys.remove(key)); + checkIntegrity(map); } - assertTrue(expectedKeys.isEmpty()); - checkIntegrity(map); - } + @Test + public void entrySet() { + int size = randomSize(); + TagMap map = createTagMap(size); - @Test - public void values() { - int size = randomSize(); - TagMap map = createTagMap(size); + Set> actualEntries = map.entrySet(); + assertEquals(size, actualEntries.size()); + assertFalse(actualEntries.isEmpty()); - Collection actualValues = map.values(); - assertEquals(size, actualValues.size()); - assertFalse(actualValues.isEmpty()); + Set expectedKeys = expectedKeys(size); + for (Map.Entry entry : actualEntries) { + assertTrue(expectedKeys.remove(entry.getKey())); + } + assertTrue(expectedKeys.isEmpty()); - Set expectedValues = expectedValues(size); - for (Object value : map.values()) { - assertTrue(expectedValues.remove(value)); + checkIntegrity(map); } - assertTrue(expectedValues.isEmpty()); - checkIntegrity(map); - } + @Test + public void keySet() { + int size = randomSize(); + TagMap map = createTagMap(size); - @Test - public void _toString() { - int size = 4; - TagMap map = createTagMap(size); + Set actualKeys = map.keySet(); + assertEquals(size, actualKeys.size()); + assertFalse(actualKeys.isEmpty()); - String str = map.toString(); + Set expectedKeys = expectedKeys(size); + for (String key : actualKeys) { + assertTrue(expectedKeys.remove(key)); + } + assertTrue(expectedKeys.isEmpty()); - for (int i = 0; i < size; ++i) { - assertTrue(str.contains(key(i) + "=" + value(i))); + checkIntegrity(map); } - } - @Test - public void stream() { - int size = randomSize(); - TagMap map = createTagMap(size); + @Test + public void values() { + int size = randomSize(); + TagMap map = createTagMap(size); - Set keys = map.stream().map(TagMap.EntryReader::tag).collect(Collectors.toSet()); + Collection actualValues = map.values(); + assertEquals(size, actualValues.size()); + assertFalse(actualValues.isEmpty()); - assertEquals(size, keys.size()); - for (int i = 0; i < size; ++i) { - assertTrue(keys.contains(key(i))); + Set expectedValues = expectedValues(size); + for (Object value : map.values()) { + assertTrue(expectedValues.remove(value)); + } + assertTrue(expectedValues.isEmpty()); + + checkIntegrity(map); + } + + @Test + public void _toString() { + int size = 4; + TagMap map = createTagMap(size); + + String str = map.toString(); + + for (int i = 0; i < size; ++i) { + assertTrue(str.contains(key(i) + "=" + value(i))); + } + } + + @Test + public void stream() { + int size = randomSize(); + TagMap map = createTagMap(size); + + Set keys = map.stream().map(TagMap.EntryReader::tag).collect(Collectors.toSet()); + + assertEquals(size, keys.size()); + for (int i = 0; i < size; ++i) { + assertTrue(keys.contains(key(i))); + } + } + + @Test + public void compute() { + TagMap map = TagMap.create(); + map.set("key", "original"); + + map.compute("key", (k, v) -> "updated"); + assertEquals("updated", map.get("key")); + + map.compute("new-key", (k, v) -> "created"); + assertEquals("created", map.get("new-key")); + } + + @Test + public void computeIfAbsent() { + TagMap map = TagMap.create(); + map.set("key", "existing"); + + map.computeIfAbsent("key", k -> "ignored"); + assertEquals("existing", map.get("key")); + + map.computeIfAbsent("new-key", k -> "added"); + assertEquals("added", map.get("new-key")); + } + + @Test + public void computeIfPresent() { + TagMap map = TagMap.create(); + map.set("key", "original"); + + map.computeIfPresent("key", (k, v) -> "updated"); + assertEquals("updated", map.get("key")); + + map.computeIfPresent("missing", (k, v) -> "never"); + assertNull(map.get("missing")); + } + + @ParameterizedTest + @ValueSource(ints = {0, 5, 25, 125}) + public void _toInternalString(int size) { + TagMap tagMap = new TagMap(); + fillMap(tagMap, size); + + String str = tagMap.toInternalString(); + + for (int i = 0; i < size; ++i) { + assertTrue(str.contains(key(i) + "=" + value(i))); + } + } + + static int randomSize() { + return ThreadLocalRandom.current().nextInt(16, MANY_SIZE); + } + + static TagMap createTagMap() { + return createTagMap(randomSize()); + } + + static TagMap createTagMap(int size) { + TagMap map = TagMap.create(); + fillMap(map, size); + return map; + } + + static void fillMap(TagMap map, int size) { + for (int i = 0; i < size; ++i) { + map.set(key(i), value(i)); + } + } + + static Set expectedKeys(int size) { + Set set = new HashSet(size); + for (int i = 0; i < size; ++i) { + set.add(key(i)); + } + return set; } - } - @Test - public void compute() { - TagMap map = TagMap.create(); - map.set("key", "original"); + static Set expectedValues(int size) { + Set set = new HashSet(size); + for (int i = 0; i < size; ++i) { + set.add(value(i)); + } + return set; + } - map.compute("key", (k, v) -> "updated"); - assertEquals("updated", map.get("key")); + static String key(int i) { + return "key-" + i; + } - map.compute("new-key", (k, v) -> "created"); - assertEquals("created", map.get("new-key")); - } + static String value(int i) { + return "value-" + i; + } - @Test - public void computeIfAbsent() { - TagMap map = TagMap.create(); - map.set("key", "existing"); + static String altValue(int i) { + return "alt-value-" + i; + } - map.computeIfAbsent("key", k -> "ignored"); - assertEquals("existing", map.get("key")); + static int count(Iterable iterable) { + return count(iterable.iterator()); + } - map.computeIfAbsent("new-key", k -> "added"); - assertEquals("added", map.get("new-key")); - } + static int count(Iterator iter) { + int count; + for (count = 0; iter.hasNext(); ++count) { + iter.next(); + } + return count; + } - @Test - public void computeIfPresent() { - TagMap map = TagMap.create(); - map.set("key", "original"); + static final void assertBoolean(boolean expected, TagMap map, String key) { + assertEquals(expected, map.getBoolean(key)); + assertEquals(expected, map.getBooleanOrDefault(key, !expected)); + } - map.computeIfPresent("key", (k, v) -> "updated"); - assertEquals("updated", map.get("key")); + static final void assertInt(int expected, TagMap map, String key) { + assertEquals(expected, map.getInt(key)); + assertEquals(expected, map.getIntOrDefault(key, Integer.MAX_VALUE)); + } - map.computeIfPresent("missing", (k, v) -> "never"); - assertNull(map.get("missing")); - } + static final void assertLong(long expected, TagMap map, String key) { + assertEquals(expected, map.getLong(key)); + assertEquals(expected, map.getLongOrDefault(key, Long.MAX_VALUE)); + } - @ParameterizedTest - @ValueSource(ints = {0, 5, 25, 125}) - public void _toInternalString(int size) { - TagMap tagMap = new TagMap(); - fillMap(tagMap, size); + static final void assertFloat(float expected, TagMap map, String key) { + assertEquals(expected, map.getFloat(key)); + assertEquals(expected, map.getFloatOrDefault(key, Float.MAX_VALUE)); + } - String str = tagMap.toInternalString(); + static final void assertDouble(double expected, TagMap map, String key) { + assertEquals(expected, map.getDouble(key)); + assertEquals(expected, map.getDoubleOrDefault(key, Double.MAX_VALUE)); + } - for (int i = 0; i < size; ++i) { - assertTrue(str.contains(key(i) + "=" + value(i))); + static final void assertString(String expected, TagMap map, String key) { + assertEquals(expected, map.getString(key)); } - } - static int randomSize() { - return ThreadLocalRandom.current().nextInt(16, MANY_SIZE); - } + static final void assertEntry(String key, String value, TagMap map) { + TagMap.Entry entry = map.getEntry(key); + assertNotNull(entry); - static TagMap createTagMap() { - return createTagMap(randomSize()); - } + assertEquals(key, entry.tag()); + assertEquals(key, entry.getKey()); - static TagMap createTagMap(int size) { - TagMap map = TagMap.create(); - fillMap(map, size); - return map; - } + assertEquals(value, entry.objectValue()); + assertTrue(entry.isObject()); + assertEquals(value, entry.getValue()); - static void fillMap(TagMap map, int size) { - for (int i = 0; i < size; ++i) { - map.set(key(i), value(i)); + assertEquals(value, entry.stringValue()); + + assertTrue(map.containsKey(key)); + assertTrue(map.keySet().contains(key)); + + assertTrue(map.containsValue(value)); + assertTrue(map.values().contains(value)); } - } - static Set expectedKeys(int size) { - Set set = new HashSet(size); - for (int i = 0; i < size; ++i) { - set.add(key(i)); + static final void assertSize(int size, TagMap map) { + assertEquals(size, map.computeSize()); + assertEquals(size, map.size()); + + assertEquals(size, count(map)); + assertEquals(size, map.keySet().size()); + assertEquals(size, map.values().size()); + + assertEquals(size, count(map.keySet())); + assertEquals(size, count(map.tagIterator())); + + assertEquals(size, count(map.values().iterator())); + assertEquals(size, count(map.valueIterator())); + assertEquals(size, count(map.values())); } - return set; - } - static Set expectedValues(int size) { - Set set = new HashSet(size); - for (int i = 0; i < size; ++i) { - set.add(value(i)); + static void assertEmptiness(TagMapScenario scenario, TagMap map) { + assertEmptiness(scenario.size() == 0, map); } - return set; - } - static String key(int i) { - return "key-" + i; - } + static void assertEmptiness(boolean expectEmpty, TagMap map) { + if (expectEmpty) { + assertEmpty(map); + } else { + assertNotEmpty(map); + } + } - static String value(int i) { - return "value-" + i; - } + static void assertNotEmpty(TagMap map) { + assertFalse(map.checkIfEmpty()); + assertFalse(map.isEmpty()); + } - static String altValue(int i) { - return "alt-value-" + i; - } + static void assertEmpty(TagMap map) { + assertTrue(map.checkIfEmpty()); + assertTrue(map.isEmpty()); + } - static int count(Iterable iterable) { - return count(iterable.iterator()); - } - - static int count(Iterator iter) { - int count; - for (count = 0; iter.hasNext(); ++count) { - iter.next(); + static void assertFrozen(TagMap map) { + IllegalStateException ex = null; + try { + map.put("foo", "bar"); + } catch (IllegalStateException e) { + ex = e; + } + assertNotNull(ex); } - return count; - } - - static final void assertBoolean(boolean expected, TagMap map, String key) { - assertEquals(expected, map.getBoolean(key)); - assertEquals(expected, map.getBooleanOrDefault(key, !expected)); - } - - static final void assertInt(int expected, TagMap map, String key) { - assertEquals(expected, map.getInt(key)); - assertEquals(expected, map.getIntOrDefault(key, Integer.MAX_VALUE)); - } - - static final void assertLong(long expected, TagMap map, String key) { - assertEquals(expected, map.getLong(key)); - assertEquals(expected, map.getLongOrDefault(key, Long.MAX_VALUE)); - } - - static final void assertFloat(float expected, TagMap map, String key) { - assertEquals(expected, map.getFloat(key)); - assertEquals(expected, map.getFloatOrDefault(key, Float.MAX_VALUE)); - } - static final void assertDouble(double expected, TagMap map, String key) { - assertEquals(expected, map.getDouble(key)); - assertEquals(expected, map.getDoubleOrDefault(key, Double.MAX_VALUE)); - } - - static final void assertString(String expected, TagMap map, String key) { - assertEquals(expected, map.getString(key)); - } - - static final void assertEntry(String key, String value, TagMap map) { - TagMap.Entry entry = map.getEntry(key); - assertNotNull(entry); + static void assertFrozen(Runnable runnable) { + IllegalStateException ex = null; + try { + runnable.run(); + } catch (IllegalStateException e) { + ex = e; + } + assertNotNull(ex); + } - assertEquals(key, entry.tag()); - assertEquals(key, entry.getKey()); - - assertEquals(value, entry.objectValue()); - assertTrue(entry.isObject()); - assertEquals(value, entry.getValue()); - - assertEquals(value, entry.stringValue()); - - assertTrue(map.containsKey(key)); - assertTrue(map.keySet().contains(key)); - - assertTrue(map.containsValue(value)); - assertTrue(map.values().contains(value)); - } - - static final void assertSize(int size, TagMap map) { - assertEquals(size, map.computeSize()); - assertEquals(size, map.size()); - - assertEquals(size, count(map)); - assertEquals(size, map.keySet().size()); - assertEquals(size, map.values().size()); - - assertEquals(size, count(map.keySet())); - assertEquals(size, count(map.tagIterator())); - - assertEquals(size, count(map.values().iterator())); - assertEquals(size, count(map.valueIterator())); - assertEquals(size, count(map.values())); - } - - static void assertEmptiness(TagMapScenario scenario, TagMap map) { - assertEmptiness(scenario.size() == 0, map); - } - - static void assertEmptiness(boolean expectEmpty, TagMap map) { - if (expectEmpty) { - assertEmpty(map); - } else { - assertNotEmpty(map); - } - } - - static void assertNotEmpty(TagMap map) { - assertFalse(map.checkIfEmpty()); - assertFalse(map.isEmpty()); - } - - static void assertEmpty(TagMap map) { - assertTrue(map.checkIfEmpty()); - assertTrue(map.isEmpty()); - } - - static void assertFrozen(TagMap map) { - IllegalStateException ex = null; - try { - map.put("foo", "bar"); - } catch (IllegalStateException e) { - ex = e; - } - assertNotNull(ex); - } - - static void assertFrozen(Runnable runnable) { - IllegalStateException ex = null; - try { - runnable.run(); - } catch (IllegalStateException e) { - ex = e; - } - assertNotNull(ex); - } - - static void checkIntegrity(TagMap map) { - map.checkIntegrity(); - } + static void checkIntegrity(TagMap map) { + map.checkIntegrity(); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagValueConversionsTest.java b/internal-api/src/test/java/datadog/trace/api/TagValueConversionsTest.java index 45815ddd881..e01ac0c74ea 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagValueConversionsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagValueConversionsTest.java @@ -8,159 +8,158 @@ import org.junit.jupiter.params.provider.ValueSource; public class TagValueConversionsTest { - @ParameterizedTest - @ValueSource(booleans = {false, true}) - public void booleanConversions(boolean value) { - Boolean box = Boolean.valueOf(value); - - assertEquals(TagMap.EntryReader.BOOLEAN, TagValueConversions.typeOf(box)); - assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.BOOLEAN)); - assertFalse(TagValueConversions.isNumericPrimitive(box)); - assertFalse(TagValueConversions.isNumber(box)); - assertFalse(TagValueConversions.isObject(box)); - - assertEquals(value, TagValueConversions.toBoolean(box)); - assertEquals(value ? 1 : 0, TagValueConversions.toInt(box)); - assertEquals(value ? 1L : 0L, TagValueConversions.toLong(box)); - assertEquals(value ? 1F : 0F, TagValueConversions.toFloat(box)); - assertEquals(value ? 1D : 0D, TagValueConversions.toDouble(box)); - - assertEquals(Boolean.toString(value), TagValueConversions.toString(box)); - } - - @ParameterizedTest - @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) - public void intConversions(int value) { - Integer box = Integer.valueOf(value); - - assertEquals(TagMap.EntryReader.INT, TagValueConversions.typeOf(box)); - assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.INT)); - assertTrue(TagValueConversions.isNumericPrimitive(box)); - assertTrue(TagValueConversions.isNumber(box)); - assertFalse(TagValueConversions.isObject(box)); - - assertEquals(value, TagValueConversions.toInt(box)); - assertEquals((long) value, TagValueConversions.toLong(box)); - assertEquals((float) value, TagValueConversions.toFloat(box)); - assertEquals((double) value, TagValueConversions.toDouble(box)); - - assertEquals(value != 0, TagValueConversions.toBoolean(box)); - assertEquals(Integer.toString(value), TagValueConversions.toString(box)); - } - - @ParameterizedTest - @ValueSource(bytes = {Byte.MIN_VALUE, -32, -1, 0, 1, 32, Byte.MAX_VALUE}) - public void byteConversions(byte value) { - Byte box = Byte.valueOf(value); - - assertEquals(TagMap.EntryReader.INT, TagValueConversions.typeOf(box)); - assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.INT)); - assertTrue(TagValueConversions.isNumericPrimitive(box)); - assertTrue(TagValueConversions.isNumber(box)); - assertFalse(TagValueConversions.isObject(box)); - - assertEquals((int) value, TagValueConversions.toInt(box)); - assertEquals((long) value, TagValueConversions.toLong(box)); - assertEquals((float) value, TagValueConversions.toFloat(box)); - assertEquals((double) value, TagValueConversions.toDouble(box)); - - assertEquals(value != 0, TagValueConversions.toBoolean(box)); - assertEquals(Byte.toString(value), TagValueConversions.toString(box)); - } - - @ParameterizedTest - @ValueSource(shorts = {Short.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Short.MAX_VALUE}) - public void shortConversions(short value) { - Short box = Short.valueOf(value); - - assertEquals(TagMap.EntryReader.INT, TagValueConversions.typeOf(box)); - assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.INT)); - assertTrue(TagValueConversions.isNumericPrimitive(box)); - assertTrue(TagValueConversions.isNumber(box)); - assertFalse(TagValueConversions.isObject(box)); - - assertEquals((int) value, TagValueConversions.toInt(box)); - assertEquals((long) value, TagValueConversions.toLong(box)); - assertEquals((float) value, TagValueConversions.toFloat(box)); - assertEquals((double) value, TagValueConversions.toDouble(box)); - - assertEquals(value != 0, TagValueConversions.toBoolean(box)); - assertEquals(Short.toString(value), TagValueConversions.toString(box)); - } - - @ParameterizedTest - @ValueSource( - longs = { - Long.MIN_VALUE, - Integer.MIN_VALUE, - -1_048_576L, - -256L, - -128L, - -1L, - 0L, - 1L, - 128L, - 256L, - 1_048_576L, - Integer.MAX_VALUE, - Long.MAX_VALUE - }) - public void longConversions(long value) { - Long box = Long.valueOf(value); - - assertEquals(TagMap.EntryReader.LONG, TagValueConversions.typeOf(box)); - assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.LONG)); - assertTrue(TagValueConversions.isNumericPrimitive(box)); - assertTrue(TagValueConversions.isNumber(box)); - assertFalse(TagValueConversions.isObject(box)); - - assertEquals(value, TagValueConversions.toLong(box)); - assertEquals((int) value, TagValueConversions.toInt(box)); - assertEquals((float) value, TagValueConversions.toFloat(box)); - assertEquals((double) value, TagValueConversions.toDouble(box)); - - assertEquals(value != 0L, TagValueConversions.toBoolean(box)); - assertEquals(Long.toString(value), TagValueConversions.toString(box)); - } - - @ParameterizedTest - @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) - public void floatConversions(float value) { - Float box = Float.valueOf(value); - - assertEquals(TagMap.EntryReader.FLOAT, TagValueConversions.typeOf(box)); - assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.FLOAT)); - assertTrue(TagValueConversions.isNumericPrimitive(box)); - assertTrue(TagValueConversions.isNumber(box)); - assertFalse(TagValueConversions.isObject(box)); - - assertEquals(value, TagValueConversions.toFloat(box)); - assertEquals((int) value, TagValueConversions.toInt(box)); - assertEquals((long) value, TagValueConversions.toLong(box)); - assertEquals((double) value, TagValueConversions.toDouble(box)); - - assertEquals(value != 0F, TagValueConversions.toBoolean(box)); - assertEquals(Float.toString(value), TagValueConversions.toString(box)); - } - - @ParameterizedTest - @ValueSource( - doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) - public void doubleConversions(double value) { - Double box = Double.valueOf(value); - - assertEquals(TagMap.EntryReader.DOUBLE, TagValueConversions.typeOf(box)); - assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.DOUBLE)); - assertTrue(TagValueConversions.isNumericPrimitive(box)); - assertTrue(TagValueConversions.isNumber(box)); - assertFalse(TagValueConversions.isObject(box)); - - assertEquals(value, TagValueConversions.toDouble(box)); - assertEquals((int) value, TagValueConversions.toInt(box)); - assertEquals((long) value, TagValueConversions.toLong(box)); - assertEquals((float) value, TagValueConversions.toFloat(box)); - - assertEquals(value != 0D, TagValueConversions.toBoolean(box)); - assertEquals(Double.toString(value), TagValueConversions.toString(box)); - } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void booleanConversions(boolean value) { + Boolean box = Boolean.valueOf(value); + + assertEquals(TagMap.EntryReader.BOOLEAN, TagValueConversions.typeOf(box)); + assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.BOOLEAN)); + assertFalse(TagValueConversions.isNumericPrimitive(box)); + assertFalse(TagValueConversions.isNumber(box)); + assertFalse(TagValueConversions.isObject(box)); + + assertEquals(value, TagValueConversions.toBoolean(box)); + assertEquals(value ? 1 : 0, TagValueConversions.toInt(box)); + assertEquals(value ? 1L : 0L, TagValueConversions.toLong(box)); + assertEquals(value ? 1F : 0F, TagValueConversions.toFloat(box)); + assertEquals(value ? 1D : 0D, TagValueConversions.toDouble(box)); + + assertEquals(Boolean.toString(value), TagValueConversions.toString(box)); + } + + @ParameterizedTest + @ValueSource(ints = {Integer.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Integer.MAX_VALUE}) + public void intConversions(int value) { + Integer box = Integer.valueOf(value); + + assertEquals(TagMap.EntryReader.INT, TagValueConversions.typeOf(box)); + assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.INT)); + assertTrue(TagValueConversions.isNumericPrimitive(box)); + assertTrue(TagValueConversions.isNumber(box)); + assertFalse(TagValueConversions.isObject(box)); + + assertEquals(value, TagValueConversions.toInt(box)); + assertEquals((long) value, TagValueConversions.toLong(box)); + assertEquals((float) value, TagValueConversions.toFloat(box)); + assertEquals((double) value, TagValueConversions.toDouble(box)); + + assertEquals(value != 0, TagValueConversions.toBoolean(box)); + assertEquals(Integer.toString(value), TagValueConversions.toString(box)); + } + + @ParameterizedTest + @ValueSource(bytes = {Byte.MIN_VALUE, -32, -1, 0, 1, 32, Byte.MAX_VALUE}) + public void byteConversions(byte value) { + Byte box = Byte.valueOf(value); + + assertEquals(TagMap.EntryReader.INT, TagValueConversions.typeOf(box)); + assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.INT)); + assertTrue(TagValueConversions.isNumericPrimitive(box)); + assertTrue(TagValueConversions.isNumber(box)); + assertFalse(TagValueConversions.isObject(box)); + + assertEquals((int) value, TagValueConversions.toInt(box)); + assertEquals((long) value, TagValueConversions.toLong(box)); + assertEquals((float) value, TagValueConversions.toFloat(box)); + assertEquals((double) value, TagValueConversions.toDouble(box)); + + assertEquals(value != 0, TagValueConversions.toBoolean(box)); + assertEquals(Byte.toString(value), TagValueConversions.toString(box)); + } + + @ParameterizedTest + @ValueSource(shorts = {Short.MIN_VALUE, -256, -128, -1, 0, 1, 128, 256, Short.MAX_VALUE}) + public void shortConversions(short value) { + Short box = Short.valueOf(value); + + assertEquals(TagMap.EntryReader.INT, TagValueConversions.typeOf(box)); + assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.INT)); + assertTrue(TagValueConversions.isNumericPrimitive(box)); + assertTrue(TagValueConversions.isNumber(box)); + assertFalse(TagValueConversions.isObject(box)); + + assertEquals((int) value, TagValueConversions.toInt(box)); + assertEquals((long) value, TagValueConversions.toLong(box)); + assertEquals((float) value, TagValueConversions.toFloat(box)); + assertEquals((double) value, TagValueConversions.toDouble(box)); + + assertEquals(value != 0, TagValueConversions.toBoolean(box)); + assertEquals(Short.toString(value), TagValueConversions.toString(box)); + } + + @ParameterizedTest + @ValueSource( + longs = { + Long.MIN_VALUE, + Integer.MIN_VALUE, + -1_048_576L, + -256L, + -128L, + -1L, + 0L, + 1L, + 128L, + 256L, + 1_048_576L, + Integer.MAX_VALUE, + Long.MAX_VALUE + }) + public void longConversions(long value) { + Long box = Long.valueOf(value); + + assertEquals(TagMap.EntryReader.LONG, TagValueConversions.typeOf(box)); + assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.LONG)); + assertTrue(TagValueConversions.isNumericPrimitive(box)); + assertTrue(TagValueConversions.isNumber(box)); + assertFalse(TagValueConversions.isObject(box)); + + assertEquals(value, TagValueConversions.toLong(box)); + assertEquals((int) value, TagValueConversions.toInt(box)); + assertEquals((float) value, TagValueConversions.toFloat(box)); + assertEquals((double) value, TagValueConversions.toDouble(box)); + + assertEquals(value != 0L, TagValueConversions.toBoolean(box)); + assertEquals(Long.toString(value), TagValueConversions.toString(box)); + } + + @ParameterizedTest + @ValueSource(floats = {Float.MIN_VALUE, -1F, 0F, 1F, 2.171828F, 3.1415F, Float.MAX_VALUE}) + public void floatConversions(float value) { + Float box = Float.valueOf(value); + + assertEquals(TagMap.EntryReader.FLOAT, TagValueConversions.typeOf(box)); + assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.FLOAT)); + assertTrue(TagValueConversions.isNumericPrimitive(box)); + assertTrue(TagValueConversions.isNumber(box)); + assertFalse(TagValueConversions.isObject(box)); + + assertEquals(value, TagValueConversions.toFloat(box)); + assertEquals((int) value, TagValueConversions.toInt(box)); + assertEquals((long) value, TagValueConversions.toLong(box)); + assertEquals((double) value, TagValueConversions.toDouble(box)); + + assertEquals(value != 0F, TagValueConversions.toBoolean(box)); + assertEquals(Float.toString(value), TagValueConversions.toString(box)); + } + + @ParameterizedTest + @ValueSource(doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1D, 0D, 1D, Math.E, Math.PI, Double.MAX_VALUE}) + public void doubleConversions(double value) { + Double box = Double.valueOf(value); + + assertEquals(TagMap.EntryReader.DOUBLE, TagValueConversions.typeOf(box)); + assertTrue(TagValueConversions.isA(box, TagMap.EntryReader.DOUBLE)); + assertTrue(TagValueConversions.isNumericPrimitive(box)); + assertTrue(TagValueConversions.isNumber(box)); + assertFalse(TagValueConversions.isObject(box)); + + assertEquals(value, TagValueConversions.toDouble(box)); + assertEquals((int) value, TagValueConversions.toInt(box)); + assertEquals((long) value, TagValueConversions.toLong(box)); + assertEquals((float) value, TagValueConversions.toFloat(box)); + + assertEquals(value != 0D, TagValueConversions.toBoolean(box)); + assertEquals(Double.toString(value), TagValueConversions.toString(box)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/TraceStatsAdditionalTagsTest.java b/internal-api/src/test/java/datadog/trace/api/TraceStatsAdditionalTagsTest.java index b8207dc56b9..3dbe956eeba 100644 --- a/internal-api/src/test/java/datadog/trace/api/TraceStatsAdditionalTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TraceStatsAdditionalTagsTest.java @@ -9,44 +9,44 @@ class TraceStatsAdditionalTagsTest { - private static Config configWith(String experimentalFeatures, String additionalTags) { - Properties props = new Properties(); - if (experimentalFeatures != null) { - props.setProperty("trace.experimental.features.enabled", experimentalFeatures); + private static Config configWith(String experimentalFeatures, String additionalTags) { + Properties props = new Properties(); + if (experimentalFeatures != null) { + props.setProperty("trace.experimental.features.enabled", experimentalFeatures); + } + if (additionalTags != null) { + props.setProperty("trace.stats.additional.tags", additionalTags); + } + return Config.get(props); } - if (additionalTags != null) { - props.setProperty("trace.stats.additional.tags", additionalTags); + + @Test + void returnsConfiguredTagsWhenFeatureEnabled() { + Config config = configWith("DD_TRACE_STATS_ADDITIONAL_TAGS", "peer.hostname,messaging.system"); + Set tags = config.getTraceStatsAdditionalTags(); + assertEquals(2, tags.size()); + assertTrue(tags.contains("peer.hostname")); + assertTrue(tags.contains("messaging.system")); + } + + @Test + void emptyWhenFeatureNotEnabled() { + // The value is configured but the experimental feature gate is closed. + Config config = configWith(null, "peer.hostname"); + assertTrue(config.getTraceStatsAdditionalTags().isEmpty()); + } + + @Test + void returnsSameSnapshotInstanceOnEachCall() { + // The setting is snapshotted into a final field at construction, so repeated reads + // return the identical immutable instance rather than re-querying the config source. + Config config = configWith("DD_TRACE_STATS_ADDITIONAL_TAGS", "peer.hostname"); + assertEquals(config.getTraceStatsAdditionalTags(), config.getTraceStatsAdditionalTags()); + } + + @Test + void includedInToString() { + Config config = configWith("DD_TRACE_STATS_ADDITIONAL_TAGS", "peer.hostname"); + assertTrue(config.toString().contains("traceStatsAdditionalTags=")); } - return Config.get(props); - } - - @Test - void returnsConfiguredTagsWhenFeatureEnabled() { - Config config = configWith("DD_TRACE_STATS_ADDITIONAL_TAGS", "peer.hostname,messaging.system"); - Set tags = config.getTraceStatsAdditionalTags(); - assertEquals(2, tags.size()); - assertTrue(tags.contains("peer.hostname")); - assertTrue(tags.contains("messaging.system")); - } - - @Test - void emptyWhenFeatureNotEnabled() { - // The value is configured but the experimental feature gate is closed. - Config config = configWith(null, "peer.hostname"); - assertTrue(config.getTraceStatsAdditionalTags().isEmpty()); - } - - @Test - void returnsSameSnapshotInstanceOnEachCall() { - // The setting is snapshotted into a final field at construction, so repeated reads - // return the identical immutable instance rather than re-querying the config source. - Config config = configWith("DD_TRACE_STATS_ADDITIONAL_TAGS", "peer.hostname"); - assertEquals(config.getTraceStatsAdditionalTags(), config.getTraceStatsAdditionalTags()); - } - - @Test - void includedInToString() { - Config config = configWith("DD_TRACE_STATS_ADDITIONAL_TAGS", "peer.hostname"); - assertTrue(config.toString().contains("traceStatsAdditionalTags=")); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TraceStatsCardinalityLimitTest.java b/internal-api/src/test/java/datadog/trace/api/TraceStatsCardinalityLimitTest.java index effb4e12c24..7cd36f8f0c5 100644 --- a/internal-api/src/test/java/datadog/trace/api/TraceStatsCardinalityLimitTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TraceStatsCardinalityLimitTest.java @@ -7,51 +7,47 @@ class TraceStatsCardinalityLimitTest { - private static final int DEFAULT = 1024; - // Mirrors Config.MAX_TRACE_STATS_CARDINALITY_LIMIT (package-private, not visible here). - private static final int MAX = 1 << 16; - - private static Config configWith(String limit) { - Properties props = new Properties(); - props.setProperty("trace.stats.resource.cardinality.limit", limit); - return Config.get(props); - } - - @Test - void positiveValueIsUsed() { - assertEquals(256, configWith("256").getTraceStatsCardinalityLimit("resource", DEFAULT)); - } - - @Test - void zeroFallsBackToDefault() { - assertEquals(DEFAULT, configWith("0").getTraceStatsCardinalityLimit("resource", DEFAULT)); - } - - @Test - void negativeFallsBackToDefault() { - assertEquals(DEFAULT, configWith("-5").getTraceStatsCardinalityLimit("resource", DEFAULT)); - } - - @Test - void valueAtMaxIsUsed() { - // 1 << 16 is the largest accepted value; it is used verbatim. - assertEquals( - MAX, configWith(Integer.toString(MAX)).getTraceStatsCardinalityLimit("resource", DEFAULT)); - } - - @Test - void valueAboveMaxFallsBackToDefault() { - // One past the cap falls back rather than sizing an oversized handler table. - assertEquals( - DEFAULT, - configWith(Integer.toString(MAX + 1)).getTraceStatsCardinalityLimit("resource", DEFAULT)); - } - - @Test - void heapExhaustingValueFallsBackToDefault() { - // A value below TagCardinalityHandler's own 1<<29 guard but large enough to allocate - // multi-gigabyte handler tables must not reach the handler; it falls back to the default. - assertEquals( - DEFAULT, configWith("500000000").getTraceStatsCardinalityLimit("resource", DEFAULT)); - } + private static final int DEFAULT = 1024; + // Mirrors Config.MAX_TRACE_STATS_CARDINALITY_LIMIT (package-private, not visible here). + private static final int MAX = 1 << 16; + + private static Config configWith(String limit) { + Properties props = new Properties(); + props.setProperty("trace.stats.resource.cardinality.limit", limit); + return Config.get(props); + } + + @Test + void positiveValueIsUsed() { + assertEquals(256, configWith("256").getTraceStatsCardinalityLimit("resource", DEFAULT)); + } + + @Test + void zeroFallsBackToDefault() { + assertEquals(DEFAULT, configWith("0").getTraceStatsCardinalityLimit("resource", DEFAULT)); + } + + @Test + void negativeFallsBackToDefault() { + assertEquals(DEFAULT, configWith("-5").getTraceStatsCardinalityLimit("resource", DEFAULT)); + } + + @Test + void valueAtMaxIsUsed() { + // 1 << 16 is the largest accepted value; it is used verbatim. + assertEquals(MAX, configWith(Integer.toString(MAX)).getTraceStatsCardinalityLimit("resource", DEFAULT)); + } + + @Test + void valueAboveMaxFallsBackToDefault() { + // One past the cap falls back rather than sizing an oversized handler table. + assertEquals(DEFAULT, configWith(Integer.toString(MAX + 1)).getTraceStatsCardinalityLimit("resource", DEFAULT)); + } + + @Test + void heapExhaustingValueFallsBackToDefault() { + // A value below TagCardinalityHandler's own 1<<29 guard but large enough to allocate + // multi-gigabyte handler tables must not reach the handler; it falls back to the default. + assertEquals(DEFAULT, configWith("500000000").getTraceStatsCardinalityLimit("resource", DEFAULT)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/WellKnownTagsTest.java b/internal-api/src/test/java/datadog/trace/api/WellKnownTagsTest.java index f164ab7006d..81d59dc4b7d 100644 --- a/internal-api/src/test/java/datadog/trace/api/WellKnownTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/WellKnownTagsTest.java @@ -6,27 +6,27 @@ class WellKnownTagsTest { - @Test - void wellKnownTagsDoesNotModifyInputs() { - WellKnownTags wellKnownTags = - new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language"); + @Test + void wellKnownTagsDoesNotModifyInputs() { + WellKnownTags wellKnownTags = + new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language"); - assertEquals("runtimeid", wellKnownTags.getRuntimeId().toString()); - assertEquals("hostname", wellKnownTags.getHostname().toString()); - assertEquals("env", wellKnownTags.getEnv().toString()); - assertEquals("service", wellKnownTags.getService().toString()); - assertEquals("version", wellKnownTags.getVersion().toString()); - assertEquals("language", wellKnownTags.getLanguage().toString()); - } + assertEquals("runtimeid", wellKnownTags.getRuntimeId().toString()); + assertEquals("hostname", wellKnownTags.getHostname().toString()); + assertEquals("env", wellKnownTags.getEnv().toString()); + assertEquals("service", wellKnownTags.getService().toString()); + assertEquals("version", wellKnownTags.getVersion().toString()); + assertEquals("language", wellKnownTags.getLanguage().toString()); + } - @Test - void toStringIncludesAllFields() { - WellKnownTags wellKnownTags = - new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language"); + @Test + void toStringIncludesAllFields() { + WellKnownTags wellKnownTags = + new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language"); - assertEquals( - "WellKnownTags{runtimeId=runtimeid, hostname=hostname, env=env," - + " service=service, version=version, language=language}", - wellKnownTags.toString()); - } + assertEquals( + "WellKnownTags{runtimeId=runtimeid, hostname=hostname, env=env," + + " service=service, version=version, language=language}", + wellKnownTags.toString()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/civisibility/config/BazelModeTest.java b/internal-api/src/test/java/datadog/trace/api/civisibility/config/BazelModeTest.java index bfd60894414..c80261fca98 100644 --- a/internal-api/src/test/java/datadog/trace/api/civisibility/config/BazelModeTest.java +++ b/internal-api/src/test/java/datadog/trace/api/civisibility/config/BazelModeTest.java @@ -23,208 +23,204 @@ class BazelModeTest { - private EnvironmentVariables.EnvironmentVariablesProvider originalProvider; - private TestEnvironmentVariables envProvider; - - @BeforeEach - void setUp() { - originalProvider = EnvironmentVariables.provider; - envProvider = new TestEnvironmentVariables(); - EnvironmentVariables.provider = envProvider; - BazelMode.reset(); - } - - @AfterEach - void tearDown() { - EnvironmentVariables.provider = originalProvider; - BazelMode.reset(); - } - - @Test - void disabledWhenNoConfigProvided() { - BazelMode mode = new BazelMode(configWith(null, false)); - - assertFalse(mode.isEnabled()); - assertFalse(mode.isManifestModeEnabled()); - assertFalse(mode.isPayloadFilesEnabled()); - assertNull(mode.getPayloadsDir()); - assertNull(mode.getTestPayloadsDir()); - assertNull(mode.getCoveragePayloadsDir()); - assertNull(mode.getTelemetryPayloadsDir()); - assertNull(mode.getSettingsPath()); - } - - @Test - void manifestModeEnabledWithCompatibleManifest(@TempDir Path tmp) throws IOException { - Path manifest = writeManifest(tmp, "1"); - - BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); - - assertTrue(mode.isEnabled()); - assertTrue(mode.isManifestModeEnabled()); - } - - @Test - void manifestModeDisabledForUnsupportedVersion(@TempDir Path tmp) throws IOException { - Path manifest = writeManifest(tmp, "999"); - - BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); - - assertFalse(mode.isManifestModeEnabled()); - } - - @Test - void manifestModeDisabledForUnparseableVersion(@TempDir Path tmp) throws IOException { - Path manifest = writeManifest(tmp, "not-a-number"); - - BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); - - assertFalse(mode.isManifestModeEnabled()); - } - - @Test - void manifestResolvedViaRunfilesDir(@TempDir Path tmp) throws IOException { - Path runfiles = Files.createDirectories(tmp.resolve("runfiles")); - Path manifest = writeManifest(runfiles.resolve(".testoptimization"), "1"); - envProvider.set("RUNFILES_DIR", runfiles.toString()); - String rlocation = runfiles.relativize(manifest).toString(); - - BazelMode mode = new BazelMode(configWith(rlocation, false)); - - assertTrue(mode.isManifestModeEnabled()); - } - - @Test - void manifestResolvedViaRunfilesManifestFile(@TempDir Path tmp) throws IOException { - Path actualManifest = writeManifest(tmp.resolve(".testoptimization"), "1"); - Path runfilesManifest = tmp.resolve("runfiles.manifest"); - Files.write( - runfilesManifest, - ("myproj/.testoptimization/manifest.txt " + actualManifest + "\n") - .getBytes(StandardCharsets.UTF_8)); - envProvider.set("RUNFILES_MANIFEST_FILE", runfilesManifest.toString()); - - BazelMode mode = new BazelMode(configWith("myproj/.testoptimization/manifest.txt", false)); - - assertTrue(mode.isManifestModeEnabled()); - } - - @Test - void cachePathsResolveOnlyWhenFileExists(@TempDir Path tmp) throws IOException { - Path manifestDir = Files.createDirectories(tmp.resolve(".testoptimization")); - Path manifest = writeManifest(manifestDir, "1"); - Path httpDir = Files.createDirectories(manifestDir.resolve("cache/http")); - Files.write(httpDir.resolve("settings.json"), "{}".getBytes(StandardCharsets.UTF_8)); - - BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); - - assertNotNull(mode.getSettingsPath()); - assertEquals( - httpDir.resolve("settings.json").toFile().getAbsolutePath(), mode.getSettingsPath()); - // files that do not exist return null - assertNull(mode.getKnownTestsPath()); - assertNull(mode.getTestManagementPath()); - assertNull(mode.getFlakyTestsPath()); - } - - @Test - void payloadDirsDerivedFromUndeclaredOutputsDir(@TempDir Path tmp) { - envProvider.set("TEST_UNDECLARED_OUTPUTS_DIR", tmp.toString()); - - BazelMode mode = new BazelMode(configWith(null, true)); - - assertTrue(mode.isEnabled()); - assertTrue(mode.isPayloadFilesEnabled()); - assertEquals(tmp.resolve("payloads").toString(), mode.getPayloadsDir()); - assertEquals(tmp.resolve("payloads").resolve("tests").toString(), mode.getTestPayloadsDir()); - assertEquals( - tmp.resolve("payloads").resolve("coverage").toString(), mode.getCoveragePayloadsDir()); - assertEquals( - tmp.resolve("payloads").resolve("telemetry").toString(), mode.getTelemetryPayloadsDir()); - } - - @Test - void payloadFilesDisabledWhenUndeclaredOutputsDirMissing() { - BazelMode mode = new BazelMode(configWith(null, true)); - - assertFalse(mode.isPayloadFilesEnabled()); - assertNull(mode.getPayloadsDir()); - assertNull(mode.getTestPayloadsDir()); - assertNull(mode.getCoveragePayloadsDir()); - assertNull(mode.getTelemetryPayloadsDir()); - } + private EnvironmentVariables.EnvironmentVariablesProvider originalProvider; + private TestEnvironmentVariables envProvider; + + @BeforeEach + void setUp() { + originalProvider = EnvironmentVariables.provider; + envProvider = new TestEnvironmentVariables(); + EnvironmentVariables.provider = envProvider; + BazelMode.reset(); + } + + @AfterEach + void tearDown() { + EnvironmentVariables.provider = originalProvider; + BazelMode.reset(); + } + + @Test + void disabledWhenNoConfigProvided() { + BazelMode mode = new BazelMode(configWith(null, false)); + + assertFalse(mode.isEnabled()); + assertFalse(mode.isManifestModeEnabled()); + assertFalse(mode.isPayloadFilesEnabled()); + assertNull(mode.getPayloadsDir()); + assertNull(mode.getTestPayloadsDir()); + assertNull(mode.getCoveragePayloadsDir()); + assertNull(mode.getTelemetryPayloadsDir()); + assertNull(mode.getSettingsPath()); + } + + @Test + void manifestModeEnabledWithCompatibleManifest(@TempDir Path tmp) throws IOException { + Path manifest = writeManifest(tmp, "1"); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertTrue(mode.isEnabled()); + assertTrue(mode.isManifestModeEnabled()); + } + + @Test + void manifestModeDisabledForUnsupportedVersion(@TempDir Path tmp) throws IOException { + Path manifest = writeManifest(tmp, "999"); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertFalse(mode.isManifestModeEnabled()); + } + + @Test + void manifestModeDisabledForUnparseableVersion(@TempDir Path tmp) throws IOException { + Path manifest = writeManifest(tmp, "not-a-number"); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertFalse(mode.isManifestModeEnabled()); + } + + @Test + void manifestResolvedViaRunfilesDir(@TempDir Path tmp) throws IOException { + Path runfiles = Files.createDirectories(tmp.resolve("runfiles")); + Path manifest = writeManifest(runfiles.resolve(".testoptimization"), "1"); + envProvider.set("RUNFILES_DIR", runfiles.toString()); + String rlocation = runfiles.relativize(manifest).toString(); - @Test - void repoRootResolvedViaTestSrcDir(@TempDir Path tmp) throws IOException { - Path workspace = Files.createDirectories(tmp.resolve("_main")); - envProvider.set("TEST_SRCDIR", tmp.toString()); - envProvider.set("TEST_WORKSPACE", "_main"); + BazelMode mode = new BazelMode(configWith(rlocation, false)); - BazelMode mode = new BazelMode(configWith(null, false)); + assertTrue(mode.isManifestModeEnabled()); + } + + @Test + void manifestResolvedViaRunfilesManifestFile(@TempDir Path tmp) throws IOException { + Path actualManifest = writeManifest(tmp.resolve(".testoptimization"), "1"); + Path runfilesManifest = tmp.resolve("runfiles.manifest"); + Files.write( + runfilesManifest, + ("myproj/.testoptimization/manifest.txt " + actualManifest + "\n").getBytes(StandardCharsets.UTF_8)); + envProvider.set("RUNFILES_MANIFEST_FILE", runfilesManifest.toString()); + + BazelMode mode = new BazelMode(configWith("myproj/.testoptimization/manifest.txt", false)); - assertEquals(workspace.toFile().getAbsolutePath(), mode.getRepoRoot()); - } + assertTrue(mode.isManifestModeEnabled()); + } - @Test - void repoRootResolvedViaRunfilesDirWhenTestSrcDirMissing(@TempDir Path tmp) throws IOException { - Path workspace = Files.createDirectories(tmp.resolve("_main")); - envProvider.set("RUNFILES_DIR", tmp.toString()); - envProvider.set("TEST_WORKSPACE", "_main"); - - BazelMode mode = new BazelMode(configWith(null, false)); + @Test + void cachePathsResolveOnlyWhenFileExists(@TempDir Path tmp) throws IOException { + Path manifestDir = Files.createDirectories(tmp.resolve(".testoptimization")); + Path manifest = writeManifest(manifestDir, "1"); + Path httpDir = Files.createDirectories(manifestDir.resolve("cache/http")); + Files.write(httpDir.resolve("settings.json"), "{}".getBytes(StandardCharsets.UTF_8)); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertNotNull(mode.getSettingsPath()); + assertEquals(httpDir.resolve("settings.json").toFile().getAbsolutePath(), mode.getSettingsPath()); + // files that do not exist return null + assertNull(mode.getKnownTestsPath()); + assertNull(mode.getTestManagementPath()); + assertNull(mode.getFlakyTestsPath()); + } - assertEquals(workspace.toFile().getAbsolutePath(), mode.getRepoRoot()); - } - - @Test - void repoRootNullWhenTestWorkspaceMissing(@TempDir Path tmp) throws IOException { - Files.createDirectories(tmp.resolve("_main")); - envProvider.set("TEST_SRCDIR", tmp.toString()); - - BazelMode mode = new BazelMode(configWith(null, false)); - - assertNull(mode.getRepoRoot()); - } - - @Test - void repoRootNullWhenWorkspaceDirMissing(@TempDir Path tmp) { - envProvider.set("TEST_SRCDIR", tmp.toString()); - envProvider.set("TEST_WORKSPACE", "_main"); - - BazelMode mode = new BazelMode(configWith(null, false)); - - assertNull(mode.getRepoRoot()); - } - - private static Config configWith(String manifestRloc, boolean payloadsInFiles) { - Config config = mock(Config.class); - when(config.getTestOptimizationManifestFile()).thenReturn(manifestRloc); - when(config.isTestOptimizationPayloadsInFiles()).thenReturn(payloadsInFiles); - return config; - } + @Test + void payloadDirsDerivedFromUndeclaredOutputsDir(@TempDir Path tmp) { + envProvider.set("TEST_UNDECLARED_OUTPUTS_DIR", tmp.toString()); - private static Path writeManifest(Path dir, String version) throws IOException { - Files.createDirectories(dir); - Path manifest = dir.resolve("manifest.txt"); - Files.write(manifest, ("version=" + version).getBytes(StandardCharsets.UTF_8)); - return manifest; - } - - static class TestEnvironmentVariables extends EnvironmentVariables.EnvironmentVariablesProvider { - private final Map env = new HashMap<>(); + BazelMode mode = new BazelMode(configWith(null, true)); - void set(String name, String value) { - env.put(name, value); + assertTrue(mode.isEnabled()); + assertTrue(mode.isPayloadFilesEnabled()); + assertEquals(tmp.resolve("payloads").toString(), mode.getPayloadsDir()); + assertEquals(tmp.resolve("payloads").resolve("tests").toString(), mode.getTestPayloadsDir()); + assertEquals(tmp.resolve("payloads").resolve("coverage").toString(), mode.getCoveragePayloadsDir()); + assertEquals(tmp.resolve("payloads").resolve("telemetry").toString(), mode.getTelemetryPayloadsDir()); } - @Override - public String get(String name) { - return env.get(name); + @Test + void payloadFilesDisabledWhenUndeclaredOutputsDirMissing() { + BazelMode mode = new BazelMode(configWith(null, true)); + + assertFalse(mode.isPayloadFilesEnabled()); + assertNull(mode.getPayloadsDir()); + assertNull(mode.getTestPayloadsDir()); + assertNull(mode.getCoveragePayloadsDir()); + assertNull(mode.getTelemetryPayloadsDir()); } - @Override - public Map getAll() { - return env; + @Test + void repoRootResolvedViaTestSrcDir(@TempDir Path tmp) throws IOException { + Path workspace = Files.createDirectories(tmp.resolve("_main")); + envProvider.set("TEST_SRCDIR", tmp.toString()); + envProvider.set("TEST_WORKSPACE", "_main"); + + BazelMode mode = new BazelMode(configWith(null, false)); + + assertEquals(workspace.toFile().getAbsolutePath(), mode.getRepoRoot()); + } + + @Test + void repoRootResolvedViaRunfilesDirWhenTestSrcDirMissing(@TempDir Path tmp) throws IOException { + Path workspace = Files.createDirectories(tmp.resolve("_main")); + envProvider.set("RUNFILES_DIR", tmp.toString()); + envProvider.set("TEST_WORKSPACE", "_main"); + + BazelMode mode = new BazelMode(configWith(null, false)); + + assertEquals(workspace.toFile().getAbsolutePath(), mode.getRepoRoot()); + } + + @Test + void repoRootNullWhenTestWorkspaceMissing(@TempDir Path tmp) throws IOException { + Files.createDirectories(tmp.resolve("_main")); + envProvider.set("TEST_SRCDIR", tmp.toString()); + + BazelMode mode = new BazelMode(configWith(null, false)); + + assertNull(mode.getRepoRoot()); + } + + @Test + void repoRootNullWhenWorkspaceDirMissing(@TempDir Path tmp) { + envProvider.set("TEST_SRCDIR", tmp.toString()); + envProvider.set("TEST_WORKSPACE", "_main"); + + BazelMode mode = new BazelMode(configWith(null, false)); + + assertNull(mode.getRepoRoot()); + } + + private static Config configWith(String manifestRloc, boolean payloadsInFiles) { + Config config = mock(Config.class); + when(config.getTestOptimizationManifestFile()).thenReturn(manifestRloc); + when(config.isTestOptimizationPayloadsInFiles()).thenReturn(payloadsInFiles); + return config; + } + + private static Path writeManifest(Path dir, String version) throws IOException { + Files.createDirectories(dir); + Path manifest = dir.resolve("manifest.txt"); + Files.write(manifest, ("version=" + version).getBytes(StandardCharsets.UTF_8)); + return manifest; + } + + static class TestEnvironmentVariables extends EnvironmentVariables.EnvironmentVariablesProvider { + private final Map env = new HashMap<>(); + + void set(String name, String value) { + env.put(name, value); + } + + @Override + public String get(String name) { + return env.get(name); + } + + @Override + public Map getAll() { + return env; + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/api/civisibility/execution/ExecutionAggregationTest.java b/internal-api/src/test/java/datadog/trace/api/civisibility/execution/ExecutionAggregationTest.java index a97badb0712..81bb1999a3d 100644 --- a/internal-api/src/test/java/datadog/trace/api/civisibility/execution/ExecutionAggregationTest.java +++ b/internal-api/src/test/java/datadog/trace/api/civisibility/execution/ExecutionAggregationTest.java @@ -10,45 +10,45 @@ class ExecutionAggregationTest { - @Test - void noneAfterFailIsOnlyFailed() { - assertEquals(ONLY_FAILED, NONE.withExecution(TestStatus.fail)); - } - - @Test - void noneAfterPassIsOnlyPassed() { - assertEquals(ONLY_PASSED, NONE.withExecution(TestStatus.pass)); - } - - @Test - void noneAfterSkipIsOnlyPassed() { - assertEquals(ONLY_PASSED, NONE.withExecution(TestStatus.skip)); - } - - @Test - void onlyFailedAfterFailStaysOnlyFailed() { - assertEquals(ONLY_FAILED, ONLY_FAILED.withExecution(TestStatus.fail)); - } - - @Test - void onlyFailedAfterPassIsMixed() { - assertEquals(MIXED, ONLY_FAILED.withExecution(TestStatus.pass)); - } - - @Test - void onlyPassedAfterPassStaysOnlyPassed() { - assertEquals(ONLY_PASSED, ONLY_PASSED.withExecution(TestStatus.pass)); - } - - @Test - void onlyPassedAfterFailIsMixed() { - assertEquals(MIXED, ONLY_PASSED.withExecution(TestStatus.fail)); - } - - @Test - void mixedAfterAnyStatusStaysMixed() { - assertEquals(MIXED, MIXED.withExecution(TestStatus.pass)); - assertEquals(MIXED, MIXED.withExecution(TestStatus.fail)); - assertEquals(MIXED, MIXED.withExecution(TestStatus.skip)); - } + @Test + void noneAfterFailIsOnlyFailed() { + assertEquals(ONLY_FAILED, NONE.withExecution(TestStatus.fail)); + } + + @Test + void noneAfterPassIsOnlyPassed() { + assertEquals(ONLY_PASSED, NONE.withExecution(TestStatus.pass)); + } + + @Test + void noneAfterSkipIsOnlyPassed() { + assertEquals(ONLY_PASSED, NONE.withExecution(TestStatus.skip)); + } + + @Test + void onlyFailedAfterFailStaysOnlyFailed() { + assertEquals(ONLY_FAILED, ONLY_FAILED.withExecution(TestStatus.fail)); + } + + @Test + void onlyFailedAfterPassIsMixed() { + assertEquals(MIXED, ONLY_FAILED.withExecution(TestStatus.pass)); + } + + @Test + void onlyPassedAfterPassStaysOnlyPassed() { + assertEquals(ONLY_PASSED, ONLY_PASSED.withExecution(TestStatus.pass)); + } + + @Test + void onlyPassedAfterFailIsMixed() { + assertEquals(MIXED, ONLY_PASSED.withExecution(TestStatus.fail)); + } + + @Test + void mixedAfterAnyStatusStaysMixed() { + assertEquals(MIXED, MIXED.withExecution(TestStatus.pass)); + assertEquals(MIXED, MIXED.withExecution(TestStatus.fail)); + assertEquals(MIXED, MIXED.withExecution(TestStatus.skip)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/debugger/DebuggerMetricCollectorTest.java b/internal-api/src/test/java/datadog/trace/api/debugger/DebuggerMetricCollectorTest.java index 671a7b97d63..9b87829bf88 100644 --- a/internal-api/src/test/java/datadog/trace/api/debugger/DebuggerMetricCollectorTest.java +++ b/internal-api/src/test/java/datadog/trace/api/debugger/DebuggerMetricCollectorTest.java @@ -15,105 +15,105 @@ class DebuggerMetricCollectorTest { - private final DebuggerMetricCollector collector = DebuggerMetricCollector.get(); - - @BeforeEach - public void clearQueue() { - collector.drain(); - } - - @Test - public void drainWithoutRecordsReturnsEmpty() { - assertEquals(0, collector.drain().size()); - } - - @Test - public void prepareMetricsWithoutRecordsProducesNoMetric() { - collector.prepareMetrics(); - assertEquals(0, collector.drain().size()); - } - - @Test - public void recordEventDroppedSurfacesOnPrepareAndDrain() { - collector.recordEventDropped(QUEUE_FULL); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - assertEquals(1, metrics.size()); - DebuggerMetric metric = metrics.iterator().next(); - assertEquals("live_debugger", metric.namespace); - assertEquals("events.dropped", metric.metricName); - assertEquals("count", metric.type); - assertTrue(metric.common); - assertEquals(1L, metric.value); - assertEquals(Collections.singletonList("reason:queueFull"), metric.tags); - } - - @Test - public void recordEventSkippedSurfacesOnPrepareAndDrain() { - collector.recordEventSkipped(EVALUATION_TIME_OUT); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - assertEquals(1, metrics.size()); - DebuggerMetric metric = metrics.iterator().next(); - assertEquals("events.skipped", metric.metricName); - assertEquals(1L, metric.value); - assertEquals(Collections.singletonList("reason:evaluationTimeOut"), metric.tags); - } - - @Test - public void countersAccumulateBeforePrepare() { - collector.recordEventDropped(QUEUE_FULL); - collector.recordEventDropped(QUEUE_FULL); - collector.recordEventDropped(QUEUE_FULL); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - assertEquals(1, metrics.size()); - assertEquals(3L, metrics.iterator().next().value); - } - - @Test - public void distinctReasonsProduceDistinctMetrics() { - collector.recordEventDropped(QUEUE_FULL); - collector.recordEventDropped(PAYLOAD_TOO_LARGE); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - assertEquals(2, metrics.size()); - assertTrue(metrics.stream().anyMatch(m -> m.tags.contains("reason:queueFull"))); - assertTrue(metrics.stream().anyMatch(m -> m.tags.contains("reason:payloadTooLarge"))); - } - - @Test - public void droppedAndSkippedReasonsAreIndependent() { - collector.recordEventDropped(QUEUE_FULL); - collector.recordEventSkipped(RATE_LIMIT); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - assertEquals(2, metrics.size()); - assertTrue(metrics.stream().anyMatch(m -> m.metricName.equals("events.dropped"))); - assertTrue(metrics.stream().anyMatch(m -> m.metricName.equals("events.skipped"))); - } - - @Test - public void prepareMetricsResetsCountersAfterDrain() { - collector.recordEventDropped(QUEUE_FULL); - collector.prepareMetrics(); - assertEquals(1, collector.drain().size()); - - // no new events recorded, so a second prepare/drain cycle should be empty - collector.prepareMetrics(); - assertEquals(0, collector.drain().size()); - } - - @Test - public void drainClearsQueue() { - collector.recordEventDropped(QUEUE_FULL); - collector.prepareMetrics(); - assertEquals(1, collector.drain().size()); - assertEquals(0, collector.drain().size()); - } + private final DebuggerMetricCollector collector = DebuggerMetricCollector.get(); + + @BeforeEach + public void clearQueue() { + collector.drain(); + } + + @Test + public void drainWithoutRecordsReturnsEmpty() { + assertEquals(0, collector.drain().size()); + } + + @Test + public void prepareMetricsWithoutRecordsProducesNoMetric() { + collector.prepareMetrics(); + assertEquals(0, collector.drain().size()); + } + + @Test + public void recordEventDroppedSurfacesOnPrepareAndDrain() { + collector.recordEventDropped(QUEUE_FULL); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + assertEquals(1, metrics.size()); + DebuggerMetric metric = metrics.iterator().next(); + assertEquals("live_debugger", metric.namespace); + assertEquals("events.dropped", metric.metricName); + assertEquals("count", metric.type); + assertTrue(metric.common); + assertEquals(1L, metric.value); + assertEquals(Collections.singletonList("reason:queueFull"), metric.tags); + } + + @Test + public void recordEventSkippedSurfacesOnPrepareAndDrain() { + collector.recordEventSkipped(EVALUATION_TIME_OUT); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + assertEquals(1, metrics.size()); + DebuggerMetric metric = metrics.iterator().next(); + assertEquals("events.skipped", metric.metricName); + assertEquals(1L, metric.value); + assertEquals(Collections.singletonList("reason:evaluationTimeOut"), metric.tags); + } + + @Test + public void countersAccumulateBeforePrepare() { + collector.recordEventDropped(QUEUE_FULL); + collector.recordEventDropped(QUEUE_FULL); + collector.recordEventDropped(QUEUE_FULL); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + assertEquals(1, metrics.size()); + assertEquals(3L, metrics.iterator().next().value); + } + + @Test + public void distinctReasonsProduceDistinctMetrics() { + collector.recordEventDropped(QUEUE_FULL); + collector.recordEventDropped(PAYLOAD_TOO_LARGE); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + assertEquals(2, metrics.size()); + assertTrue(metrics.stream().anyMatch(m -> m.tags.contains("reason:queueFull"))); + assertTrue(metrics.stream().anyMatch(m -> m.tags.contains("reason:payloadTooLarge"))); + } + + @Test + public void droppedAndSkippedReasonsAreIndependent() { + collector.recordEventDropped(QUEUE_FULL); + collector.recordEventSkipped(RATE_LIMIT); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + assertEquals(2, metrics.size()); + assertTrue(metrics.stream().anyMatch(m -> m.metricName.equals("events.dropped"))); + assertTrue(metrics.stream().anyMatch(m -> m.metricName.equals("events.skipped"))); + } + + @Test + public void prepareMetricsResetsCountersAfterDrain() { + collector.recordEventDropped(QUEUE_FULL); + collector.prepareMetrics(); + assertEquals(1, collector.drain().size()); + + // no new events recorded, so a second prepare/drain cycle should be empty + collector.prepareMetrics(); + assertEquals(0, collector.drain().size()); + } + + @Test + public void drainClearsQueue() { + collector.recordEventDropped(QUEUE_FULL); + collector.prepareMetrics(); + assertEquals(1, collector.drain().size()); + assertEquals(0, collector.drain().size()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/gateway/BlockResponseFunctionTest.java b/internal-api/src/test/java/datadog/trace/api/gateway/BlockResponseFunctionTest.java index e07f49ceb12..39b8488d659 100644 --- a/internal-api/src/test/java/datadog/trace/api/gateway/BlockResponseFunctionTest.java +++ b/internal-api/src/test/java/datadog/trace/api/gateway/BlockResponseFunctionTest.java @@ -20,122 +20,122 @@ */ class BlockResponseFunctionTest { - private static final Flow.Action.RequestBlockingAction RBA = - new Flow.Action.RequestBlockingAction(403, BlockingContentType.AUTO); + private static final Flow.Action.RequestBlockingAction RBA = + new Flow.Action.RequestBlockingAction(403, BlockingContentType.AUTO); - @Test - void doesNotReportBlockFailureWhenCommitSucceeds() { - CountingAppSecContext appSecCtx = new CountingAppSecContext(); - TestRequestContext ctx = new TestRequestContext(appSecCtx); - TestBlockResponseFunction brf = new TestBlockResponseFunction(true); + @Test + void doesNotReportBlockFailureWhenCommitSucceeds() { + CountingAppSecContext appSecCtx = new CountingAppSecContext(); + TestRequestContext ctx = new TestRequestContext(appSecCtx); + TestBlockResponseFunction brf = new TestBlockResponseFunction(true); - assertTrue(brf.tryCommitBlockingResponse(ctx, RBA)); + assertTrue(brf.tryCommitBlockingResponse(ctx, RBA)); - assertSame(ctx.traceSegment, brf.lastSegment); - assertEquals(403, brf.lastStatusCode); - assertEquals(BlockingContentType.AUTO, brf.lastTemplateType); - assertEquals(0, appSecCtx.blockFailures); - } - - @Test - void reportsBlockFailureWhenCommitFails() { - CountingAppSecContext appSecCtx = new CountingAppSecContext(); - TestRequestContext ctx = new TestRequestContext(appSecCtx); - TestBlockResponseFunction brf = new TestBlockResponseFunction(false); - - assertFalse(brf.tryCommitBlockingResponse(ctx, RBA)); + assertSame(ctx.traceSegment, brf.lastSegment); + assertEquals(403, brf.lastStatusCode); + assertEquals(BlockingContentType.AUTO, brf.lastTemplateType); + assertEquals(0, appSecCtx.blockFailures); + } - assertSame(ctx.traceSegment, brf.lastSegment); - assertEquals(1, appSecCtx.blockFailures); - } + @Test + void reportsBlockFailureWhenCommitFails() { + CountingAppSecContext appSecCtx = new CountingAppSecContext(); + TestRequestContext ctx = new TestRequestContext(appSecCtx); + TestBlockResponseFunction brf = new TestBlockResponseFunction(false); - @Test - void doesNotThrowWhenAppSecSlotDoesNotHoldAnAppSecContext() { - TestBlockResponseFunction brf = new TestBlockResponseFunction(false); + assertFalse(brf.tryCommitBlockingResponse(ctx, RBA)); - assertFalse(brf.tryCommitBlockingResponse(new TestRequestContext(null), RBA)); - assertFalse(brf.tryCommitBlockingResponse(new TestRequestContext("not an AppSecContext"), RBA)); - } + assertSame(ctx.traceSegment, brf.lastSegment); + assertEquals(1, appSecCtx.blockFailures); + } - private static final class CountingAppSecContext implements AppSecContext { - private int blockFailures; + @Test + void doesNotThrowWhenAppSecSlotDoesNotHoldAnAppSecContext() { + TestBlockResponseFunction brf = new TestBlockResponseFunction(false); - @Override - public boolean isManuallyKept() { - return false; + assertFalse(brf.tryCommitBlockingResponse(new TestRequestContext(null), RBA)); + assertFalse(brf.tryCommitBlockingResponse(new TestRequestContext("not an AppSecContext"), RBA)); } - @Override - public void reportBlockFailure() { - blockFailures++; - } - } + private static final class CountingAppSecContext implements AppSecContext { + private int blockFailures; - private static final class TestBlockResponseFunction implements BlockResponseFunction { - private final boolean committed; - private TraceSegment lastSegment; - private int lastStatusCode; - private BlockingContentType lastTemplateType; + @Override + public boolean isManuallyKept() { + return false; + } - private TestBlockResponseFunction(boolean committed) { - this.committed = committed; + @Override + public void reportBlockFailure() { + blockFailures++; + } } - @Override - public boolean tryCommitBlockingResponse( - TraceSegment segment, - int statusCode, - BlockingContentType templateType, - Map extraHeaders, - String securityResponseId) { - this.lastSegment = segment; - this.lastStatusCode = statusCode; - this.lastTemplateType = templateType; - return committed; + private static final class TestBlockResponseFunction implements BlockResponseFunction { + private final boolean committed; + private TraceSegment lastSegment; + private int lastStatusCode; + private BlockingContentType lastTemplateType; + + private TestBlockResponseFunction(boolean committed) { + this.committed = committed; + } + + @Override + public boolean tryCommitBlockingResponse( + TraceSegment segment, + int statusCode, + BlockingContentType templateType, + Map extraHeaders, + String securityResponseId) { + this.lastSegment = segment; + this.lastStatusCode = statusCode; + this.lastTemplateType = templateType; + return committed; + } } - } - private static final class TestRequestContext implements RequestContext { - private final Object appSecData; - private final TraceSegment traceSegment = TraceSegment.NoOp.INSTANCE; + private static final class TestRequestContext implements RequestContext { + private final Object appSecData; + private final TraceSegment traceSegment = TraceSegment.NoOp.INSTANCE; - private TestRequestContext(Object appSecData) { - this.appSecData = appSecData; - } + private TestRequestContext(Object appSecData) { + this.appSecData = appSecData; + } - @SuppressWarnings("unchecked") - @Override - public T getData(RequestContextSlot slot) { - return slot == RequestContextSlot.APPSEC ? (T) appSecData : null; - } + @SuppressWarnings("unchecked") + @Override + public T getData(RequestContextSlot slot) { + return slot == RequestContextSlot.APPSEC ? (T) appSecData : null; + } - @Override - public TraceSegment getTraceSegment() { - return traceSegment; - } + @Override + public TraceSegment getTraceSegment() { + return traceSegment; + } - @Override - public void setBlockResponseFunction(BlockResponseFunction blockResponseFunction) {} + @Override + public void setBlockResponseFunction(BlockResponseFunction blockResponseFunction) {} - @Override - public BlockResponseFunction getBlockResponseFunction() { - return null; - } + @Override + public BlockResponseFunction getBlockResponseFunction() { + return null; + } - @Override - public T getOrCreateMetaStructTop(String key, Function defaultValue) { - return null; - } + @Override + public T getOrCreateMetaStructTop(String key, Function defaultValue) { + return null; + } - @Override - public void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {} + @Override + public void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {} - @Override - public ClientIpAddressData getClientIpAddressData() { - return null; - } + @Override + public ClientIpAddressData getClientIpAddressData() { + return null; + } - @Override - public void close() {} - } + @Override + public void close() {} + } } diff --git a/internal-api/src/test/java/datadog/trace/api/gateway/InferredProxySpanTests.java b/internal-api/src/test/java/datadog/trace/api/gateway/InferredProxySpanTests.java index 309e8616080..2d89342af27 100644 --- a/internal-api/src/test/java/datadog/trace/api/gateway/InferredProxySpanTests.java +++ b/internal-api/src/test/java/datadog/trace/api/gateway/InferredProxySpanTests.java @@ -36,691 +36,688 @@ @DisplayName("InferredProxyContext Tests") class InferredProxySpanTests { - @Test - @DisplayName("Valid headers should make valid span") - void testMapConstructor() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - - InferredProxySpan inferredProxySpan = InferredProxySpan.fromHeaders(headers); - assertTrue(inferredProxySpan.isValid()); - assertNotNull( - inferredProxySpan.start(null), "inferred proxy span start and return new parent context"); - assertNull(inferredProxySpan.start(null), "inferred proxy span not should start twice"); - inferredProxySpan.finish(); - } - - @ParameterizedTest(name = "{0}") - @DisplayName("Invalid headers should make invalid span") - @MethodSource("invalidHeaders") - void testInvalidHeaders(String useCase, Map headers) { - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertFalse(inferredProxySpan.isValid(), useCase + " should not be valid"); - assertNull(inferredProxySpan.start(null), "Invalid inferred proxy span should not start"); - } - - static Stream invalidHeaders() { // Renamed - Map missingSystem = new HashMap<>(); - missingSystem.put(PROXY_START_TIME_MS, "12345"); - - Map missingTime = new HashMap<>(); - missingTime.put(PROXY_SYSTEM, "aws-apigateway"); - Map invalidSystem = new HashMap<>(); - invalidSystem.put(PROXY_START_TIME_MS, "12345"); - invalidSystem.put(PROXY_SYSTEM, "invalidSystem"); - - return Stream.of( - of("Missing system headers", missingSystem), - of("Missing start time headers", missingTime), - of("Invalid system headers", invalidSystem)); - } - - @Test - @DisplayName("Constructor with null should not crash") - void testNullMapConstructor() { - InferredProxySpan inferredProxySpan = fromHeaders(null); - assertNotNull(inferredProxySpan); - assertFalse(inferredProxySpan.isValid()); - } - - @Test - @DisplayName("Constructor with empty map should be invalid") - void testEmptyMapConstructor() { - InferredProxySpan inferredProxySpan = fromHeaders(emptyMap()); - assertNotNull(inferredProxySpan); - assertFalse(inferredProxySpan.isValid()); - } - - @Test - @DisplayName("storeInto and fromContext should correctly attach and retrieve the context") - void testStoreAndFromContext() { - InferredProxySpan inferredProxySpan = fromHeaders(null); - Context context = inferredProxySpan.storeInto(root()); - assertNotNull(context); - - InferredProxySpan retrieved = fromContext(context); - assertNotNull(retrieved); - - assertNull(fromContext(root()), "fromContext on empty context should be null"); - } - - @Test - @DisplayName("Invalid start time should return extracted context") - void testInvalidStartTime() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "invalid-number"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertTrue(inferredProxySpan.isValid()); - assertNull(inferredProxySpan.start(null), "Invalid start time should return null"); - } - - @Test - @DisplayName("Service name should fallback to config when domain name is null") - void testServiceNameFallbackNull() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - // Service name should use Config.get().getServiceName() when domain name is null - } - - @Test - @DisplayName("Service name should fallback to config when domain name is empty") - void testServiceNameFallbackEmpty() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, ""); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - // Service name should use Config.get().getServiceName() when domain name is empty - } - - @Test - @DisplayName("HTTP URL should use path only when domain name is null") - void testHttpUrlWithoutDomain() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - // HTTP URL should be just the path when domain name is null - } - - @Test - @DisplayName("Resource name should be null when httpMethod is null") - void testResourceNameNullMethod() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - // Resource name should be null when httpMethod is null - } - - @Test - @DisplayName("Resource name should be null when path is null") - void testResourceNameNullPath() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - // Resource name should be null when path is null - } - - @Test - @DisplayName("Finish should handle null span gracefully") - void testFinishWithNullSpan() { - InferredProxySpan inferredProxySpan = fromHeaders(null); - // Should not throw exception when span is null - inferredProxySpan.finish(); - assertFalse(inferredProxySpan.isValid()); - } - - @Test - @DisplayName("Finish should clear span after finishing") - void testFinishClearsSpan() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - inferredProxySpan.finish(); - // Span should be cleared after finish, so calling finish again should be safe - inferredProxySpan.finish(); - } - - @Test - @DisplayName("aws-httpapi proxy type should be valid and create span") - void testAwsHttpApiProxyType() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-httpapi"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertTrue(inferredProxySpan.isValid(), "aws-httpapi should be a valid proxy system"); - assertNotNull(inferredProxySpan.start(null), "aws-httpapi should create a span"); - inferredProxySpan.finish(); - } - - @ParameterizedTest(name = "Proxy system: {0}") - @DisplayName("Both v1 and v2 proxy systems should be supported") - @MethodSource("supportedProxySystems") - void testSupportedProxySystems(String proxySystem, String expectedSpanName) { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, proxySystem); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertTrue(inferredProxySpan.isValid(), proxySystem + " should be valid"); - assertNotNull(inferredProxySpan.start(null), proxySystem + " should create span"); - inferredProxySpan.finish(); - } - - static Stream supportedProxySystems() { - return Stream.of( - of("aws-apigateway", "aws.apigateway"), - of("aws-httpapi", "aws.httpapi"), - of("azure-apim", "azure.apim")); - } - - @Test - @DisplayName("Inferred proxy span should have span.kind=server tag") - void testSpanKindServerTag() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("aws-httpapi span should also have span.kind=server tag") - void testAwsHttpApiSpanKindServerTag() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-httpapi"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/v2/resource"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - inferredProxySpan.finish(); - } - - @Test - @DisplayName("http.url should include https:// scheme when domain name is present") - void testHttpUrlWithHttpsScheme() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected URL: https://api.example.com/api/users - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("http.url should be path only when domain name is null") - void testHttpUrlWithoutHttpsSchemeWhenNoDomain() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users"); - // No PROXY_DOMAIN_NAME - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected URL: /api/users (no scheme, just path) - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("http.url with https scheme should work for aws-httpapi") - void testAwsHttpApiHttpUrlWithHttpsScheme() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-httpapi"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); - headers.put(InferredProxySpan.PROXY_PATH, "/v2/items"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "httpapi.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected URL: https://httpapi.example.com/v2/items - - inferredProxySpan.finish(); - } - - // Task 13: Tests for http.route from resourcePath with fallback - @Test - @DisplayName("http.route should use resourcePath when x-dd-proxy-resource-path is present") - void testHttpRouteFromResourcePath() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); - headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/api/users/{id}"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected http.route: /api/users/{id} (from resourcePath) - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("http.route should fallback to path when x-dd-proxy-resource-path is not present") - void testHttpRouteFallbackToPath() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); - // No PROXY_RESOURCE_PATH - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected http.route: /api/users/123 (fallback to path for backwards compat) - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("http.route should use resourcePath for aws-httpapi") - void testAwsHttpApiHttpRouteFromResourcePath() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-httpapi"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); - headers.put(InferredProxySpan.PROXY_PATH, "/v2/items/abc-123"); - headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/v2/items/{itemId}"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "httpapi.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected http.route: /v2/items/{itemId} - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("resource.name should prefer route when resourcePath is present") - void testResourceNamePrefersRoute() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); - headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/api/users/{id}"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected resource.name: "GET /api/users/{id}" (uses route from resourcePath) - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("resource.name should use path when resourcePath is not present") - void testResourceNameFallbackToPath() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); - // No PROXY_RESOURCE_PATH - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected resource.name: "GET /api/users/123" (uses path) - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("resource.name should prefer route for POST requests") - void testResourceNamePrefersRouteForPost() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-httpapi"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); - headers.put(InferredProxySpan.PROXY_PATH, "/v2/orders/order-456"); - headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/v2/orders/{orderId}"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected resource.name: "POST /v2/orders/{orderId}" - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("resource.name should handle complex route patterns") - void testResourceNameWithComplexRoute() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "PUT"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/v1/users/123/posts/456/comments/789"); - headers.put( - InferredProxySpan.PROXY_RESOURCE_PATH, - "/api/v1/users/{userId}/posts/{postId}/comments/{commentId}"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected resource.name: "PUT /api/v1/users/{userId}/posts/{postId}/comments/{commentId}" - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("resource.name should be null when both httpMethod and path are null") - void testResourceNameNullWhenBothNull() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - // No PROXY_HTTP_METHOD and no PROXY_PATH - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Expected resource.name: null - - inferredProxySpan.finish(); - } - - // Note: These tests verify the copyAppSecTagsFromRoot() logic exists and doesn't crash. - // Full integration testing of AppSec tag propagation requires the actual tracer - // infrastructure and is better suited for integration tests. - - @Test - @DisplayName("InferredProxySpan finish should not crash when no AppSec tags present") - void testFinishWithoutAppSecTags() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // finish() should execute copyAppSecTagsFromRoot() without errors - // even when no AppSec tags are present - inferredProxySpan.finish(); - } - - @Test - @DisplayName("InferredProxySpan finish should handle null root span gracefully") - void testFinishWithNullRootSpan() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // finish() should handle the case where getLocalRootSpan() might return null - inferredProxySpan.finish(); - } - - @Test - @DisplayName("InferredProxySpan finish should work for both v1 and v2 proxy types") - void testFinishWithDifferentProxyTypes() { - // Test with aws-apigateway (v1) - Map headersV1 = new HashMap<>(); - headersV1.put(PROXY_START_TIME_MS, "12345"); - headersV1.put(PROXY_SYSTEM, "aws-apigateway"); - headersV1.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headersV1.put(InferredProxySpan.PROXY_PATH, "/v1/test"); - - InferredProxySpan proxySpanV1 = fromHeaders(headersV1); - assertNotNull(proxySpanV1.start(null)); - proxySpanV1.finish(); - - // Test with aws-httpapi (v2) - Map headersV2 = new HashMap<>(); - headersV2.put(PROXY_START_TIME_MS, "12345"); - headersV2.put(PROXY_SYSTEM, "aws-httpapi"); - headersV2.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); - headersV2.put(InferredProxySpan.PROXY_PATH, "/v2/test"); - - InferredProxySpan proxySpanV2 = fromHeaders(headersV2); - assertNotNull(proxySpanV2.start(null)); - proxySpanV2.finish(); - } - - @Test - @DisplayName("InferredProxySpan finish should be idempotent") - void testFinishIsIdempotent() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/test"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // Call finish multiple times - should not crash - inferredProxySpan.finish(); - inferredProxySpan.finish(); - inferredProxySpan.finish(); - } - - @Test - @DisplayName("InferredProxySpan with all headers should finish successfully") - void testFinishWithAllHeaders() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); - headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/api/users/{id}"); - headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); - headers.put(InferredProxySpan.STAGE, "prod"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - // With all headers present, finish should work correctly - inferredProxySpan.finish(); - } - - @Test - @DisplayName("optional API Gateway metadata should exercise ARN tag computation") - void testStartWithOptionalApiGatewayMetadata() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); - headers.put(InferredProxySpan.PROXY_API_ID, "api-id"); - headers.put(InferredProxySpan.PROXY_REGION, "us-east-1"); - headers.put(InferredProxySpan.PROXY_ACCOUNT_ID, "123456789012"); - - InferredProxySpan inferredProxySpan = fromHeaders(headers); - assertNotNull(inferredProxySpan.start(null)); - - inferredProxySpan.finish(); - } - - @Test - @DisplayName("computeArn should support known proxy systems and reject unknown input") - void testComputeArn() { - InferredProxySpan inferredProxySpan = fromHeaders(null); - - assertEquals( - "arn:aws:apigateway:us-east-1::/restapis/api-id", - inferredProxySpan.computeArn("aws-apigateway", "us-east-1", "api-id")); - assertEquals( - "arn:aws:apigateway:us-east-1::/apis/api-id", - inferredProxySpan.computeArn("aws-httpapi", "us-east-1", "api-id")); - assertNull(inferredProxySpan.computeArn("unknown", "us-east-1", "api-id")); - assertNull(inferredProxySpan.computeArn("aws-apigateway", null, "api-id")); - assertNull(inferredProxySpan.computeArn("aws-apigateway", "us-east-1", null)); - } - - @Test - @DisplayName("Multiple InferredProxySpan instances should finish independently") - void testMultipleProxySpansFinishIndependently() { - // Create first proxy span - Map headers1 = new HashMap<>(); - headers1.put(PROXY_START_TIME_MS, "12345"); - headers1.put(PROXY_SYSTEM, "aws-apigateway"); - headers1.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); - headers1.put(InferredProxySpan.PROXY_PATH, "/test1"); - - InferredProxySpan proxySpan1 = fromHeaders(headers1); - assertNotNull(proxySpan1.start(null)); - - // Create second proxy span - Map headers2 = new HashMap<>(); - headers2.put(PROXY_START_TIME_MS, "12346"); - headers2.put(PROXY_SYSTEM, "aws-httpapi"); - headers2.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); - headers2.put(InferredProxySpan.PROXY_PATH, "/test2"); - - InferredProxySpan proxySpan2 = fromHeaders(headers2); - assertNotNull(proxySpan2.start(null)); - - // Finish both - should work independently - proxySpan1.finish(); - proxySpan2.finish(); - } - - @Test - @DisplayName( - "finish forwards the Datadog scan/test markers from the service-entry span to the inferred span") - void testFinishForwardsSecurityTestingHeaders() throws Exception { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - InferredProxySpan inferredProxySpan = fromHeaders(headers); - - // Replace the real (noop) inferred span with a mock we can verify against. Drive through - // the public finish() API so the test stays valid if the internal copy-helper is renamed. - AgentSpan mockInferredSpan = mock(AgentSpan.class); - // Keep this reflected field name in sync with InferredProxySpan.span. - Field spanField = InferredProxySpan.class.getDeclaredField("span"); - spanField.setAccessible(true); - spanField.set(inferredProxySpan, mockInferredSpan); - - AgentSpan serviceEntrySpan = mock(AgentSpan.class); - when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN)) - .thenReturn("scan-uuid"); - when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST)) - .thenReturn("test-uuid"); - inferredProxySpan.registerServiceEntrySpan(serviceEntrySpan); - - inferredProxySpan.finish(serviceEntrySpan); - - verify(mockInferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN, "scan-uuid"); - verify(mockInferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST, "test-uuid"); - } - - @Test - @DisplayName("finish should phase child calls and publish after service-entry completion") - void testFinishPhasesChildCallAndPublishesOnServiceEntry() throws Exception { - InferredProxySpan inferredProxySpan = fromHeaders(validHeaders()); - - AgentSpan inferredSpan = mock(AgentSpan.class); - inferredProxySpan.span = inferredSpan; - - AgentSpan childSpan = mock(AgentSpan.class); - when(childSpan.getTag("_dd.appsec.enabled")).thenReturn(Boolean.TRUE); - when(childSpan.getTag("_dd.appsec.json")).thenReturn("{\"triggers\":[]}"); - - AgentSpan serviceEntrySpan = mock(AgentSpan.class); - when(serviceEntrySpan.getHttpStatusCode()).thenReturn((short) 503); - when(serviceEntrySpan.getTag(HTTP_USER_AGENT)).thenReturn("curl/8.0"); - when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN)) - .thenReturn("scan-uuid"); - when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST)) - .thenReturn("test-uuid"); - - inferredProxySpan.registerServiceEntrySpan(serviceEntrySpan); - - inferredProxySpan.finish(childSpan); - inferredProxySpan.finish(childSpan); - verify(inferredSpan).setMetric("_dd.appsec.enabled", 1); - verify(inferredSpan).setTag("_dd.appsec.json", "{\"triggers\":[]}"); - verify(inferredSpan, times(1)).phasedFinish(); - verify(inferredSpan, never()).finish(); - verify(inferredSpan, never()).publish(); - - inferredProxySpan.finish(serviceEntrySpan); - - verify(inferredSpan).setHttpStatusCode(503); - verify(inferredSpan).setError(true, HTTP_SERVER_DECORATOR); - verify(inferredSpan).setTag(HTTP_USER_AGENT, "curl/8.0"); - verify(inferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN, "scan-uuid"); - verify(inferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST, "test-uuid"); - verify(inferredSpan).publish(); - verify(inferredSpan, never()).finish(); - } - - private static Map validHeaders() { - Map headers = new HashMap<>(); - headers.put(PROXY_START_TIME_MS, "12345"); - headers.put(PROXY_SYSTEM, "aws-apigateway"); - return headers; - } + @Test + @DisplayName("Valid headers should make valid span") + void testMapConstructor() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + + InferredProxySpan inferredProxySpan = InferredProxySpan.fromHeaders(headers); + assertTrue(inferredProxySpan.isValid()); + assertNotNull(inferredProxySpan.start(null), "inferred proxy span start and return new parent context"); + assertNull(inferredProxySpan.start(null), "inferred proxy span not should start twice"); + inferredProxySpan.finish(); + } + + @ParameterizedTest(name = "{0}") + @DisplayName("Invalid headers should make invalid span") + @MethodSource("invalidHeaders") + void testInvalidHeaders(String useCase, Map headers) { + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertFalse(inferredProxySpan.isValid(), useCase + " should not be valid"); + assertNull(inferredProxySpan.start(null), "Invalid inferred proxy span should not start"); + } + + static Stream invalidHeaders() { // Renamed + Map missingSystem = new HashMap<>(); + missingSystem.put(PROXY_START_TIME_MS, "12345"); + + Map missingTime = new HashMap<>(); + missingTime.put(PROXY_SYSTEM, "aws-apigateway"); + Map invalidSystem = new HashMap<>(); + invalidSystem.put(PROXY_START_TIME_MS, "12345"); + invalidSystem.put(PROXY_SYSTEM, "invalidSystem"); + + return Stream.of( + of("Missing system headers", missingSystem), + of("Missing start time headers", missingTime), + of("Invalid system headers", invalidSystem)); + } + + @Test + @DisplayName("Constructor with null should not crash") + void testNullMapConstructor() { + InferredProxySpan inferredProxySpan = fromHeaders(null); + assertNotNull(inferredProxySpan); + assertFalse(inferredProxySpan.isValid()); + } + + @Test + @DisplayName("Constructor with empty map should be invalid") + void testEmptyMapConstructor() { + InferredProxySpan inferredProxySpan = fromHeaders(emptyMap()); + assertNotNull(inferredProxySpan); + assertFalse(inferredProxySpan.isValid()); + } + + @Test + @DisplayName("storeInto and fromContext should correctly attach and retrieve the context") + void testStoreAndFromContext() { + InferredProxySpan inferredProxySpan = fromHeaders(null); + Context context = inferredProxySpan.storeInto(root()); + assertNotNull(context); + + InferredProxySpan retrieved = fromContext(context); + assertNotNull(retrieved); + + assertNull(fromContext(root()), "fromContext on empty context should be null"); + } + + @Test + @DisplayName("Invalid start time should return extracted context") + void testInvalidStartTime() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "invalid-number"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertTrue(inferredProxySpan.isValid()); + assertNull(inferredProxySpan.start(null), "Invalid start time should return null"); + } + + @Test + @DisplayName("Service name should fallback to config when domain name is null") + void testServiceNameFallbackNull() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + // Service name should use Config.get().getServiceName() when domain name is null + } + + @Test + @DisplayName("Service name should fallback to config when domain name is empty") + void testServiceNameFallbackEmpty() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, ""); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + // Service name should use Config.get().getServiceName() when domain name is empty + } + + @Test + @DisplayName("HTTP URL should use path only when domain name is null") + void testHttpUrlWithoutDomain() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + // HTTP URL should be just the path when domain name is null + } + + @Test + @DisplayName("Resource name should be null when httpMethod is null") + void testResourceNameNullMethod() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + // Resource name should be null when httpMethod is null + } + + @Test + @DisplayName("Resource name should be null when path is null") + void testResourceNameNullPath() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + // Resource name should be null when path is null + } + + @Test + @DisplayName("Finish should handle null span gracefully") + void testFinishWithNullSpan() { + InferredProxySpan inferredProxySpan = fromHeaders(null); + // Should not throw exception when span is null + inferredProxySpan.finish(); + assertFalse(inferredProxySpan.isValid()); + } + + @Test + @DisplayName("Finish should clear span after finishing") + void testFinishClearsSpan() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + inferredProxySpan.finish(); + // Span should be cleared after finish, so calling finish again should be safe + inferredProxySpan.finish(); + } + + @Test + @DisplayName("aws-httpapi proxy type should be valid and create span") + void testAwsHttpApiProxyType() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-httpapi"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertTrue(inferredProxySpan.isValid(), "aws-httpapi should be a valid proxy system"); + assertNotNull(inferredProxySpan.start(null), "aws-httpapi should create a span"); + inferredProxySpan.finish(); + } + + @ParameterizedTest(name = "Proxy system: {0}") + @DisplayName("Both v1 and v2 proxy systems should be supported") + @MethodSource("supportedProxySystems") + void testSupportedProxySystems(String proxySystem, String expectedSpanName) { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, proxySystem); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertTrue(inferredProxySpan.isValid(), proxySystem + " should be valid"); + assertNotNull(inferredProxySpan.start(null), proxySystem + " should create span"); + inferredProxySpan.finish(); + } + + static Stream supportedProxySystems() { + return Stream.of( + of("aws-apigateway", "aws.apigateway"), + of("aws-httpapi", "aws.httpapi"), + of("azure-apim", "azure.apim")); + } + + @Test + @DisplayName("Inferred proxy span should have span.kind=server tag") + void testSpanKindServerTag() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("aws-httpapi span should also have span.kind=server tag") + void testAwsHttpApiSpanKindServerTag() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-httpapi"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/v2/resource"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + inferredProxySpan.finish(); + } + + @Test + @DisplayName("http.url should include https:// scheme when domain name is present") + void testHttpUrlWithHttpsScheme() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected URL: https://api.example.com/api/users + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("http.url should be path only when domain name is null") + void testHttpUrlWithoutHttpsSchemeWhenNoDomain() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users"); + // No PROXY_DOMAIN_NAME + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected URL: /api/users (no scheme, just path) + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("http.url with https scheme should work for aws-httpapi") + void testAwsHttpApiHttpUrlWithHttpsScheme() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-httpapi"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); + headers.put(InferredProxySpan.PROXY_PATH, "/v2/items"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "httpapi.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected URL: https://httpapi.example.com/v2/items + + inferredProxySpan.finish(); + } + + // Task 13: Tests for http.route from resourcePath with fallback + @Test + @DisplayName("http.route should use resourcePath when x-dd-proxy-resource-path is present") + void testHttpRouteFromResourcePath() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); + headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/api/users/{id}"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected http.route: /api/users/{id} (from resourcePath) + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("http.route should fallback to path when x-dd-proxy-resource-path is not present") + void testHttpRouteFallbackToPath() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); + // No PROXY_RESOURCE_PATH + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected http.route: /api/users/123 (fallback to path for backwards compat) + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("http.route should use resourcePath for aws-httpapi") + void testAwsHttpApiHttpRouteFromResourcePath() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-httpapi"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); + headers.put(InferredProxySpan.PROXY_PATH, "/v2/items/abc-123"); + headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/v2/items/{itemId}"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "httpapi.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected http.route: /v2/items/{itemId} + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("resource.name should prefer route when resourcePath is present") + void testResourceNamePrefersRoute() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); + headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/api/users/{id}"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected resource.name: "GET /api/users/{id}" (uses route from resourcePath) + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("resource.name should use path when resourcePath is not present") + void testResourceNameFallbackToPath() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); + // No PROXY_RESOURCE_PATH + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected resource.name: "GET /api/users/123" (uses path) + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("resource.name should prefer route for POST requests") + void testResourceNamePrefersRouteForPost() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-httpapi"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); + headers.put(InferredProxySpan.PROXY_PATH, "/v2/orders/order-456"); + headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/v2/orders/{orderId}"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected resource.name: "POST /v2/orders/{orderId}" + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("resource.name should handle complex route patterns") + void testResourceNameWithComplexRoute() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "PUT"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/v1/users/123/posts/456/comments/789"); + headers.put( + InferredProxySpan.PROXY_RESOURCE_PATH, "/api/v1/users/{userId}/posts/{postId}/comments/{commentId}"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected resource.name: "PUT /api/v1/users/{userId}/posts/{postId}/comments/{commentId}" + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("resource.name should be null when both httpMethod and path are null") + void testResourceNameNullWhenBothNull() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + // No PROXY_HTTP_METHOD and no PROXY_PATH + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Expected resource.name: null + + inferredProxySpan.finish(); + } + + // Note: These tests verify the copyAppSecTagsFromRoot() logic exists and doesn't crash. + // Full integration testing of AppSec tag propagation requires the actual tracer + // infrastructure and is better suited for integration tests. + + @Test + @DisplayName("InferredProxySpan finish should not crash when no AppSec tags present") + void testFinishWithoutAppSecTags() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // finish() should execute copyAppSecTagsFromRoot() without errors + // even when no AppSec tags are present + inferredProxySpan.finish(); + } + + @Test + @DisplayName("InferredProxySpan finish should handle null root span gracefully") + void testFinishWithNullRootSpan() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // finish() should handle the case where getLocalRootSpan() might return null + inferredProxySpan.finish(); + } + + @Test + @DisplayName("InferredProxySpan finish should work for both v1 and v2 proxy types") + void testFinishWithDifferentProxyTypes() { + // Test with aws-apigateway (v1) + Map headersV1 = new HashMap<>(); + headersV1.put(PROXY_START_TIME_MS, "12345"); + headersV1.put(PROXY_SYSTEM, "aws-apigateway"); + headersV1.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headersV1.put(InferredProxySpan.PROXY_PATH, "/v1/test"); + + InferredProxySpan proxySpanV1 = fromHeaders(headersV1); + assertNotNull(proxySpanV1.start(null)); + proxySpanV1.finish(); + + // Test with aws-httpapi (v2) + Map headersV2 = new HashMap<>(); + headersV2.put(PROXY_START_TIME_MS, "12345"); + headersV2.put(PROXY_SYSTEM, "aws-httpapi"); + headersV2.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); + headersV2.put(InferredProxySpan.PROXY_PATH, "/v2/test"); + + InferredProxySpan proxySpanV2 = fromHeaders(headersV2); + assertNotNull(proxySpanV2.start(null)); + proxySpanV2.finish(); + } + + @Test + @DisplayName("InferredProxySpan finish should be idempotent") + void testFinishIsIdempotent() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/test"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // Call finish multiple times - should not crash + inferredProxySpan.finish(); + inferredProxySpan.finish(); + inferredProxySpan.finish(); + } + + @Test + @DisplayName("InferredProxySpan with all headers should finish successfully") + void testFinishWithAllHeaders() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); + headers.put(InferredProxySpan.PROXY_RESOURCE_PATH, "/api/users/{id}"); + headers.put(InferredProxySpan.PROXY_DOMAIN_NAME, "api.example.com"); + headers.put(InferredProxySpan.STAGE, "prod"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + // With all headers present, finish should work correctly + inferredProxySpan.finish(); + } + + @Test + @DisplayName("optional API Gateway metadata should exercise ARN tag computation") + void testStartWithOptionalApiGatewayMetadata() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + headers.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers.put(InferredProxySpan.PROXY_PATH, "/api/users/123"); + headers.put(InferredProxySpan.PROXY_API_ID, "api-id"); + headers.put(InferredProxySpan.PROXY_REGION, "us-east-1"); + headers.put(InferredProxySpan.PROXY_ACCOUNT_ID, "123456789012"); + + InferredProxySpan inferredProxySpan = fromHeaders(headers); + assertNotNull(inferredProxySpan.start(null)); + + inferredProxySpan.finish(); + } + + @Test + @DisplayName("computeArn should support known proxy systems and reject unknown input") + void testComputeArn() { + InferredProxySpan inferredProxySpan = fromHeaders(null); + + assertEquals( + "arn:aws:apigateway:us-east-1::/restapis/api-id", + inferredProxySpan.computeArn("aws-apigateway", "us-east-1", "api-id")); + assertEquals( + "arn:aws:apigateway:us-east-1::/apis/api-id", + inferredProxySpan.computeArn("aws-httpapi", "us-east-1", "api-id")); + assertNull(inferredProxySpan.computeArn("unknown", "us-east-1", "api-id")); + assertNull(inferredProxySpan.computeArn("aws-apigateway", null, "api-id")); + assertNull(inferredProxySpan.computeArn("aws-apigateway", "us-east-1", null)); + } + + @Test + @DisplayName("Multiple InferredProxySpan instances should finish independently") + void testMultipleProxySpansFinishIndependently() { + // Create first proxy span + Map headers1 = new HashMap<>(); + headers1.put(PROXY_START_TIME_MS, "12345"); + headers1.put(PROXY_SYSTEM, "aws-apigateway"); + headers1.put(InferredProxySpan.PROXY_HTTP_METHOD, "GET"); + headers1.put(InferredProxySpan.PROXY_PATH, "/test1"); + + InferredProxySpan proxySpan1 = fromHeaders(headers1); + assertNotNull(proxySpan1.start(null)); + + // Create second proxy span + Map headers2 = new HashMap<>(); + headers2.put(PROXY_START_TIME_MS, "12346"); + headers2.put(PROXY_SYSTEM, "aws-httpapi"); + headers2.put(InferredProxySpan.PROXY_HTTP_METHOD, "POST"); + headers2.put(InferredProxySpan.PROXY_PATH, "/test2"); + + InferredProxySpan proxySpan2 = fromHeaders(headers2); + assertNotNull(proxySpan2.start(null)); + + // Finish both - should work independently + proxySpan1.finish(); + proxySpan2.finish(); + } + + @Test + @DisplayName("finish forwards the Datadog scan/test markers from the service-entry span to the inferred span") + void testFinishForwardsSecurityTestingHeaders() throws Exception { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + InferredProxySpan inferredProxySpan = fromHeaders(headers); + + // Replace the real (noop) inferred span with a mock we can verify against. Drive through + // the public finish() API so the test stays valid if the internal copy-helper is renamed. + AgentSpan mockInferredSpan = mock(AgentSpan.class); + // Keep this reflected field name in sync with InferredProxySpan.span. + Field spanField = InferredProxySpan.class.getDeclaredField("span"); + spanField.setAccessible(true); + spanField.set(inferredProxySpan, mockInferredSpan); + + AgentSpan serviceEntrySpan = mock(AgentSpan.class); + when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN)) + .thenReturn("scan-uuid"); + when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST)) + .thenReturn("test-uuid"); + inferredProxySpan.registerServiceEntrySpan(serviceEntrySpan); + + inferredProxySpan.finish(serviceEntrySpan); + + verify(mockInferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN, "scan-uuid"); + verify(mockInferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST, "test-uuid"); + } + + @Test + @DisplayName("finish should phase child calls and publish after service-entry completion") + void testFinishPhasesChildCallAndPublishesOnServiceEntry() throws Exception { + InferredProxySpan inferredProxySpan = fromHeaders(validHeaders()); + + AgentSpan inferredSpan = mock(AgentSpan.class); + inferredProxySpan.span = inferredSpan; + + AgentSpan childSpan = mock(AgentSpan.class); + when(childSpan.getTag("_dd.appsec.enabled")).thenReturn(Boolean.TRUE); + when(childSpan.getTag("_dd.appsec.json")).thenReturn("{\"triggers\":[]}"); + + AgentSpan serviceEntrySpan = mock(AgentSpan.class); + when(serviceEntrySpan.getHttpStatusCode()).thenReturn((short) 503); + when(serviceEntrySpan.getTag(HTTP_USER_AGENT)).thenReturn("curl/8.0"); + when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN)) + .thenReturn("scan-uuid"); + when(serviceEntrySpan.getTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST)) + .thenReturn("test-uuid"); + + inferredProxySpan.registerServiceEntrySpan(serviceEntrySpan); + + inferredProxySpan.finish(childSpan); + inferredProxySpan.finish(childSpan); + verify(inferredSpan).setMetric("_dd.appsec.enabled", 1); + verify(inferredSpan).setTag("_dd.appsec.json", "{\"triggers\":[]}"); + verify(inferredSpan, times(1)).phasedFinish(); + verify(inferredSpan, never()).finish(); + verify(inferredSpan, never()).publish(); + + inferredProxySpan.finish(serviceEntrySpan); + + verify(inferredSpan).setHttpStatusCode(503); + verify(inferredSpan).setError(true, HTTP_SERVER_DECORATOR); + verify(inferredSpan).setTag(HTTP_USER_AGENT, "curl/8.0"); + verify(inferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_ENDPOINT_SCAN, "scan-uuid"); + verify(inferredSpan).setTag(HTTP_REQUEST_HEADERS_X_DATADOG_SECURITY_TEST, "test-uuid"); + verify(inferredSpan).publish(); + verify(inferredSpan, never()).finish(); + } + + private static Map validHeaders() { + Map headers = new HashMap<>(); + headers.put(PROXY_START_TIME_MS, "12345"); + headers.put(PROXY_SYSTEM, "aws-apigateway"); + return headers; + } } diff --git a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java index 145b3b117f6..215a35e07ad 100644 --- a/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java +++ b/internal-api/src/test/java/datadog/trace/api/gateway/InstrumentationGatewayTest.java @@ -26,659 +26,661 @@ import org.junit.jupiter.api.Test; public class InstrumentationGatewayTest { - private InstrumentationGateway gateway; - private SubscriptionService ss; - private CallbackProvider cbp; - private RequestContext context; - private Flow flow; - private Callback callback; - private Events events; - - @BeforeEach - public void setUp() { - gateway = new InstrumentationGateway(); - ss = gateway.getSubscriptionService(RequestContextSlot.APPSEC); - cbp = gateway.getCallbackProvider(RequestContextSlot.APPSEC); - context = - new RequestContext() { - @Override - public void close() {} - - @Override - public Object getData(RequestContextSlot slot) { - return this; - } - - @Override - public TraceSegment getTraceSegment() { - return TraceSegment.NoOp.INSTANCE; - } - - @Override - public void setBlockResponseFunction(BlockResponseFunction blockResponseFunction) {} - - @Override - public BlockResponseFunction getBlockResponseFunction() { - return null; - } - - @Override - public T getOrCreateMetaStructTop(String key, Function defaultValue) { - return null; - } - - @Override - public void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {} - - @Override - public ClientIpAddressData getClientIpAddressData() { - return null; - } - }; - flow = new Flow.ResultFlow<>(null); - callback = new Callback(context, flow); - events = Events.get(); - } - - @Test - public void testGetCallback() { - ss.registerCallback(events.requestStarted(), callback); - // check event without registered callback - assertNull(cbp.getCallback(events.requestEnded())); - // check event with registered callback - Supplier> cback = cbp.getCallback(events.requestStarted()); - assertEquals(cback, callback); - Flow flow = cback.get(); - assertEquals(Flow.Action.Noop.INSTANCE, flow.getAction()); - assertEquals(context, flow.getResult()); - } - - @Test - public void testRegisterCallback() { - Subscription s1 = ss.registerCallback(events.requestStarted(), callback); - // check event without registered callback - assertNull(cbp.getCallback(events.requestEnded())); - // check event with registered callback - assertEquals(cbp.getCallback(events.requestStarted()), callback); - // check that we can register a callback - Callback cb = new Callback(context, flow); - Subscription s2 = ss.registerCallback(events.requestEnded(), cb); - assertNotNull(s2); - assertEquals(cbp.getCallback(events.requestEnded()), cb); - // check that we can cancel a callback - s1.cancel(); - assertNull(cbp.getCallback(events.requestStarted())); - // check that we didn't remove the other callback - assertEquals(cbp.getCallback(events.requestEnded()), cb); - } - - @Test - public void testDoubleRegistration() { - ss.registerCallback(events.requestStarted(), callback); - // check event with registered callback - assertEquals(cbp.getCallback(events.requestStarted()), callback); - // check that we can't overwrite the callback - IllegalStateException ex = - assertThrows( - IllegalStateException.class, - () -> ss.registerCallback(events.requestStarted(), callback)); - assertAll( - () -> assertTrue(ex.getMessage().startsWith("Trying to overwrite existing callback ")), - () -> assertTrue(ex.getMessage().contains(events.requestStarted().toString()))); - } - - @Test - public void testDoubleCancel() { - Subscription s1 = ss.registerCallback(events.requestStarted(), callback); - // check event with registered callback - assertEquals(cbp.getCallback(events.requestStarted()), callback); - // check that we can cancel a callback - s1.cancel(); - assertNull(cbp.getCallback(events.requestStarted())); - // check that we can cancel a callback - s1.cancel(); - assertNull(cbp.getCallback(events.requestStarted())); - } - - @Test - public void testNoopAction() { - assertFalse(Flow.Action.Noop.INSTANCE.isBlocking()); - } - - @Test - public void testRequestBlockingAction() { - Flow.Action.RequestBlockingAction rba = - new Flow.Action.RequestBlockingAction(400, BlockingContentType.HTML); - assertTrue(rba.isBlocking()); - assertEquals(400, rba.getStatusCode()); - assertEquals(BlockingContentType.HTML, rba.getBlockingContentType()); - - rba = - new Flow.Action.RequestBlockingAction( - 400, - BlockingContentType.HTML, - Collections.singletonMap("Location", "https://www.google.com/")); - assertTrue(rba.isBlocking()); - assertEquals(400, rba.getStatusCode()); - assertEquals(BlockingContentType.HTML, rba.getBlockingContentType()); - assertEquals("https://www.google.com/", rba.getExtraHeaders().get("Location")); - - rba = Flow.Action.RequestBlockingAction.forRedirect(301, "https://www.google.com/"); - assertTrue(rba.isBlocking()); - assertEquals(301, rba.getStatusCode()); - assertEquals(BlockingContentType.NONE, rba.getBlockingContentType()); - assertEquals("https://www.google.com/", rba.getExtraHeaders().get("Location")); - } - - @Test - public void blockResponseFunctionDefaultMethodDelegatesRequestBlockingAction() { - TraceSegment segment = TraceSegment.NoOp.INSTANCE; - Flow.Action.RequestBlockingAction action = - new Flow.Action.RequestBlockingAction( - 451, - BlockingContentType.JSON, - Collections.singletonMap("x-blocked", "true"), - "security-response-id"); - - CapturingBlockResponseFunction blockResponseFunction = new CapturingBlockResponseFunction(); - - assertTrue(blockResponseFunction.tryCommitBlockingResponse(segment, action)); - assertSame(segment, blockResponseFunction.segment); - assertEquals(451, blockResponseFunction.statusCode); - assertEquals(BlockingContentType.JSON, blockResponseFunction.templateType); - assertEquals("true", blockResponseFunction.extraHeaders.get("x-blocked")); - assertEquals("security-response-id", blockResponseFunction.securityResponseId); - } - - @Test - public void testNormalCalls() { - // check that we pass through normal calls - ss.registerCallback(events.requestStarted(), callback); - assertEquals(context, cbp.getCallback(events.requestStarted()).get().getResult()); - ss.registerCallback(events.requestEnded(), callback); - assertEquals(flow, cbp.getCallback(events.requestEnded()).apply(null, null)); - ss.registerCallback(events.requestHeader(), callback); - cbp.getCallback(events.requestHeader()).accept(null, null, null); - ss.registerCallback(events.requestHeaderDone(), callback.function); - assertEquals(flow, cbp.getCallback(events.requestHeaderDone()).apply(null)); - ss.registerCallback(events.requestMethodUriRaw(), callback); - assertEquals(flow, cbp.getCallback(events.requestMethodUriRaw()).apply(null, null, null)); - ss.registerCallback(events.requestPathParams(), callback); - assertEquals(flow, cbp.getCallback(events.requestPathParams()).apply(null, null)); - ss.registerCallback(events.requestClientSocketAddress(), callback.asClientSocketAddress()); - assertEquals( - flow, cbp.getCallback(events.requestClientSocketAddress()).apply(null, null, null)); - ss.registerCallback(events.requestInferredClientAddress(), callback); - assertEquals(flow, cbp.getCallback(events.requestInferredClientAddress()).apply(null, null)); - ss.registerCallback(events.requestBodyStart(), callback.asRequestBodyStart()); - assertNull(cbp.getCallback(events.requestBodyStart()).apply(null, null)); - ss.registerCallback(events.requestBodyDone(), callback.asRequestBodyDone()); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.requestBodyDone()).apply(null, null).getAction()); - ss.registerCallback(events.requestBodyProcessed(), callback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.requestBodyProcessed()).apply(null, null).getAction()); - ss.registerCallback(events.responseBody(), callback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.responseBody()).apply(null, null).getAction()); - ss.registerCallback(events.grpcServerMethod(), callback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.grpcServerMethod()).apply(null, null).getAction()); - ss.registerCallback(events.grpcServerRequestMessage(), callback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.grpcServerRequestMessage()).apply(null, null).getAction()); - ss.registerCallback(events.responseStarted(), callback); - cbp.getCallback(events.responseStarted()).apply(null, null); - ss.registerCallback(events.responseHeader(), callback); - cbp.getCallback(events.responseHeader()).accept(null, null, null); - ss.registerCallback(events.responseHeaderDone(), callback.function); - cbp.getCallback(events.responseHeaderDone()).apply(null); - ss.registerCallback(events.graphqlServerRequestMessage(), callback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.graphqlServerRequestMessage()).apply(null, null).getAction()); - ss.registerCallback(events.databaseConnection(), callback); - cbp.getCallback(events.databaseConnection()).accept(null, null); - ss.registerCallback(events.databaseSqlQuery(), callback); - cbp.getCallback(events.databaseSqlQuery()).apply(null, null); - ss.registerCallback(events.httpClientRequest(), callback); - cbp.getCallback(events.httpClientRequest()).apply(null, null); - ss.registerCallback(events.httpClientResponse(), callback); - cbp.getCallback(events.httpClientResponse()).apply(null, null); - ss.registerCallback(events.httpClientSampling(), callback); - cbp.getCallback(events.httpClientSampling()).apply(null, null); - ss.registerCallback(events.fileLoaded(), callback); - cbp.getCallback(events.fileLoaded()).apply(null, null); - ss.registerCallback(events.user(), callback); - cbp.getCallback(events.user()).apply(null, null); - ss.registerCallback(events.loginEvent(), callback); - cbp.getCallback(events.loginEvent()).apply(null, null, null); - ss.registerCallback(events.requestSession(), callback); - cbp.getCallback(events.requestSession()).apply(null, null); - ss.registerCallback(events.execCmd(), callback); - cbp.getCallback(events.execCmd()).apply(null, null); - ss.registerCallback(events.shellCmd(), callback); - cbp.getCallback(events.shellCmd()).apply(null, null); - ss.registerCallback(events.httpRoute(), callback); - cbp.getCallback(events.httpRoute()).accept(null, null); - ss.registerCallback(events.requestFilesFilenames(), callback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.requestFilesFilenames()).apply(null, null).getAction()); - ss.registerCallback(events.requestFilesContent(), callback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.requestFilesContent()).apply(null, null).getAction()); - ss.registerCallback(events.fileWritten(), callback); - cbp.getCallback(events.fileWritten()).apply(null, null); - assertEquals(Events.MAX_EVENTS, callback.count); - } - - @Test - public void testThrowableBlocking() { - Throwback throwback = new Throwback(); - // check that we block the thrown exceptions - ss.registerCallback(events.requestStarted(), throwback); - assertEquals(Flow.ResultFlow.empty(), cbp.getCallback(events.requestStarted()).get()); - ss.registerCallback(events.requestEnded(), throwback); - assertEquals(Flow.ResultFlow.empty(), cbp.getCallback(events.requestEnded()).apply(null, null)); - ss.registerCallback(events.requestHeader(), throwback); - cbp.getCallback(events.requestHeader()).accept(null, null, null); - ss.registerCallback(events.requestHeaderDone(), throwback.function); - assertEquals(Flow.ResultFlow.empty(), cbp.getCallback(events.requestHeaderDone()).apply(null)); - ss.registerCallback(events.requestMethodUriRaw(), throwback); - assertEquals( - Flow.ResultFlow.empty(), - cbp.getCallback(events.requestMethodUriRaw()).apply(null, null, null)); - ss.registerCallback(events.requestPathParams(), throwback); - assertEquals( - Flow.ResultFlow.empty(), cbp.getCallback(events.requestPathParams()).apply(null, null)); - ss.registerCallback(events.requestClientSocketAddress(), throwback.asClientSocketAddress()); - assertEquals( - Flow.ResultFlow.empty(), - cbp.getCallback(events.requestClientSocketAddress()).apply(null, null, null)); - ss.registerCallback(events.requestInferredClientAddress(), throwback.asInferredClientAddress()); - assertEquals( - Flow.ResultFlow.empty(), - cbp.getCallback(events.requestInferredClientAddress()).apply(null, null)); - ss.registerCallback(events.requestBodyStart(), throwback.asRequestBodyStart()); - assertNull(cbp.getCallback(events.requestBodyStart()).apply(null, null)); - ss.registerCallback(events.requestBodyDone(), throwback.asRequestBodyDone()); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.requestBodyDone()).apply(null, null).getAction()); - ss.registerCallback(events.requestBodyProcessed(), throwback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.requestBodyProcessed()).apply(null, null).getAction()); - ss.registerCallback(events.responseBody(), throwback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.responseBody()).apply(null, null).getAction()); - ss.registerCallback(events.grpcServerMethod(), throwback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.grpcServerMethod()).apply(null, null).getAction()); - ss.registerCallback(events.grpcServerRequestMessage(), throwback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.grpcServerRequestMessage()).apply(null, null).getAction()); - ss.registerCallback(events.responseStarted(), throwback); - cbp.getCallback(events.responseStarted()).apply(null, null); - ss.registerCallback(events.responseHeader(), throwback); - cbp.getCallback(events.responseHeader()).accept(null, null, null); - ss.registerCallback(events.responseHeaderDone(), throwback.function); - cbp.getCallback(events.responseHeaderDone()).apply(null); - ss.registerCallback(events.graphqlServerRequestMessage(), throwback); - assertEquals( - Flow.Action.Noop.INSTANCE, - cbp.getCallback(events.graphqlServerRequestMessage()).apply(null, null).getAction()); - ss.registerCallback(events.databaseConnection(), throwback); - cbp.getCallback(events.databaseConnection()).accept(null, null); - ss.registerCallback(events.databaseSqlQuery(), throwback); - cbp.getCallback(events.databaseSqlQuery()).apply(null, null); - ss.registerCallback(events.httpClientRequest(), throwback); - cbp.getCallback(events.httpClientRequest()).apply(null, null); - ss.registerCallback(events.httpClientResponse(), throwback); - cbp.getCallback(events.httpClientResponse()).apply(null, null); - ss.registerCallback(events.httpClientSampling(), throwback); - cbp.getCallback(events.httpClientSampling()).apply(null, null); - ss.registerCallback(events.fileLoaded(), throwback); - cbp.getCallback(events.fileLoaded()).apply(null, null); - ss.registerCallback(events.user(), throwback); - cbp.getCallback(events.user()).apply(null, null); - ss.registerCallback(events.loginEvent(), throwback); - cbp.getCallback(events.loginEvent()).apply(null, null, null); - ss.registerCallback(events.requestSession(), throwback); - cbp.getCallback(events.requestSession()).apply(null, null); - ss.registerCallback(events.execCmd(), throwback); - cbp.getCallback(events.execCmd()).apply(null, null); - ss.registerCallback(events.shellCmd(), throwback); - cbp.getCallback(events.shellCmd()).apply(null, null); - ss.registerCallback(events.httpRoute(), throwback); - cbp.getCallback(events.httpRoute()).accept(null, null); - ss.registerCallback(events.requestFilesFilenames(), throwback); - assertEquals( - Flow.ResultFlow.empty(), cbp.getCallback(events.requestFilesFilenames()).apply(null, null)); - ss.registerCallback(events.requestFilesContent(), throwback); - assertEquals( - Flow.ResultFlow.empty(), cbp.getCallback(events.requestFilesContent()).apply(null, null)); - ss.registerCallback(events.fileWritten(), throwback); - cbp.getCallback(events.fileWritten()).apply(null, null); - assertEquals(Events.MAX_EVENTS, throwback.count); - } - - @Test - public void iastRegistryOperatesIndependently() { - SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); - CallbackProvider cbpIast = gateway.getCallbackProvider(RequestContextSlot.IAST); - - ss.registerCallback(events.requestStarted(), callback); - assertNull(cbpIast.getCallback(events.requestStarted())); - - ssIast.registerCallback(events.requestStarted(), callback); - assertNotNull(cbpIast.getCallback(events.requestStarted())); - } - - @Test - public void resettingResetsAllSubsystems() { - SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); - CallbackProvider cbpIast = gateway.getCallbackProvider(RequestContextSlot.IAST); - - ss.registerCallback(events.requestStarted(), callback); - ssIast.registerCallback(events.requestStarted(), callback); - - gateway.reset(); - - assertNull(cbp.getCallback(events.requestStarted())); - assertNull(cbpIast.getCallback(events.requestStarted())); - } - - @Test - public void invalidRequestContextSlot() { - SubscriptionService ss = gateway.getSubscriptionService(null); - CallbackProvider cbp = gateway.getCallbackProvider(null); - - assertSame(SubscriptionService.SubscriptionServiceNoop.INSTANCE, ss); - assertSame(CallbackProvider.CallbackProviderNoop.INSTANCE, cbp); - } - - @Test - public void universalCallbackProviderForRequestEnded() { - SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); - final int[] count = new int[1]; - BiFunction> cb = - (requestContext, igSpanInfo) -> { - assertSame(callback.ctx, requestContext); - assertSame(AgentTracer.noopSpan(), igSpanInfo); - count[0]++; - return new Flow.ResultFlow<>(null); - }; - ss.registerCallback(events.requestEnded(), cb); - ssIast.registerCallback(events.requestEnded(), cb); - BiFunction> uniCb = - gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); - Flow res = uniCb.apply(callback.ctx, AgentTracer.noopSpan()); - - assertEquals(2, count[0]); - assertNotNull(res); - - BiFunction> uniCb2 = - gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); - assertSame(uniCb2, uniCb); - } - - @Test - public void universalCallbackWithOnlyAppSec() { - ss.registerCallback(events.requestEnded(), callback); - - BiFunction> uniCb = - gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); - BiFunction> appSecCb = - cbp.getCallback(events.requestEnded()); - assertSame(uniCb, appSecCb); - } - - @Test - public void universalCallbackWithOnlyIast() { - SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); - CallbackProvider cbpIast = gateway.getCallbackProvider(RequestContextSlot.IAST); - - ssIast.registerCallback(events.requestEnded(), callback); - - BiFunction> uniCb = - gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); - BiFunction> iastCb = - cbpIast.getCallback(events.requestEnded()); - assertSame(uniCb, iastCb); - } - - @Test - public void universalCallbackWithNoCallbacks() { - assertNull(gateway.getUniversalCallbackProvider().getCallback(events.requestEnded())); - } - - @Test - public void mergeFlowIdenticalFlows() { - Flow flow = new Flow.ResultFlow<>(null); - Flow resFlow = InstrumentationGateway.mergeFlows(flow, flow); - - assertSame(flow, resFlow); - } - - @Test - public void mergeFlowBlockingActionHasPriority() { - Flow flow1 = new Flow.ResultFlow<>(null); - Flow flow2 = - new Flow.ResultFlow(null) { - @Override - public Action getAction() { - return new Action.RequestBlockingAction(410, BlockingContentType.AUTO); - } + private InstrumentationGateway gateway; + private SubscriptionService ss; + private CallbackProvider cbp; + private RequestContext context; + private Flow flow; + private Callback callback; + private Events events; + + @BeforeEach + public void setUp() { + gateway = new InstrumentationGateway(); + ss = gateway.getSubscriptionService(RequestContextSlot.APPSEC); + cbp = gateway.getCallbackProvider(RequestContextSlot.APPSEC); + context = new RequestContext() { + @Override + public void close() {} + + @Override + public Object getData(RequestContextSlot slot) { + return this; + } + + @Override + public TraceSegment getTraceSegment() { + return TraceSegment.NoOp.INSTANCE; + } + + @Override + public void setBlockResponseFunction(BlockResponseFunction blockResponseFunction) {} + + @Override + public BlockResponseFunction getBlockResponseFunction() { + return null; + } + + @Override + public T getOrCreateMetaStructTop(String key, Function defaultValue) { + return null; + } + + @Override + public void setClientIpAddressData(ClientIpAddressData clientIpAddressData) {} + + @Override + public ClientIpAddressData getClientIpAddressData() { + return null; + } }; + flow = new Flow.ResultFlow<>(null); + callback = new Callback(context, flow); + events = Events.get(); + } - Flow resFlow1 = InstrumentationGateway.mergeFlows(flow1, flow2); - Flow resFlow2 = InstrumentationGateway.mergeFlows(flow2, flow1); - - assertTrue(resFlow1.getAction().isBlocking()); - assertTrue(resFlow2.getAction().isBlocking()); - } - - @Test - public void mergeFlowReturnsNonNullResult() { - Flow flow1 = new Flow.ResultFlow<>(42); - Flow flow2 = new Flow.ResultFlow<>(null); - - Flow resFlow1 = InstrumentationGateway.mergeFlows(flow1, flow2); - Flow resFlow2 = InstrumentationGateway.mergeFlows(flow2, flow1); - - assertEquals(42, resFlow1.getResult()); - assertEquals(42, resFlow2.getResult()); - } - - @Test - public void mergeFlowReturnsLatestNonNullResult() { - Flow flow1 = new Flow.ResultFlow<>(42); - Flow flow2 = new Flow.ResultFlow<>(43); - - Flow resFlow1 = InstrumentationGateway.mergeFlows(flow1, flow2); - Flow resFlow2 = InstrumentationGateway.mergeFlows(flow2, flow1); - - assertEquals(43, resFlow1.getResult()); - assertEquals(42, resFlow2.getResult()); - } - - private static class Callback - implements Supplier>, - BiConsumer, - TriConsumer, - BiFunction>, - TriFunction> { - - private final RequestContext ctx; - private final Flow flow; - private int count = 0; - private final Function> function; - - public Callback(RequestContext ctx, Flow flow) { - this.ctx = ctx; - this.flow = flow; - function = - input -> { - count++; - return flow; - }; + @Test + public void testGetCallback() { + ss.registerCallback(events.requestStarted(), callback); + // check event without registered callback + assertNull(cbp.getCallback(events.requestEnded())); + // check event with registered callback + Supplier> cback = cbp.getCallback(events.requestStarted()); + assertEquals(cback, callback); + Flow flow = cback.get(); + assertEquals(Flow.Action.Noop.INSTANCE, flow.getAction()); + assertEquals(context, flow.getResult()); } - @Override - public Flow apply(RequestContext requestContext, T arg) { - count++; - return flow; + @Test + public void testRegisterCallback() { + Subscription s1 = ss.registerCallback(events.requestStarted(), callback); + // check event without registered callback + assertNull(cbp.getCallback(events.requestEnded())); + // check event with registered callback + assertEquals(cbp.getCallback(events.requestStarted()), callback); + // check that we can register a callback + Callback cb = new Callback(context, flow); + Subscription s2 = ss.registerCallback(events.requestEnded(), cb); + assertNotNull(s2); + assertEquals(cbp.getCallback(events.requestEnded()), cb); + // check that we can cancel a callback + s1.cancel(); + assertNull(cbp.getCallback(events.requestStarted())); + // check that we didn't remove the other callback + assertEquals(cbp.getCallback(events.requestEnded()), cb); } - @Override - public Flow get() { - count++; - return new Flow.ResultFlow<>((D) ctx); + @Test + public void testDoubleRegistration() { + ss.registerCallback(events.requestStarted(), callback); + // check event with registered callback + assertEquals(cbp.getCallback(events.requestStarted()), callback); + // check that we can't overwrite the callback + IllegalStateException ex = + assertThrows(IllegalStateException.class, () -> ss.registerCallback(events.requestStarted(), callback)); + assertAll( + () -> assertTrue(ex.getMessage().startsWith("Trying to overwrite existing callback ")), + () -> assertTrue( + ex.getMessage().contains(events.requestStarted().toString()))); } - @Override - public void accept(RequestContext requestContext, T s, T s2) { - count++; + @Test + public void testDoubleCancel() { + Subscription s1 = ss.registerCallback(events.requestStarted(), callback); + // check event with registered callback + assertEquals(cbp.getCallback(events.requestStarted()), callback); + // check that we can cancel a callback + s1.cancel(); + assertNull(cbp.getCallback(events.requestStarted())); + // check that we can cancel a callback + s1.cancel(); + assertNull(cbp.getCallback(events.requestStarted())); } - public TriFunction> asClientSocketAddress() { - return (requestContext, s, aShort) -> { - count++; - return flow; - }; + @Test + public void testNoopAction() { + assertFalse(Flow.Action.Noop.INSTANCE.isBlocking()); } - public BiFunction asRequestBodyStart() { - return (requestContext, storedBodySupplier) -> { - count++; - return null; - }; + @Test + public void testRequestBlockingAction() { + Flow.Action.RequestBlockingAction rba = new Flow.Action.RequestBlockingAction(400, BlockingContentType.HTML); + assertTrue(rba.isBlocking()); + assertEquals(400, rba.getStatusCode()); + assertEquals(BlockingContentType.HTML, rba.getBlockingContentType()); + + rba = new Flow.Action.RequestBlockingAction( + 400, BlockingContentType.HTML, Collections.singletonMap("Location", "https://www.google.com/")); + assertTrue(rba.isBlocking()); + assertEquals(400, rba.getStatusCode()); + assertEquals(BlockingContentType.HTML, rba.getBlockingContentType()); + assertEquals("https://www.google.com/", rba.getExtraHeaders().get("Location")); + + rba = Flow.Action.RequestBlockingAction.forRedirect(301, "https://www.google.com/"); + assertTrue(rba.isBlocking()); + assertEquals(301, rba.getStatusCode()); + assertEquals(BlockingContentType.NONE, rba.getBlockingContentType()); + assertEquals("https://www.google.com/", rba.getExtraHeaders().get("Location")); } - public BiFunction> asRequestBodyDone() { - return (requestContext, storedBodySupplier) -> { - count++; - return new Flow.ResultFlow<>(null); - }; + @Test + public void blockResponseFunctionDefaultMethodDelegatesRequestBlockingAction() { + TraceSegment segment = TraceSegment.NoOp.INSTANCE; + Flow.Action.RequestBlockingAction action = new Flow.Action.RequestBlockingAction( + 451, BlockingContentType.JSON, Collections.singletonMap("x-blocked", "true"), "security-response-id"); + + CapturingBlockResponseFunction blockResponseFunction = new CapturingBlockResponseFunction(); + + assertTrue(blockResponseFunction.tryCommitBlockingResponse(segment, action)); + assertSame(segment, blockResponseFunction.segment); + assertEquals(451, blockResponseFunction.statusCode); + assertEquals(BlockingContentType.JSON, blockResponseFunction.templateType); + assertEquals("true", blockResponseFunction.extraHeaders.get("x-blocked")); + assertEquals("security-response-id", blockResponseFunction.securityResponseId); } - @Override - public void accept(RequestContext requestContext, T t) { - count++; + @Test + public void testNormalCalls() { + // check that we pass through normal calls + ss.registerCallback(events.requestStarted(), callback); + assertEquals(context, cbp.getCallback(events.requestStarted()).get().getResult()); + ss.registerCallback(events.requestEnded(), callback); + assertEquals(flow, cbp.getCallback(events.requestEnded()).apply(null, null)); + ss.registerCallback(events.requestHeader(), callback); + cbp.getCallback(events.requestHeader()).accept(null, null, null); + ss.registerCallback(events.requestHeaderDone(), callback.function); + assertEquals(flow, cbp.getCallback(events.requestHeaderDone()).apply(null)); + ss.registerCallback(events.requestMethodUriRaw(), callback); + assertEquals(flow, cbp.getCallback(events.requestMethodUriRaw()).apply(null, null, null)); + ss.registerCallback(events.requestPathParams(), callback); + assertEquals(flow, cbp.getCallback(events.requestPathParams()).apply(null, null)); + ss.registerCallback(events.requestClientSocketAddress(), callback.asClientSocketAddress()); + assertEquals(flow, cbp.getCallback(events.requestClientSocketAddress()).apply(null, null, null)); + ss.registerCallback(events.requestInferredClientAddress(), callback); + assertEquals( + flow, cbp.getCallback(events.requestInferredClientAddress()).apply(null, null)); + ss.registerCallback(events.requestBodyStart(), callback.asRequestBodyStart()); + assertNull(cbp.getCallback(events.requestBodyStart()).apply(null, null)); + ss.registerCallback(events.requestBodyDone(), callback.asRequestBodyDone()); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.requestBodyDone()).apply(null, null).getAction()); + ss.registerCallback(events.requestBodyProcessed(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.requestBodyProcessed()).apply(null, null).getAction()); + ss.registerCallback(events.responseBody(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.responseBody()).apply(null, null).getAction()); + ss.registerCallback(events.grpcServerMethod(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.grpcServerMethod()).apply(null, null).getAction()); + ss.registerCallback(events.grpcServerRequestMessage(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.grpcServerRequestMessage()) + .apply(null, null) + .getAction()); + ss.registerCallback(events.responseStarted(), callback); + cbp.getCallback(events.responseStarted()).apply(null, null); + ss.registerCallback(events.responseHeader(), callback); + cbp.getCallback(events.responseHeader()).accept(null, null, null); + ss.registerCallback(events.responseHeaderDone(), callback.function); + cbp.getCallback(events.responseHeaderDone()).apply(null); + ss.registerCallback(events.graphqlServerRequestMessage(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.graphqlServerRequestMessage()) + .apply(null, null) + .getAction()); + ss.registerCallback(events.databaseConnection(), callback); + cbp.getCallback(events.databaseConnection()).accept(null, null); + ss.registerCallback(events.databaseSqlQuery(), callback); + cbp.getCallback(events.databaseSqlQuery()).apply(null, null); + ss.registerCallback(events.httpClientRequest(), callback); + cbp.getCallback(events.httpClientRequest()).apply(null, null); + ss.registerCallback(events.httpClientResponse(), callback); + cbp.getCallback(events.httpClientResponse()).apply(null, null); + ss.registerCallback(events.httpClientSampling(), callback); + cbp.getCallback(events.httpClientSampling()).apply(null, null); + ss.registerCallback(events.fileLoaded(), callback); + cbp.getCallback(events.fileLoaded()).apply(null, null); + ss.registerCallback(events.user(), callback); + cbp.getCallback(events.user()).apply(null, null); + ss.registerCallback(events.loginEvent(), callback); + cbp.getCallback(events.loginEvent()).apply(null, null, null); + ss.registerCallback(events.requestSession(), callback); + cbp.getCallback(events.requestSession()).apply(null, null); + ss.registerCallback(events.execCmd(), callback); + cbp.getCallback(events.execCmd()).apply(null, null); + ss.registerCallback(events.shellCmd(), callback); + cbp.getCallback(events.shellCmd()).apply(null, null); + ss.registerCallback(events.httpRoute(), callback); + cbp.getCallback(events.httpRoute()).accept(null, null); + ss.registerCallback(events.requestFilesFilenames(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.requestFilesFilenames()) + .apply(null, null) + .getAction()); + ss.registerCallback(events.requestFilesContent(), callback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.requestFilesContent()).apply(null, null).getAction()); + ss.registerCallback(events.fileWritten(), callback); + cbp.getCallback(events.fileWritten()).apply(null, null); + assertEquals(Events.MAX_EVENTS, callback.count); } - @Override - public Flow apply(RequestContext requestContext, T t, T t2) { - count++; - return flow; + @Test + public void testThrowableBlocking() { + Throwback throwback = new Throwback(); + // check that we block the thrown exceptions + ss.registerCallback(events.requestStarted(), throwback); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestStarted()).get()); + ss.registerCallback(events.requestEnded(), throwback); + assertEquals( + Flow.ResultFlow.empty(), cbp.getCallback(events.requestEnded()).apply(null, null)); + ss.registerCallback(events.requestHeader(), throwback); + cbp.getCallback(events.requestHeader()).accept(null, null, null); + ss.registerCallback(events.requestHeaderDone(), throwback.function); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestHeaderDone()).apply(null)); + ss.registerCallback(events.requestMethodUriRaw(), throwback); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestMethodUriRaw()).apply(null, null, null)); + ss.registerCallback(events.requestPathParams(), throwback); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestPathParams()).apply(null, null)); + ss.registerCallback(events.requestClientSocketAddress(), throwback.asClientSocketAddress()); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestClientSocketAddress()).apply(null, null, null)); + ss.registerCallback(events.requestInferredClientAddress(), throwback.asInferredClientAddress()); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestInferredClientAddress()).apply(null, null)); + ss.registerCallback(events.requestBodyStart(), throwback.asRequestBodyStart()); + assertNull(cbp.getCallback(events.requestBodyStart()).apply(null, null)); + ss.registerCallback(events.requestBodyDone(), throwback.asRequestBodyDone()); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.requestBodyDone()).apply(null, null).getAction()); + ss.registerCallback(events.requestBodyProcessed(), throwback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.requestBodyProcessed()).apply(null, null).getAction()); + ss.registerCallback(events.responseBody(), throwback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.responseBody()).apply(null, null).getAction()); + ss.registerCallback(events.grpcServerMethod(), throwback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.grpcServerMethod()).apply(null, null).getAction()); + ss.registerCallback(events.grpcServerRequestMessage(), throwback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.grpcServerRequestMessage()) + .apply(null, null) + .getAction()); + ss.registerCallback(events.responseStarted(), throwback); + cbp.getCallback(events.responseStarted()).apply(null, null); + ss.registerCallback(events.responseHeader(), throwback); + cbp.getCallback(events.responseHeader()).accept(null, null, null); + ss.registerCallback(events.responseHeaderDone(), throwback.function); + cbp.getCallback(events.responseHeaderDone()).apply(null); + ss.registerCallback(events.graphqlServerRequestMessage(), throwback); + assertEquals( + Flow.Action.Noop.INSTANCE, + cbp.getCallback(events.graphqlServerRequestMessage()) + .apply(null, null) + .getAction()); + ss.registerCallback(events.databaseConnection(), throwback); + cbp.getCallback(events.databaseConnection()).accept(null, null); + ss.registerCallback(events.databaseSqlQuery(), throwback); + cbp.getCallback(events.databaseSqlQuery()).apply(null, null); + ss.registerCallback(events.httpClientRequest(), throwback); + cbp.getCallback(events.httpClientRequest()).apply(null, null); + ss.registerCallback(events.httpClientResponse(), throwback); + cbp.getCallback(events.httpClientResponse()).apply(null, null); + ss.registerCallback(events.httpClientSampling(), throwback); + cbp.getCallback(events.httpClientSampling()).apply(null, null); + ss.registerCallback(events.fileLoaded(), throwback); + cbp.getCallback(events.fileLoaded()).apply(null, null); + ss.registerCallback(events.user(), throwback); + cbp.getCallback(events.user()).apply(null, null); + ss.registerCallback(events.loginEvent(), throwback); + cbp.getCallback(events.loginEvent()).apply(null, null, null); + ss.registerCallback(events.requestSession(), throwback); + cbp.getCallback(events.requestSession()).apply(null, null); + ss.registerCallback(events.execCmd(), throwback); + cbp.getCallback(events.execCmd()).apply(null, null); + ss.registerCallback(events.shellCmd(), throwback); + cbp.getCallback(events.shellCmd()).apply(null, null); + ss.registerCallback(events.httpRoute(), throwback); + cbp.getCallback(events.httpRoute()).accept(null, null); + ss.registerCallback(events.requestFilesFilenames(), throwback); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestFilesFilenames()).apply(null, null)); + ss.registerCallback(events.requestFilesContent(), throwback); + assertEquals( + Flow.ResultFlow.empty(), + cbp.getCallback(events.requestFilesContent()).apply(null, null)); + ss.registerCallback(events.fileWritten(), throwback); + cbp.getCallback(events.fileWritten()).apply(null, null); + assertEquals(Events.MAX_EVENTS, throwback.count); } - } - - private static final class CapturingBlockResponseFunction implements BlockResponseFunction { - private TraceSegment segment; - private int statusCode; - private BlockingContentType templateType; - private Map extraHeaders; - private String securityResponseId; - - @Override - public boolean tryCommitBlockingResponse( - TraceSegment segment, - int statusCode, - BlockingContentType templateType, - Map extraHeaders, - String securityResponseId) { - this.segment = segment; - this.statusCode = statusCode; - this.templateType = templateType; - this.extraHeaders = extraHeaders; - this.securityResponseId = securityResponseId; - return true; + + @Test + public void iastRegistryOperatesIndependently() { + SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); + CallbackProvider cbpIast = gateway.getCallbackProvider(RequestContextSlot.IAST); + + ss.registerCallback(events.requestStarted(), callback); + assertNull(cbpIast.getCallback(events.requestStarted())); + + ssIast.registerCallback(events.requestStarted(), callback); + assertNotNull(cbpIast.getCallback(events.requestStarted())); } - } - private static class Throwback - implements Supplier>, - BiConsumer, - TriConsumer, - BiFunction>, - TriFunction> { + @Test + public void resettingResetsAllSubsystems() { + SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); + CallbackProvider cbpIast = gateway.getCallbackProvider(RequestContextSlot.IAST); + + ss.registerCallback(events.requestStarted(), callback); + ssIast.registerCallback(events.requestStarted(), callback); + + gateway.reset(); + + assertNull(cbp.getCallback(events.requestStarted())); + assertNull(cbpIast.getCallback(events.requestStarted())); + } - private int count = 0; + @Test + public void invalidRequestContextSlot() { + SubscriptionService ss = gateway.getSubscriptionService(null); + CallbackProvider cbp = gateway.getCallbackProvider(null); - private final Function> function = - input -> { - count++; - throw new IllegalArgumentException(); + assertSame(SubscriptionService.SubscriptionServiceNoop.INSTANCE, ss); + assertSame(CallbackProvider.CallbackProviderNoop.INSTANCE, cbp); + } + + @Test + public void universalCallbackProviderForRequestEnded() { + SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); + final int[] count = new int[1]; + BiFunction> cb = (requestContext, igSpanInfo) -> { + assertSame(callback.ctx, requestContext); + assertSame(AgentTracer.noopSpan(), igSpanInfo); + count[0]++; + return new Flow.ResultFlow<>(null); }; + ss.registerCallback(events.requestEnded(), cb); + ssIast.registerCallback(events.requestEnded(), cb); + BiFunction> uniCb = + gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); + Flow res = uniCb.apply(callback.ctx, AgentTracer.noopSpan()); + + assertEquals(2, count[0]); + assertNotNull(res); + + BiFunction> uniCb2 = + gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); + assertSame(uniCb2, uniCb); + } + + @Test + public void universalCallbackWithOnlyAppSec() { + ss.registerCallback(events.requestEnded(), callback); - @Override - public Flow apply(RequestContext requestContext, T arg) { - count++; - throw new IllegalArgumentException(); + BiFunction> uniCb = + gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); + BiFunction> appSecCb = cbp.getCallback(events.requestEnded()); + assertSame(uniCb, appSecCb); } - @Override - public Flow get() { - count++; - throw new IllegalArgumentException(); + @Test + public void universalCallbackWithOnlyIast() { + SubscriptionService ssIast = gateway.getSubscriptionService(RequestContextSlot.IAST); + CallbackProvider cbpIast = gateway.getCallbackProvider(RequestContextSlot.IAST); + + ssIast.registerCallback(events.requestEnded(), callback); + + BiFunction> uniCb = + gateway.getUniversalCallbackProvider().getCallback(events.requestEnded()); + BiFunction> iastCb = cbpIast.getCallback(events.requestEnded()); + assertSame(uniCb, iastCb); } - @Override - public void accept(RequestContext requestContext, T s, T s2) { - count++; - throw new IllegalArgumentException(); + @Test + public void universalCallbackWithNoCallbacks() { + assertNull(gateway.getUniversalCallbackProvider().getCallback(events.requestEnded())); } - @Override - public void accept(RequestContext requestContext, T t) { - count++; - throw new IllegalArgumentException(); + @Test + public void mergeFlowIdenticalFlows() { + Flow flow = new Flow.ResultFlow<>(null); + Flow resFlow = InstrumentationGateway.mergeFlows(flow, flow); + + assertSame(flow, resFlow); } - public TriFunction> asClientSocketAddress() { - return (requestContext, s, aShort) -> { - count++; - throw new IllegalArgumentException(); - }; + @Test + public void mergeFlowBlockingActionHasPriority() { + Flow flow1 = new Flow.ResultFlow<>(null); + Flow flow2 = new Flow.ResultFlow(null) { + @Override + public Action getAction() { + return new Action.RequestBlockingAction(410, BlockingContentType.AUTO); + } + }; + + Flow resFlow1 = InstrumentationGateway.mergeFlows(flow1, flow2); + Flow resFlow2 = InstrumentationGateway.mergeFlows(flow2, flow1); + + assertTrue(resFlow1.getAction().isBlocking()); + assertTrue(resFlow2.getAction().isBlocking()); } - public BiFunction> asInferredClientAddress() { - return (requestContext, s) -> { - count++; - throw new IllegalArgumentException(); - }; + @Test + public void mergeFlowReturnsNonNullResult() { + Flow flow1 = new Flow.ResultFlow<>(42); + Flow flow2 = new Flow.ResultFlow<>(null); + + Flow resFlow1 = InstrumentationGateway.mergeFlows(flow1, flow2); + Flow resFlow2 = InstrumentationGateway.mergeFlows(flow2, flow1); + + assertEquals(42, resFlow1.getResult()); + assertEquals(42, resFlow2.getResult()); } - public BiFunction asRequestBodyStart() { - return (requestContext, storedBodySupplier) -> { - count++; - throw new IllegalArgumentException(); - }; + @Test + public void mergeFlowReturnsLatestNonNullResult() { + Flow flow1 = new Flow.ResultFlow<>(42); + Flow flow2 = new Flow.ResultFlow<>(43); + + Flow resFlow1 = InstrumentationGateway.mergeFlows(flow1, flow2); + Flow resFlow2 = InstrumentationGateway.mergeFlows(flow2, flow1); + + assertEquals(43, resFlow1.getResult()); + assertEquals(42, resFlow2.getResult()); + } + + private static class Callback + implements Supplier>, + BiConsumer, + TriConsumer, + BiFunction>, + TriFunction> { + + private final RequestContext ctx; + private final Flow flow; + private int count = 0; + private final Function> function; + + public Callback(RequestContext ctx, Flow flow) { + this.ctx = ctx; + this.flow = flow; + function = input -> { + count++; + return flow; + }; + } + + @Override + public Flow apply(RequestContext requestContext, T arg) { + count++; + return flow; + } + + @Override + public Flow get() { + count++; + return new Flow.ResultFlow<>((D) ctx); + } + + @Override + public void accept(RequestContext requestContext, T s, T s2) { + count++; + } + + public TriFunction> asClientSocketAddress() { + return (requestContext, s, aShort) -> { + count++; + return flow; + }; + } + + public BiFunction asRequestBodyStart() { + return (requestContext, storedBodySupplier) -> { + count++; + return null; + }; + } + + public BiFunction> asRequestBodyDone() { + return (requestContext, storedBodySupplier) -> { + count++; + return new Flow.ResultFlow<>(null); + }; + } + + @Override + public void accept(RequestContext requestContext, T t) { + count++; + } + + @Override + public Flow apply(RequestContext requestContext, T t, T t2) { + count++; + return flow; + } } - public BiFunction> asRequestBodyDone() { - return (requestContext, storedBodySupplier) -> { - count++; - throw new IllegalArgumentException(); - }; + private static final class CapturingBlockResponseFunction implements BlockResponseFunction { + private TraceSegment segment; + private int statusCode; + private BlockingContentType templateType; + private Map extraHeaders; + private String securityResponseId; + + @Override + public boolean tryCommitBlockingResponse( + TraceSegment segment, + int statusCode, + BlockingContentType templateType, + Map extraHeaders, + String securityResponseId) { + this.segment = segment; + this.statusCode = statusCode; + this.templateType = templateType; + this.extraHeaders = extraHeaders; + this.securityResponseId = securityResponseId; + return true; + } } - @Override - public Flow apply(RequestContext requestContext, T t, T t2) { - count++; - throw new IllegalArgumentException(); + private static class Throwback + implements Supplier>, + BiConsumer, + TriConsumer, + BiFunction>, + TriFunction> { + + private int count = 0; + + private final Function> function = input -> { + count++; + throw new IllegalArgumentException(); + }; + + @Override + public Flow apply(RequestContext requestContext, T arg) { + count++; + throw new IllegalArgumentException(); + } + + @Override + public Flow get() { + count++; + throw new IllegalArgumentException(); + } + + @Override + public void accept(RequestContext requestContext, T s, T s2) { + count++; + throw new IllegalArgumentException(); + } + + @Override + public void accept(RequestContext requestContext, T t) { + count++; + throw new IllegalArgumentException(); + } + + public TriFunction> asClientSocketAddress() { + return (requestContext, s, aShort) -> { + count++; + throw new IllegalArgumentException(); + }; + } + + public BiFunction> asInferredClientAddress() { + return (requestContext, s) -> { + count++; + throw new IllegalArgumentException(); + }; + } + + public BiFunction asRequestBodyStart() { + return (requestContext, storedBodySupplier) -> { + count++; + throw new IllegalArgumentException(); + }; + } + + public BiFunction> asRequestBodyDone() { + return (requestContext, storedBodySupplier) -> { + count++; + throw new IllegalArgumentException(); + }; + } + + @Override + public Flow apply(RequestContext requestContext, T t, T t2) { + count++; + throw new IllegalArgumentException(); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java b/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java index cddffb6a7de..055ee1b10db 100644 --- a/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java +++ b/internal-api/src/test/java/datadog/trace/api/http/MultipartContentDecoderTest.java @@ -15,209 +15,199 @@ public class MultipartContentDecoderTest { - @Test - void decodeBytesUsesDeclaredUtf8Charset() { - String text = "héllo wörld"; - byte[] bytes = text.getBytes(StandardCharsets.UTF_8); - assertEquals( - text, - MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain; charset=UTF-8")); - } - - @Test - void decodeBytesUsesDeclaredIso88591Charset() { - String text = "café"; - byte[] bytes = text.getBytes(StandardCharsets.ISO_8859_1); - assertEquals( - text, - MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain; charset=ISO-8859-1")); - } - - @Test - void decodeBytesDefaultsToMachineDefaultWhenNoCharset() { - String text = "hello world"; - byte[] bytes = text.getBytes(StandardCharsets.UTF_8); - assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain")); - } - - @Test - void decodeBytesDefaultsToMachineDefaultWhenNullContentType() { - String text = "hello world"; - byte[] bytes = text.getBytes(StandardCharsets.UTF_8); - assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, null)); - } - - @Test - void decodeBytesRespectsLengthParameter() { - byte[] bytes = "hello world".getBytes(StandardCharsets.UTF_8); - assertEquals("hello", MultipartContentDecoder.decodeBytes(bytes, 5, null)); - } - - @Test - void decodeBytesReturnsEmptyStringForZeroLength() { - assertEquals("", MultipartContentDecoder.decodeBytes(new byte[16], 0, null)); - } - - @Test - void decodeBytesReplacesMalformedBytesWithReplacementCharacterUsingDeclaredCharset() { - // 0xE9 (ISO-8859-1 'é') is not valid UTF-8; REPLACE substitutes U+FFFD - byte[] bytes = "café".getBytes(StandardCharsets.ISO_8859_1); - assertEquals( - "caf�", - MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain; charset=UTF-8")); - } - - @Test - void decodeBytesHandlesTruncationAtMultibyteCharacterBoundary() { - // "€" encodes as 3 bytes in UTF-8: E2 82 AC - byte[] complete = "hello€".getBytes(StandardCharsets.UTF_8); // 8 bytes - // Pass only 6 bytes: "hello" + first byte of "€" (incomplete sequence) - String result = MultipartContentDecoder.decodeBytes(complete, 6, "text/plain; charset=UTF-8"); - // Incomplete sequence → U+FFFD with declared charset, not fallback to JVM default - assertEquals("hello�", result); - } - - @ParameterizedTest - @NullAndEmptySource - void extractCharsetReturnsNullForNullOrEmptyContentType(String contentType) { - assertNull(MultipartContentDecoder.extractCharset(contentType)); - } - - @ParameterizedTest - @ValueSource(strings = {"text/plain", "image/jpeg", "application/octet-stream"}) - void extractCharsetReturnsNullForContentTypeWithoutCharset(String contentType) { - assertNull(MultipartContentDecoder.extractCharset(contentType)); - } - - @Test - void extractCharsetReturnsNullForInvalidCharsetName() { - assertNull(MultipartContentDecoder.extractCharset("text/plain; charset=NOTACHARSET")); - } - - @ParameterizedTest - @ValueSource( - strings = { - "text/plain; CHARSET=UTF-8", - "text/plain; Charset=UTF-8", - "text/plain; charset=utf-8" - }) - void extractCharsetIsCaseInsensitive(String contentType) { - assertEquals("UTF-8", MultipartContentDecoder.extractCharset(contentType).name()); - } - - @ParameterizedTest - @CsvSource({"text/plain; charset=UTF-8, UTF-8", "text/xml; charset=ISO-8859-1, ISO-8859-1"}) - void extractCharsetFromStandardContentType(String contentType, String expectedCharset) { - assertEquals(expectedCharset, MultipartContentDecoder.extractCharset(contentType).name()); - } - - @ParameterizedTest - @ValueSource( - strings = { - "text/plain;\tcharset=ISO-8859-1", - "text/plain; \tcharset=ISO-8859-1", - "text/plain;\t charset=ISO-8859-1" - }) - void extractCharsetAcceptsTabAsParameterBoundary(String contentType) { - // RFC 7230 optional whitespace (OWS) allows both space and horizontal tab before a parameter - assertEquals("ISO-8859-1", MultipartContentDecoder.extractCharset(contentType).name()); - } - - @ParameterizedTest - @ValueSource( - strings = { - "text/plain; charset = ISO-8859-1", - "text/plain; charset =ISO-8859-1", - "text/plain; charset= ISO-8859-1" - }) - void extractCharsetAcceptsWhitespaceAroundEqualsSign(String contentType) { - assertEquals("ISO-8859-1", MultipartContentDecoder.extractCharset(contentType).name()); - } - - @Test - void extractCharsetContinuesSearchAfterInvalidCharsetValue() { - // The first "charset" parameter is invalid; a later, valid one must still be found. - assertEquals( - "UTF-8", - MultipartContentDecoder.extractCharset("text/plain; charset=NOTACHARSET; charset=UTF-8") - .name()); - } - - @Test - void extractCharsetIgnoresCharsetLookingSubstringInsideQuotedParameterValue() { - // The quoted boundary value contains "charset=" but must be treated as opaque text, not a - // real charset parameter; the actual charset= that follows must be used. - assertEquals( - "UTF-8", - MultipartContentDecoder.extractCharset( - "text/plain; boundary=\"charset=oops\"; charset=UTF-8") - .name()); - } - - @Test - void extractCharsetReturnsNullWhenOnlyMatchIsInsideQuotedParameterValue() { - assertNull(MultipartContentDecoder.extractCharset("text/plain; boundary=\"charset=oops\"")); - } - - @Test - void extractCharsetIgnoresSubstringMatchInParameterName() { - // "xcharset=UTF-16" must not match; the real "charset=UTF-8" that follows must be used - assertEquals( - "UTF-8", - MultipartContentDecoder.extractCharset("text/plain; xcharset=UTF-16; charset=UTF-8") - .name()); - } - - @Test - void extractCharsetReturnsNullWhenOnlySubstringMatchExists() { - assertNull(MultipartContentDecoder.extractCharset("text/plain; xcharset=UTF-8")); - } - - @Test - void extractCharsetHandlesAdditionalParameters() { - assertEquals( - "UTF-8", - MultipartContentDecoder.extractCharset("text/plain; charset=UTF-8; boundary=something") - .name()); - } - - @ParameterizedTest - @CsvSource({ - "text/plain; charset=\"UTF-8\", UTF-8", - "text/xml; charset=\"ISO-8859-1\", ISO-8859-1" - }) - void extractCharsetHandlesQuotedCharsetValue(String contentType, String expectedCharset) { - assertEquals(expectedCharset, MultipartContentDecoder.extractCharset(contentType).name()); - } - - @Test - void decodeBytesUsesQuotedDeclaredCharset() { - String text = "café"; - byte[] bytes = text.getBytes(StandardCharsets.ISO_8859_1); - assertEquals( - text, - MultipartContentDecoder.decodeBytes( - bytes, bytes.length, "text/plain; charset=\"ISO-8859-1\"")); - } - - @Test - void readInputStreamTruncatesAtMaxBytes() throws IOException { - byte[] data = "hello world".getBytes(StandardCharsets.UTF_8); - assertEquals( - "hello", MultipartContentDecoder.readInputStream(new ByteArrayInputStream(data), 5, null)); - } - - @Test - void readInputStreamHandlesMultipleReadCallsToFillBuffer() throws IOException { - byte[] data = "hello world".getBytes(StandardCharsets.UTF_8); - // InputStream that returns 2 bytes per read() call to exercise the accumulation loop. - InputStream slow = - new ByteArrayInputStream(data) { - @Override - public synchronized int read(byte[] b, int off, int len) { - return super.read(b, off, Math.min(len, 2)); - } + @Test + void decodeBytesUsesDeclaredUtf8Charset() { + String text = "héllo wörld"; + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain; charset=UTF-8")); + } + + @Test + void decodeBytesUsesDeclaredIso88591Charset() { + String text = "café"; + byte[] bytes = text.getBytes(StandardCharsets.ISO_8859_1); + assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain; charset=ISO-8859-1")); + } + + @Test + void decodeBytesDefaultsToMachineDefaultWhenNoCharset() { + String text = "hello world"; + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain")); + } + + @Test + void decodeBytesDefaultsToMachineDefaultWhenNullContentType() { + String text = "hello world"; + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + assertEquals(text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, null)); + } + + @Test + void decodeBytesRespectsLengthParameter() { + byte[] bytes = "hello world".getBytes(StandardCharsets.UTF_8); + assertEquals("hello", MultipartContentDecoder.decodeBytes(bytes, 5, null)); + } + + @Test + void decodeBytesReturnsEmptyStringForZeroLength() { + assertEquals("", MultipartContentDecoder.decodeBytes(new byte[16], 0, null)); + } + + @Test + void decodeBytesReplacesMalformedBytesWithReplacementCharacterUsingDeclaredCharset() { + // 0xE9 (ISO-8859-1 'é') is not valid UTF-8; REPLACE substitutes U+FFFD + byte[] bytes = "café".getBytes(StandardCharsets.ISO_8859_1); + assertEquals("caf�", MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain; charset=UTF-8")); + } + + @Test + void decodeBytesHandlesTruncationAtMultibyteCharacterBoundary() { + // "€" encodes as 3 bytes in UTF-8: E2 82 AC + byte[] complete = "hello€".getBytes(StandardCharsets.UTF_8); // 8 bytes + // Pass only 6 bytes: "hello" + first byte of "€" (incomplete sequence) + String result = MultipartContentDecoder.decodeBytes(complete, 6, "text/plain; charset=UTF-8"); + // Incomplete sequence → U+FFFD with declared charset, not fallback to JVM default + assertEquals("hello�", result); + } + + @ParameterizedTest + @NullAndEmptySource + void extractCharsetReturnsNullForNullOrEmptyContentType(String contentType) { + assertNull(MultipartContentDecoder.extractCharset(contentType)); + } + + @ParameterizedTest + @ValueSource(strings = {"text/plain", "image/jpeg", "application/octet-stream"}) + void extractCharsetReturnsNullForContentTypeWithoutCharset(String contentType) { + assertNull(MultipartContentDecoder.extractCharset(contentType)); + } + + @Test + void extractCharsetReturnsNullForInvalidCharsetName() { + assertNull(MultipartContentDecoder.extractCharset("text/plain; charset=NOTACHARSET")); + } + + @ParameterizedTest + @ValueSource(strings = {"text/plain; CHARSET=UTF-8", "text/plain; Charset=UTF-8", "text/plain; charset=utf-8"}) + void extractCharsetIsCaseInsensitive(String contentType) { + assertEquals( + "UTF-8", MultipartContentDecoder.extractCharset(contentType).name()); + } + + @ParameterizedTest + @CsvSource({"text/plain; charset=UTF-8, UTF-8", "text/xml; charset=ISO-8859-1, ISO-8859-1"}) + void extractCharsetFromStandardContentType(String contentType, String expectedCharset) { + assertEquals( + expectedCharset, + MultipartContentDecoder.extractCharset(contentType).name()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "text/plain;\tcharset=ISO-8859-1", + "text/plain; \tcharset=ISO-8859-1", + "text/plain;\t charset=ISO-8859-1" + }) + void extractCharsetAcceptsTabAsParameterBoundary(String contentType) { + // RFC 7230 optional whitespace (OWS) allows both space and horizontal tab before a parameter + assertEquals( + "ISO-8859-1", + MultipartContentDecoder.extractCharset(contentType).name()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "text/plain; charset = ISO-8859-1", + "text/plain; charset =ISO-8859-1", + "text/plain; charset= ISO-8859-1" + }) + void extractCharsetAcceptsWhitespaceAroundEqualsSign(String contentType) { + assertEquals( + "ISO-8859-1", + MultipartContentDecoder.extractCharset(contentType).name()); + } + + @Test + void extractCharsetContinuesSearchAfterInvalidCharsetValue() { + // The first "charset" parameter is invalid; a later, valid one must still be found. + assertEquals( + "UTF-8", + MultipartContentDecoder.extractCharset("text/plain; charset=NOTACHARSET; charset=UTF-8") + .name()); + } + + @Test + void extractCharsetIgnoresCharsetLookingSubstringInsideQuotedParameterValue() { + // The quoted boundary value contains "charset=" but must be treated as opaque text, not a + // real charset parameter; the actual charset= that follows must be used. + assertEquals( + "UTF-8", + MultipartContentDecoder.extractCharset("text/plain; boundary=\"charset=oops\"; charset=UTF-8") + .name()); + } + + @Test + void extractCharsetReturnsNullWhenOnlyMatchIsInsideQuotedParameterValue() { + assertNull(MultipartContentDecoder.extractCharset("text/plain; boundary=\"charset=oops\"")); + } + + @Test + void extractCharsetIgnoresSubstringMatchInParameterName() { + // "xcharset=UTF-16" must not match; the real "charset=UTF-8" that follows must be used + assertEquals( + "UTF-8", + MultipartContentDecoder.extractCharset("text/plain; xcharset=UTF-16; charset=UTF-8") + .name()); + } + + @Test + void extractCharsetReturnsNullWhenOnlySubstringMatchExists() { + assertNull(MultipartContentDecoder.extractCharset("text/plain; xcharset=UTF-8")); + } + + @Test + void extractCharsetHandlesAdditionalParameters() { + assertEquals( + "UTF-8", + MultipartContentDecoder.extractCharset("text/plain; charset=UTF-8; boundary=something") + .name()); + } + + @ParameterizedTest + @CsvSource({"text/plain; charset=\"UTF-8\", UTF-8", "text/xml; charset=\"ISO-8859-1\", ISO-8859-1"}) + void extractCharsetHandlesQuotedCharsetValue(String contentType, String expectedCharset) { + assertEquals( + expectedCharset, + MultipartContentDecoder.extractCharset(contentType).name()); + } + + @Test + void decodeBytesUsesQuotedDeclaredCharset() { + String text = "café"; + byte[] bytes = text.getBytes(StandardCharsets.ISO_8859_1); + assertEquals( + text, MultipartContentDecoder.decodeBytes(bytes, bytes.length, "text/plain; charset=\"ISO-8859-1\"")); + } + + @Test + void readInputStreamTruncatesAtMaxBytes() throws IOException { + byte[] data = "hello world".getBytes(StandardCharsets.UTF_8); + assertEquals("hello", MultipartContentDecoder.readInputStream(new ByteArrayInputStream(data), 5, null)); + } + + @Test + void readInputStreamHandlesMultipleReadCallsToFillBuffer() throws IOException { + byte[] data = "hello world".getBytes(StandardCharsets.UTF_8); + // InputStream that returns 2 bytes per read() call to exercise the accumulation loop. + InputStream slow = new ByteArrayInputStream(data) { + @Override + public synchronized int read(byte[] b, int off, int len) { + return super.read(b, off, Math.min(len, 2)); + } }; - assertEquals("hello world", MultipartContentDecoder.readInputStream(slow, data.length, null)); - } + assertEquals("hello world", MultipartContentDecoder.readInputStream(slow, data.length, null)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/iast/HttpReqquestEndModuleTestImpl.java b/internal-api/src/test/java/datadog/trace/api/iast/HttpReqquestEndModuleTestImpl.java index ecaa2d010ef..08e287a4ecc 100644 --- a/internal-api/src/test/java/datadog/trace/api/iast/HttpReqquestEndModuleTestImpl.java +++ b/internal-api/src/test/java/datadog/trace/api/iast/HttpReqquestEndModuleTestImpl.java @@ -4,6 +4,6 @@ import datadog.trace.api.iast.sink.HttpRequestEndModule; public class HttpReqquestEndModuleTestImpl implements HttpRequestEndModule { - @Override - public void onRequestEnd(IastContext ctx, IGSpanInfo span) {} + @Override + public void onRequestEnd(IastContext ctx, IGSpanInfo span) {} } diff --git a/internal-api/src/test/java/datadog/trace/api/llmobs/GenAiApmTagsTest.java b/internal-api/src/test/java/datadog/trace/api/llmobs/GenAiApmTagsTest.java index 0fef8aaf1af..f7dff18910f 100644 --- a/internal-api/src/test/java/datadog/trace/api/llmobs/GenAiApmTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/llmobs/GenAiApmTagsTest.java @@ -18,210 +18,206 @@ import org.junit.jupiter.api.Test; class GenAiApmTagsTest { - private static final String LLMOBS_TAG_PREFIX = "_ml_obs_tag."; - private static final String LLMOBS_METRIC_PREFIX = "_ml_obs_metric."; - - private final Map tags = new HashMap<>(); - private AgentSpan span; - - @BeforeEach - void setUp() { - span = mock(AgentSpan.class); - when(span.getTag(anyString())).thenAnswer(call -> tags.get(call.getArgument(0))); - when(span.setTag(anyString(), anyString())) - .thenAnswer( - call -> { - tags.put(call.getArgument(0), call.getArgument(1)); - return span; - }); - when(span.setMetric(any(CharSequence.class), anyDouble())) - .thenAnswer( - call -> { - tags.put(call.getArgument(0).toString(), call.getArgument(1)); - return span; - }); - } - - @Test - void llmSpanEmitsEveryScalarAndTokenUsage() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_LLM_SPAN_KIND); - llmObsTag(LLMObsTags.MODEL_NAME, "gpt-4"); - llmObsTag(LLMObsTags.MODEL_PROVIDER, "OpenAI"); - llmObsTag(LLMObsTags.ML_APP, "my-app"); - llmObsTag(LLMObsTags.SESSION_ID, "sess-1"); - llmObsMetric("input_tokens", 10); - llmObsMetric("output_tokens", 20); - llmObsMetric("total_tokens", 30); - llmObsMetric("cache_read_input_tokens", 4); - llmObsMetric("cache_write_input_tokens", 5); - llmObsMetric("reasoning_output_tokens", 6); - - GenAiApmTags.apply(span); - - assertEquals(Tags.LLMOBS_LLM_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); - assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); - assertEquals("openai", tags.get(GenAiApmTags.PROVIDER_NAME)); - assertEquals("my-app", tags.get(GenAiApmTags.APPLICATION_NAME)); - assertEquals("sess-1", tags.get(GenAiApmTags.CONVERSATION_ID)); - assertEquals(10.0, tags.get(GenAiApmTags.USAGE_INPUT_TOKENS)); - assertEquals(20.0, tags.get(GenAiApmTags.USAGE_OUTPUT_TOKENS)); - assertEquals(30.0, tags.get(GenAiApmTags.USAGE_TOTAL_TOKENS)); - assertEquals(4.0, tags.get(GenAiApmTags.USAGE_CACHE_READ_INPUT_TOKENS)); - assertEquals(5.0, tags.get(GenAiApmTags.USAGE_CACHE_WRITE_INPUT_TOKENS)); - assertEquals(6.0, tags.get(GenAiApmTags.USAGE_REASONING_OUTPUT_TOKENS)); - assertEquals("true", tags.get(GenAiApmTags.ARTIFICIAL_TAGS)); - } - - @Test - void modelBackedSpanWithoutModelFallsBackToCustom() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_EMBEDDING_SPAN_KIND); - - GenAiApmTags.apply(span); - - assertEquals("custom", tags.get(GenAiApmTags.REQUEST_MODEL)); - assertEquals("custom", tags.get(GenAiApmTags.PROVIDER_NAME)); - } - - @Test - void emptyModelValuesAreTreatedAsAbsent() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_LLM_SPAN_KIND); - llmObsTag(LLMObsTags.MODEL_NAME, ""); - llmObsTag(LLMObsTags.MODEL_PROVIDER, ""); - llmObsTag(LLMObsTags.ML_APP, ""); - llmObsTag(LLMObsTags.SESSION_ID, ""); - - GenAiApmTags.apply(span); - - assertEquals("custom", tags.get(GenAiApmTags.REQUEST_MODEL)); - assertEquals("custom", tags.get(GenAiApmTags.PROVIDER_NAME)); - assertNull(tags.get(GenAiApmTags.APPLICATION_NAME)); - assertNull(tags.get(GenAiApmTags.CONVERSATION_ID)); - } - - @Test - void nonModelBackedSpanKeepsExplicitModelFieldsWithoutFallbacks() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_AGENT_SPAN_KIND); - llmObsTag(LLMObsTags.MODEL_NAME, "gpt-4"); - llmObsTag(LLMObsTags.MODEL_PROVIDER, "OpenAI"); - - GenAiApmTags.apply(span); - - assertEquals(Tags.LLMOBS_AGENT_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); - assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); - assertEquals("openai", tags.get(GenAiApmTags.PROVIDER_NAME)); - } - - @Test - void nonModelBackedSpanWithoutModelEmitsNoModelFields() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_WORKFLOW_SPAN_KIND); - - GenAiApmTags.apply(span); - - assertFalse(tags.containsKey(GenAiApmTags.REQUEST_MODEL)); - assertFalse(tags.containsKey(GenAiApmTags.PROVIDER_NAME)); - } - - @Test - void nonModelBackedSpanDropsTokenUsage() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_TASK_SPAN_KIND); - llmObsMetric("input_tokens", 10); - - GenAiApmTags.apply(span); - - assertFalse(tags.containsKey(GenAiApmTags.USAGE_INPUT_TOKENS)); - } - - @Test - void nonNumericTokenMetricIsSkipped() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_LLM_SPAN_KIND); - tags.put(LLMOBS_METRIC_PREFIX + "input_tokens", "not-a-number"); - - GenAiApmTags.apply(span); - - assertFalse(tags.containsKey(GenAiApmTags.USAGE_INPUT_TOKENS)); - } - - @Test - void spanWithoutLlmObsKindEmitsNothing() { - llmObsTag(LLMObsTags.MODEL_NAME, "gpt-4"); - - GenAiApmTags.apply(span); - - assertTrue(tags.keySet().stream().noneMatch(key -> key.startsWith("gen_ai."))); - assertFalse(tags.containsKey(GenAiApmTags.ARTIFICIAL_TAGS)); - } - - @Test - void nullSpanIsANoOp() { - GenAiApmTags.apply(null); - GenAiApmTags.apply(null, Tags.LLMOBS_LLM_SPAN_KIND, "gpt-4", "app"); - } - - @Test - void argumentsStandInForMissingLlmObsTags() { - llmObsTag(LLMObsTags.MODEL_PROVIDER, "OpenAI"); - - GenAiApmTags.apply(span, Tags.LLMOBS_LLM_SPAN_KIND, "gpt-4", "my-app"); - - assertEquals(Tags.LLMOBS_LLM_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); - assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); - assertEquals("openai", tags.get(GenAiApmTags.PROVIDER_NAME)); - assertEquals("my-app", tags.get(GenAiApmTags.APPLICATION_NAME)); - assertFalse(tags.containsKey(GenAiApmTags.CONVERSATION_ID)); - assertFalse(tags.containsKey(GenAiApmTags.USAGE_INPUT_TOKENS)); - } + private static final String LLMOBS_TAG_PREFIX = "_ml_obs_tag."; + private static final String LLMOBS_METRIC_PREFIX = "_ml_obs_metric."; + + private final Map tags = new HashMap<>(); + private AgentSpan span; + + @BeforeEach + void setUp() { + span = mock(AgentSpan.class); + when(span.getTag(anyString())).thenAnswer(call -> tags.get(call.getArgument(0))); + when(span.setTag(anyString(), anyString())).thenAnswer(call -> { + tags.put(call.getArgument(0), call.getArgument(1)); + return span; + }); + when(span.setMetric(any(CharSequence.class), anyDouble())).thenAnswer(call -> { + tags.put(call.getArgument(0).toString(), call.getArgument(1)); + return span; + }); + } + + @Test + void llmSpanEmitsEveryScalarAndTokenUsage() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_LLM_SPAN_KIND); + llmObsTag(LLMObsTags.MODEL_NAME, "gpt-4"); + llmObsTag(LLMObsTags.MODEL_PROVIDER, "OpenAI"); + llmObsTag(LLMObsTags.ML_APP, "my-app"); + llmObsTag(LLMObsTags.SESSION_ID, "sess-1"); + llmObsMetric("input_tokens", 10); + llmObsMetric("output_tokens", 20); + llmObsMetric("total_tokens", 30); + llmObsMetric("cache_read_input_tokens", 4); + llmObsMetric("cache_write_input_tokens", 5); + llmObsMetric("reasoning_output_tokens", 6); + + GenAiApmTags.apply(span); + + assertEquals(Tags.LLMOBS_LLM_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); + assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); + assertEquals("openai", tags.get(GenAiApmTags.PROVIDER_NAME)); + assertEquals("my-app", tags.get(GenAiApmTags.APPLICATION_NAME)); + assertEquals("sess-1", tags.get(GenAiApmTags.CONVERSATION_ID)); + assertEquals(10.0, tags.get(GenAiApmTags.USAGE_INPUT_TOKENS)); + assertEquals(20.0, tags.get(GenAiApmTags.USAGE_OUTPUT_TOKENS)); + assertEquals(30.0, tags.get(GenAiApmTags.USAGE_TOTAL_TOKENS)); + assertEquals(4.0, tags.get(GenAiApmTags.USAGE_CACHE_READ_INPUT_TOKENS)); + assertEquals(5.0, tags.get(GenAiApmTags.USAGE_CACHE_WRITE_INPUT_TOKENS)); + assertEquals(6.0, tags.get(GenAiApmTags.USAGE_REASONING_OUTPUT_TOKENS)); + assertEquals("true", tags.get(GenAiApmTags.ARTIFICIAL_TAGS)); + } + + @Test + void modelBackedSpanWithoutModelFallsBackToCustom() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_EMBEDDING_SPAN_KIND); + + GenAiApmTags.apply(span); + + assertEquals("custom", tags.get(GenAiApmTags.REQUEST_MODEL)); + assertEquals("custom", tags.get(GenAiApmTags.PROVIDER_NAME)); + } + + @Test + void emptyModelValuesAreTreatedAsAbsent() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_LLM_SPAN_KIND); + llmObsTag(LLMObsTags.MODEL_NAME, ""); + llmObsTag(LLMObsTags.MODEL_PROVIDER, ""); + llmObsTag(LLMObsTags.ML_APP, ""); + llmObsTag(LLMObsTags.SESSION_ID, ""); + + GenAiApmTags.apply(span); + + assertEquals("custom", tags.get(GenAiApmTags.REQUEST_MODEL)); + assertEquals("custom", tags.get(GenAiApmTags.PROVIDER_NAME)); + assertNull(tags.get(GenAiApmTags.APPLICATION_NAME)); + assertNull(tags.get(GenAiApmTags.CONVERSATION_ID)); + } + + @Test + void nonModelBackedSpanKeepsExplicitModelFieldsWithoutFallbacks() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_AGENT_SPAN_KIND); + llmObsTag(LLMObsTags.MODEL_NAME, "gpt-4"); + llmObsTag(LLMObsTags.MODEL_PROVIDER, "OpenAI"); + + GenAiApmTags.apply(span); + + assertEquals(Tags.LLMOBS_AGENT_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); + assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); + assertEquals("openai", tags.get(GenAiApmTags.PROVIDER_NAME)); + } + + @Test + void nonModelBackedSpanWithoutModelEmitsNoModelFields() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_WORKFLOW_SPAN_KIND); + + GenAiApmTags.apply(span); + + assertFalse(tags.containsKey(GenAiApmTags.REQUEST_MODEL)); + assertFalse(tags.containsKey(GenAiApmTags.PROVIDER_NAME)); + } + + @Test + void nonModelBackedSpanDropsTokenUsage() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_TASK_SPAN_KIND); + llmObsMetric("input_tokens", 10); + + GenAiApmTags.apply(span); + + assertFalse(tags.containsKey(GenAiApmTags.USAGE_INPUT_TOKENS)); + } + + @Test + void nonNumericTokenMetricIsSkipped() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_LLM_SPAN_KIND); + tags.put(LLMOBS_METRIC_PREFIX + "input_tokens", "not-a-number"); + + GenAiApmTags.apply(span); + + assertFalse(tags.containsKey(GenAiApmTags.USAGE_INPUT_TOKENS)); + } + + @Test + void spanWithoutLlmObsKindEmitsNothing() { + llmObsTag(LLMObsTags.MODEL_NAME, "gpt-4"); + + GenAiApmTags.apply(span); + + assertTrue(tags.keySet().stream().noneMatch(key -> key.startsWith("gen_ai."))); + assertFalse(tags.containsKey(GenAiApmTags.ARTIFICIAL_TAGS)); + } + + @Test + void nullSpanIsANoOp() { + GenAiApmTags.apply(null); + GenAiApmTags.apply(null, Tags.LLMOBS_LLM_SPAN_KIND, "gpt-4", "app"); + } + + @Test + void argumentsStandInForMissingLlmObsTags() { + llmObsTag(LLMObsTags.MODEL_PROVIDER, "OpenAI"); + + GenAiApmTags.apply(span, Tags.LLMOBS_LLM_SPAN_KIND, "gpt-4", "my-app"); + + assertEquals(Tags.LLMOBS_LLM_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); + assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); + assertEquals("openai", tags.get(GenAiApmTags.PROVIDER_NAME)); + assertEquals("my-app", tags.get(GenAiApmTags.APPLICATION_NAME)); + assertFalse(tags.containsKey(GenAiApmTags.CONVERSATION_ID)); + assertFalse(tags.containsKey(GenAiApmTags.USAGE_INPUT_TOKENS)); + } - @Test - void argumentsTakePrecedenceOverLlmObsTags() { - llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_WORKFLOW_SPAN_KIND); - llmObsTag(LLMObsTags.MODEL_NAME, "gpt-3.5"); - llmObsTag(LLMObsTags.ML_APP, "tagged-app"); - - GenAiApmTags.apply(span, Tags.LLMOBS_LLM_SPAN_KIND, "gpt-4", "my-app"); - - assertEquals(Tags.LLMOBS_LLM_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); - assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); - assertEquals("my-app", tags.get(GenAiApmTags.APPLICATION_NAME)); - } - - @Test - void modelBackedArgumentsFallBackToCustom() { - GenAiApmTags.apply(span, Tags.LLMOBS_EMBEDDING_SPAN_KIND, null, "app"); + @Test + void argumentsTakePrecedenceOverLlmObsTags() { + llmObsTag(Tags.SPAN_KIND, Tags.LLMOBS_WORKFLOW_SPAN_KIND); + llmObsTag(LLMObsTags.MODEL_NAME, "gpt-3.5"); + llmObsTag(LLMObsTags.ML_APP, "tagged-app"); + + GenAiApmTags.apply(span, Tags.LLMOBS_LLM_SPAN_KIND, "gpt-4", "my-app"); + + assertEquals(Tags.LLMOBS_LLM_SPAN_KIND, tags.get(GenAiApmTags.OPERATION_NAME)); + assertEquals("gpt-4", tags.get(GenAiApmTags.REQUEST_MODEL)); + assertEquals("my-app", tags.get(GenAiApmTags.APPLICATION_NAME)); + } + + @Test + void modelBackedArgumentsFallBackToCustom() { + GenAiApmTags.apply(span, Tags.LLMOBS_EMBEDDING_SPAN_KIND, null, "app"); - assertEquals("custom", tags.get(GenAiApmTags.REQUEST_MODEL)); - assertEquals("custom", tags.get(GenAiApmTags.PROVIDER_NAME)); - } + assertEquals("custom", tags.get(GenAiApmTags.REQUEST_MODEL)); + assertEquals("custom", tags.get(GenAiApmTags.PROVIDER_NAME)); + } - @Test - void spanWithNoResolvableOperationEmitsNothing() { - GenAiApmTags.apply(span, null, "gpt-4", "my-app"); + @Test + void spanWithNoResolvableOperationEmitsNothing() { + GenAiApmTags.apply(span, null, "gpt-4", "my-app"); - assertTrue(tags.keySet().stream().noneMatch(key -> key.startsWith("gen_ai."))); - } + assertTrue(tags.keySet().stream().noneMatch(key -> key.startsWith("gen_ai."))); + } - @Test - void usageWritesTheMappedGenAiMetric() { - GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "input_tokens", 11L); - GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "cache_read_input_tokens", 4); + @Test + void usageWritesTheMappedGenAiMetric() { + GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "input_tokens", 11L); + GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "cache_read_input_tokens", 4); - assertEquals(11.0, tags.get(GenAiApmTags.USAGE_INPUT_TOKENS)); - assertEquals(4.0, tags.get(GenAiApmTags.USAGE_CACHE_READ_INPUT_TOKENS)); - } + assertEquals(11.0, tags.get(GenAiApmTags.USAGE_INPUT_TOKENS)); + assertEquals(4.0, tags.get(GenAiApmTags.USAGE_CACHE_READ_INPUT_TOKENS)); + } - @Test - void usageIgnoresUnknownMetricsAndNulls() { - GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "time_to_first_token", 5L); - GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "input_tokens", null); - GenAiApmTags.usage(null, LLMOBS_METRIC_PREFIX + "input_tokens", 11L); + @Test + void usageIgnoresUnknownMetricsAndNulls() { + GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "time_to_first_token", 5L); + GenAiApmTags.usage(span, LLMOBS_METRIC_PREFIX + "input_tokens", null); + GenAiApmTags.usage(null, LLMOBS_METRIC_PREFIX + "input_tokens", 11L); - assertTrue(tags.keySet().stream().noneMatch(key -> key.startsWith("gen_ai."))); - } + assertTrue(tags.keySet().stream().noneMatch(key -> key.startsWith("gen_ai."))); + } - private void llmObsTag(String key, String value) { - tags.put(LLMOBS_TAG_PREFIX + key, value); - } + private void llmObsTag(String key, String value) { + tags.put(LLMOBS_TAG_PREFIX + key, value); + } - private void llmObsMetric(String key, Number value) { - tags.put(LLMOBS_METRIC_PREFIX + key, value); - } + private void llmObsMetric(String key, Number value) { + tags.put(LLMOBS_METRIC_PREFIX + key, value); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsContextTest.java b/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsContextTest.java index 20f91b51a8f..e9862e685bd 100644 --- a/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsContextTest.java +++ b/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsContextTest.java @@ -9,349 +9,333 @@ import org.junit.jupiter.api.Test; class LLMObsContextTest { - @Test - void rootSpanIdIsUndefined() { - assertEquals("undefined", LLMObsContext.ROOT_SPAN_ID); - } - - @Test - void currentReturnsNullWhenNoContextAttached() { - assertNull(LLMObsContext.current()); - } - - @Test - void currentSessionIdReturnsNullWhenNoContextAttached() { - assertNull(LLMObsContext.currentSessionId()); - } - - @Test - void attachStoresSpanContext() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx)) { - assertEquals(ctx, LLMObsContext.current()); + @Test + void rootSpanIdIsUndefined() { + assertEquals("undefined", LLMObsContext.ROOT_SPAN_ID); } - assertNull(LLMObsContext.current()); - } - - @Test - void attachWithoutSessionIdLeavesSessionIdNull() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx)) { - assertNull(LLMObsContext.currentSessionId()); + + @Test + void currentReturnsNullWhenNoContextAttached() { + assertNull(LLMObsContext.current()); } - } - - @Test - void attachWithSessionIdStoresBothContextAndSessionId() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, "session-123")) { - assertEquals(ctx, LLMObsContext.current()); - assertEquals("session-123", LLMObsContext.currentSessionId()); + + @Test + void currentSessionIdReturnsNullWhenNoContextAttached() { + assertNull(LLMObsContext.currentSessionId()); } - assertNull(LLMObsContext.current()); - assertNull(LLMObsContext.currentSessionId()); - } - - @Test - void attachWithNullSessionIdIgnoresSessionId() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, null)) { - assertEquals(ctx, LLMObsContext.current()); - assertNull(LLMObsContext.currentSessionId()); + + @Test + void attachStoresSpanContext() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx)) { + assertEquals(ctx, LLMObsContext.current()); + } + assertNull(LLMObsContext.current()); + } + + @Test + void attachWithoutSessionIdLeavesSessionIdNull() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx)) { + assertNull(LLMObsContext.currentSessionId()); + } } - } - - @Test - void attachWithEmptySessionIdIgnoresSessionId() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, "")) { - assertEquals(ctx, LLMObsContext.current()); - assertNull(LLMObsContext.currentSessionId()); + + @Test + void attachWithSessionIdStoresBothContextAndSessionId() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, "session-123")) { + assertEquals(ctx, LLMObsContext.current()); + assertEquals("session-123", LLMObsContext.currentSessionId()); + } + assertNull(LLMObsContext.current()); + assertNull(LLMObsContext.currentSessionId()); } - } - - @Test - void nestedScopesRestoreParentContextOnClose() { - AgentSpanContext outer = mock(AgentSpanContext.class); - AgentSpanContext inner = mock(AgentSpanContext.class); - try (ContextScope outerScope = LLMObsContext.attach(outer, "outer-session")) { - assertEquals(outer, LLMObsContext.current()); - assertEquals("outer-session", LLMObsContext.currentSessionId()); - try (ContextScope innerScope = LLMObsContext.attach(inner, "inner-session")) { - assertEquals(inner, LLMObsContext.current()); - assertEquals("inner-session", LLMObsContext.currentSessionId()); - } - assertEquals(outer, LLMObsContext.current()); - assertEquals("outer-session", LLMObsContext.currentSessionId()); + + @Test + void attachWithNullSessionIdIgnoresSessionId() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, null)) { + assertEquals(ctx, LLMObsContext.current()); + assertNull(LLMObsContext.currentSessionId()); + } } - assertNull(LLMObsContext.current()); - assertNull(LLMObsContext.currentSessionId()); - } - - @Test - void childScopeInheritsParentSessionId() { - AgentSpanContext parent = mock(AgentSpanContext.class); - AgentSpanContext child = mock(AgentSpanContext.class); - try (ContextScope parentScope = LLMObsContext.attach(parent, "inherited-session")) { - try (ContextScope childScope = LLMObsContext.attach(child)) { - assertEquals(child, LLMObsContext.current()); - assertEquals("inherited-session", LLMObsContext.currentSessionId()); - } + + @Test + void attachWithEmptySessionIdIgnoresSessionId() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, "")) { + assertEquals(ctx, LLMObsContext.current()); + assertNull(LLMObsContext.currentSessionId()); + } } - } - - @Test - void currentAgentVersionReturnsNullWhenNoContextAttached() { - assertNull(LLMObsContext.currentAgentVersion()); - } - - @Test - void attachWithoutAgentVersionLeavesAgentVersionNull() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, null, null)) { - assertNull(LLMObsContext.currentAgentVersion()); + + @Test + void nestedScopesRestoreParentContextOnClose() { + AgentSpanContext outer = mock(AgentSpanContext.class); + AgentSpanContext inner = mock(AgentSpanContext.class); + try (ContextScope outerScope = LLMObsContext.attach(outer, "outer-session")) { + assertEquals(outer, LLMObsContext.current()); + assertEquals("outer-session", LLMObsContext.currentSessionId()); + try (ContextScope innerScope = LLMObsContext.attach(inner, "inner-session")) { + assertEquals(inner, LLMObsContext.current()); + assertEquals("inner-session", LLMObsContext.currentSessionId()); + } + assertEquals(outer, LLMObsContext.current()); + assertEquals("outer-session", LLMObsContext.currentSessionId()); + } + assertNull(LLMObsContext.current()); + assertNull(LLMObsContext.currentSessionId()); } - } - - @Test - void attachWithAgentVersionStoresIt() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, null, "v3")) { - assertEquals(ctx, LLMObsContext.current()); - assertEquals("v3", LLMObsContext.currentAgentVersion()); + + @Test + void childScopeInheritsParentSessionId() { + AgentSpanContext parent = mock(AgentSpanContext.class); + AgentSpanContext child = mock(AgentSpanContext.class); + try (ContextScope parentScope = LLMObsContext.attach(parent, "inherited-session")) { + try (ContextScope childScope = LLMObsContext.attach(child)) { + assertEquals(child, LLMObsContext.current()); + assertEquals("inherited-session", LLMObsContext.currentSessionId()); + } + } } - assertNull(LLMObsContext.currentAgentVersion()); - } - - @Test - void attachWithEmptyAgentVersionIgnoresAgentVersion() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, null, "")) { - assertNull(LLMObsContext.currentAgentVersion()); + + @Test + void currentAgentVersionReturnsNullWhenNoContextAttached() { + assertNull(LLMObsContext.currentAgentVersion()); } - } - - @Test - void nestedScopesRestoreParentAgentVersionOnClose() { - AgentSpanContext outer = mock(AgentSpanContext.class); - AgentSpanContext inner = mock(AgentSpanContext.class); - try (ContextScope outerScope = LLMObsContext.attach(outer, null, "v1")) { - assertEquals("v1", LLMObsContext.currentAgentVersion()); - try (ContextScope innerScope = LLMObsContext.attach(inner, null, "v2")) { - assertEquals("v2", LLMObsContext.currentAgentVersion()); - } - assertEquals("v1", LLMObsContext.currentAgentVersion()); + + @Test + void attachWithoutAgentVersionLeavesAgentVersionNull() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, null, null)) { + assertNull(LLMObsContext.currentAgentVersion()); + } } - assertNull(LLMObsContext.currentAgentVersion()); - } - - @Test - void childScopeInheritsParentAgentVersion() { - AgentSpanContext parent = mock(AgentSpanContext.class); - AgentSpanContext child = mock(AgentSpanContext.class); - try (ContextScope parentScope = LLMObsContext.attach(parent, null, "v3")) { - try (ContextScope childScope = LLMObsContext.attach(child)) { - assertEquals(child, LLMObsContext.current()); - assertEquals("v3", LLMObsContext.currentAgentVersion()); - } + + @Test + void attachWithAgentVersionStoresIt() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, null, "v3")) { + assertEquals(ctx, LLMObsContext.current()); + assertEquals("v3", LLMObsContext.currentAgentVersion()); + } + assertNull(LLMObsContext.currentAgentVersion()); } - } - - @Test - void samplingValuesReturnNullWhenNoContextAttached() { - assertNull(LLMObsContext.currentSamplingDecision()); - assertNull(LLMObsContext.currentSampleRate()); - } - - @Test - void attachWithoutSamplingDecisionLeavesSamplingValuesNull() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, "session-123")) { - assertNull(LLMObsContext.currentSamplingDecision()); - assertNull(LLMObsContext.currentSampleRate()); + + @Test + void attachWithEmptyAgentVersionIgnoresAgentVersion() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, null, "")) { + assertNull(LLMObsContext.currentAgentVersion()); + } } - } - - @Test - void attachWithSamplingDecisionStoresDecisionAndRate() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = - LLMObsContext.attach( - ctx, null, null, "0.25", LLMObsContext.SAMPLING_DECISION_DROPPED, null, null)) { - assertEquals( - LLMObsContext.SAMPLING_DECISION_DROPPED, LLMObsContext.currentSamplingDecision()); - assertEquals("0.25", LLMObsContext.currentSampleRate()); + + @Test + void nestedScopesRestoreParentAgentVersionOnClose() { + AgentSpanContext outer = mock(AgentSpanContext.class); + AgentSpanContext inner = mock(AgentSpanContext.class); + try (ContextScope outerScope = LLMObsContext.attach(outer, null, "v1")) { + assertEquals("v1", LLMObsContext.currentAgentVersion()); + try (ContextScope innerScope = LLMObsContext.attach(inner, null, "v2")) { + assertEquals("v2", LLMObsContext.currentAgentVersion()); + } + assertEquals("v1", LLMObsContext.currentAgentVersion()); + } + assertNull(LLMObsContext.currentAgentVersion()); } - assertNull(LLMObsContext.currentSamplingDecision()); - assertNull(LLMObsContext.currentSampleRate()); - } - - @Test - void attachWithNullSamplingDecisionIgnoresSampleRate() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - // The rate is only meaningful alongside a decision, so it is not stored on its own. - try (ContextScope scope = LLMObsContext.attach(ctx, null, null, "0.25", null, null, null)) { - assertNull(LLMObsContext.currentSamplingDecision()); - assertNull(LLMObsContext.currentSampleRate()); + + @Test + void childScopeInheritsParentAgentVersion() { + AgentSpanContext parent = mock(AgentSpanContext.class); + AgentSpanContext child = mock(AgentSpanContext.class); + try (ContextScope parentScope = LLMObsContext.attach(parent, null, "v3")) { + try (ContextScope childScope = LLMObsContext.attach(child)) { + assertEquals(child, LLMObsContext.current()); + assertEquals("v3", LLMObsContext.currentAgentVersion()); + } + } } - } - - @Test - void childScopeInheritsParentSamplingDecision() { - AgentSpanContext parent = mock(AgentSpanContext.class); - AgentSpanContext child = mock(AgentSpanContext.class); - try (ContextScope parentScope = - LLMObsContext.attach( - parent, null, null, "1", LLMObsContext.SAMPLING_DECISION_SAMPLED, null, null)) { - try (ContextScope childScope = LLMObsContext.attach(child)) { - assertEquals(child, LLMObsContext.current()); - assertEquals( - LLMObsContext.SAMPLING_DECISION_SAMPLED, LLMObsContext.currentSamplingDecision()); - assertEquals("1", LLMObsContext.currentSampleRate()); - } + + @Test + void samplingValuesReturnNullWhenNoContextAttached() { + assertNull(LLMObsContext.currentSamplingDecision()); + assertNull(LLMObsContext.currentSampleRate()); } - } - - // ── full attach (session_id + agent_version + sampling + pagent attribution) ── - - @Test - void currentParentAgentSpanIdReturnsNullWhenNoContextAttached() { - assertNull(LLMObsContext.currentParentAgentSpanId()); - } - - @Test - void currentParentAgentNameReturnsNullWhenNoContextAttached() { - assertNull(LLMObsContext.currentParentAgentName()); - } - - @Test - void fullAttachStoresAllFields() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = - LLMObsContext.attach( - ctx, - "session-1", - "v2", - "0.5", - LLMObsContext.SAMPLING_DECISION_SAMPLED, - "span-99", - "my-agent")) { - assertEquals(ctx, LLMObsContext.current()); - assertEquals("session-1", LLMObsContext.currentSessionId()); - assertEquals("v2", LLMObsContext.currentAgentVersion()); - assertEquals("0.5", LLMObsContext.currentSampleRate()); - assertEquals( - LLMObsContext.SAMPLING_DECISION_SAMPLED, LLMObsContext.currentSamplingDecision()); - assertEquals("span-99", LLMObsContext.currentParentAgentSpanId()); - assertEquals("my-agent", LLMObsContext.currentParentAgentName()); + + @Test + void attachWithoutSamplingDecisionLeavesSamplingValuesNull() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, "session-123")) { + assertNull(LLMObsContext.currentSamplingDecision()); + assertNull(LLMObsContext.currentSampleRate()); + } } - assertNull(LLMObsContext.current()); - assertNull(LLMObsContext.currentSessionId()); - assertNull(LLMObsContext.currentAgentVersion()); - assertNull(LLMObsContext.currentSampleRate()); - assertNull(LLMObsContext.currentSamplingDecision()); - assertNull(LLMObsContext.currentParentAgentSpanId()); - assertNull(LLMObsContext.currentParentAgentName()); - } - - @Test - void fullAttachWithNullSessionIdIgnoresSessionId() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, null, null, null, null, null, null)) { - assertNull(LLMObsContext.currentSessionId()); - assertNull(LLMObsContext.currentAgentVersion()); - assertNull(LLMObsContext.currentParentAgentSpanId()); - assertNull(LLMObsContext.currentParentAgentName()); + + @Test + void attachWithSamplingDecisionStoresDecisionAndRate() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = + LLMObsContext.attach(ctx, null, null, "0.25", LLMObsContext.SAMPLING_DECISION_DROPPED, null, null)) { + assertEquals(LLMObsContext.SAMPLING_DECISION_DROPPED, LLMObsContext.currentSamplingDecision()); + assertEquals("0.25", LLMObsContext.currentSampleRate()); + } + assertNull(LLMObsContext.currentSamplingDecision()); + assertNull(LLMObsContext.currentSampleRate()); } - } - - @Test - void fullAttachWithEmptySessionIdIgnoresSessionId() { - AgentSpanContext ctx = mock(AgentSpanContext.class); - try (ContextScope scope = LLMObsContext.attach(ctx, "", "", null, null, null, null)) { - assertNull(LLMObsContext.currentSessionId()); - assertNull(LLMObsContext.currentAgentVersion()); + + @Test + void attachWithNullSamplingDecisionIgnoresSampleRate() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + // The rate is only meaningful alongside a decision, so it is not stored on its own. + try (ContextScope scope = LLMObsContext.attach(ctx, null, null, "0.25", null, null, null)) { + assertNull(LLMObsContext.currentSamplingDecision()); + assertNull(LLMObsContext.currentSampleRate()); + } } - } - - @Test - void fullAttachNullPagentClearsStaleValuesFromOuterScope() { - // When an inner (non-agent) span attaches with null pagent keys, the outer agent's - // pagent ID and name must not leak through to that span's descendants. - AgentSpanContext outer = mock(AgentSpanContext.class); - AgentSpanContext inner = mock(AgentSpanContext.class); - try (ContextScope outerScope = - LLMObsContext.attach(outer, "s", "v1", null, null, "agent-span-id", "outer-agent")) { - assertEquals("agent-span-id", LLMObsContext.currentParentAgentSpanId()); - assertEquals("outer-agent", LLMObsContext.currentParentAgentName()); - - try (ContextScope innerScope = - LLMObsContext.attach(inner, null, null, null, null, null, null)) { + + @Test + void childScopeInheritsParentSamplingDecision() { + AgentSpanContext parent = mock(AgentSpanContext.class); + AgentSpanContext child = mock(AgentSpanContext.class); + try (ContextScope parentScope = + LLMObsContext.attach(parent, null, null, "1", LLMObsContext.SAMPLING_DECISION_SAMPLED, null, null)) { + try (ContextScope childScope = LLMObsContext.attach(child)) { + assertEquals(child, LLMObsContext.current()); + assertEquals(LLMObsContext.SAMPLING_DECISION_SAMPLED, LLMObsContext.currentSamplingDecision()); + assertEquals("1", LLMObsContext.currentSampleRate()); + } + } + } + + // ── full attach (session_id + agent_version + sampling + pagent attribution) ── + + @Test + void currentParentAgentSpanIdReturnsNullWhenNoContextAttached() { assertNull(LLMObsContext.currentParentAgentSpanId()); + } + + @Test + void currentParentAgentNameReturnsNullWhenNoContextAttached() { assertNull(LLMObsContext.currentParentAgentName()); - } + } - // Outer values are restored after inner scope closes. - assertEquals("agent-span-id", LLMObsContext.currentParentAgentSpanId()); - assertEquals("outer-agent", LLMObsContext.currentParentAgentName()); + @Test + void fullAttachStoresAllFields() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach( + ctx, "session-1", "v2", "0.5", LLMObsContext.SAMPLING_DECISION_SAMPLED, "span-99", "my-agent")) { + assertEquals(ctx, LLMObsContext.current()); + assertEquals("session-1", LLMObsContext.currentSessionId()); + assertEquals("v2", LLMObsContext.currentAgentVersion()); + assertEquals("0.5", LLMObsContext.currentSampleRate()); + assertEquals(LLMObsContext.SAMPLING_DECISION_SAMPLED, LLMObsContext.currentSamplingDecision()); + assertEquals("span-99", LLMObsContext.currentParentAgentSpanId()); + assertEquals("my-agent", LLMObsContext.currentParentAgentName()); + } + assertNull(LLMObsContext.current()); + assertNull(LLMObsContext.currentSessionId()); + assertNull(LLMObsContext.currentAgentVersion()); + assertNull(LLMObsContext.currentSampleRate()); + assertNull(LLMObsContext.currentSamplingDecision()); + assertNull(LLMObsContext.currentParentAgentSpanId()); + assertNull(LLMObsContext.currentParentAgentName()); } - } - - @Test - void fullAttachInnerAgentOverridesOuterAgentForDescendants() { - AgentSpanContext outer = mock(AgentSpanContext.class); - AgentSpanContext inner = mock(AgentSpanContext.class); - try (ContextScope outerScope = - LLMObsContext.attach(outer, null, null, null, null, "outer-span-id", "outer-agent")) { - try (ContextScope innerScope = - LLMObsContext.attach(inner, null, null, null, null, "inner-span-id", "inner-agent")) { - assertEquals("inner-span-id", LLMObsContext.currentParentAgentSpanId()); - assertEquals("inner-agent", LLMObsContext.currentParentAgentName()); - } - assertEquals("outer-span-id", LLMObsContext.currentParentAgentSpanId()); - assertEquals("outer-agent", LLMObsContext.currentParentAgentName()); + + @Test + void fullAttachWithNullSessionIdIgnoresSessionId() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, null, null, null, null, null, null)) { + assertNull(LLMObsContext.currentSessionId()); + assertNull(LLMObsContext.currentAgentVersion()); + assertNull(LLMObsContext.currentParentAgentSpanId()); + assertNull(LLMObsContext.currentParentAgentName()); + } } - } - - @Test - void fullAttachNullPagentNameClearsNameButNotSpanId() { - // An agent with a null name (e.g. manifest not yet set) must not let outer agent's name - // leak into its scope — only the span ID is set. - AgentSpanContext outer = mock(AgentSpanContext.class); - AgentSpanContext inner = mock(AgentSpanContext.class); - try (ContextScope outerScope = - LLMObsContext.attach(outer, null, null, null, null, "outer-span-id", "outer-agent")) { - try (ContextScope innerScope = - LLMObsContext.attach(inner, null, null, null, null, "inner-span-id", null)) { - assertEquals("inner-span-id", LLMObsContext.currentParentAgentSpanId()); - assertNull(LLMObsContext.currentParentAgentName()); - } + + @Test + void fullAttachWithEmptySessionIdIgnoresSessionId() { + AgentSpanContext ctx = mock(AgentSpanContext.class); + try (ContextScope scope = LLMObsContext.attach(ctx, "", "", null, null, null, null)) { + assertNull(LLMObsContext.currentSessionId()); + assertNull(LLMObsContext.currentAgentVersion()); + } } - } - - @Test - void attachPropagatesAllFourMechanismsTogether() { - AgentSpanContext parent = mock(AgentSpanContext.class); - AgentSpanContext child = mock(AgentSpanContext.class); - // All four propagation mechanisms coexist on one context and are inherited together. - try (ContextScope parentScope = - LLMObsContext.attach( - parent, - "session-abc", - "v7", - "0.5", - LLMObsContext.SAMPLING_DECISION_SAMPLED, - "agent-span-7", - "agent-seven")) { - try (ContextScope childScope = LLMObsContext.attach(child)) { - assertEquals("session-abc", LLMObsContext.currentSessionId()); - assertEquals("v7", LLMObsContext.currentAgentVersion()); - assertEquals( - LLMObsContext.SAMPLING_DECISION_SAMPLED, LLMObsContext.currentSamplingDecision()); - assertEquals("0.5", LLMObsContext.currentSampleRate()); - assertEquals("agent-span-7", LLMObsContext.currentParentAgentSpanId()); - assertEquals("agent-seven", LLMObsContext.currentParentAgentName()); - } + + @Test + void fullAttachNullPagentClearsStaleValuesFromOuterScope() { + // When an inner (non-agent) span attaches with null pagent keys, the outer agent's + // pagent ID and name must not leak through to that span's descendants. + AgentSpanContext outer = mock(AgentSpanContext.class); + AgentSpanContext inner = mock(AgentSpanContext.class); + try (ContextScope outerScope = + LLMObsContext.attach(outer, "s", "v1", null, null, "agent-span-id", "outer-agent")) { + assertEquals("agent-span-id", LLMObsContext.currentParentAgentSpanId()); + assertEquals("outer-agent", LLMObsContext.currentParentAgentName()); + + try (ContextScope innerScope = LLMObsContext.attach(inner, null, null, null, null, null, null)) { + assertNull(LLMObsContext.currentParentAgentSpanId()); + assertNull(LLMObsContext.currentParentAgentName()); + } + + // Outer values are restored after inner scope closes. + assertEquals("agent-span-id", LLMObsContext.currentParentAgentSpanId()); + assertEquals("outer-agent", LLMObsContext.currentParentAgentName()); + } + } + + @Test + void fullAttachInnerAgentOverridesOuterAgentForDescendants() { + AgentSpanContext outer = mock(AgentSpanContext.class); + AgentSpanContext inner = mock(AgentSpanContext.class); + try (ContextScope outerScope = + LLMObsContext.attach(outer, null, null, null, null, "outer-span-id", "outer-agent")) { + try (ContextScope innerScope = + LLMObsContext.attach(inner, null, null, null, null, "inner-span-id", "inner-agent")) { + assertEquals("inner-span-id", LLMObsContext.currentParentAgentSpanId()); + assertEquals("inner-agent", LLMObsContext.currentParentAgentName()); + } + assertEquals("outer-span-id", LLMObsContext.currentParentAgentSpanId()); + assertEquals("outer-agent", LLMObsContext.currentParentAgentName()); + } + } + + @Test + void fullAttachNullPagentNameClearsNameButNotSpanId() { + // An agent with a null name (e.g. manifest not yet set) must not let outer agent's name + // leak into its scope — only the span ID is set. + AgentSpanContext outer = mock(AgentSpanContext.class); + AgentSpanContext inner = mock(AgentSpanContext.class); + try (ContextScope outerScope = + LLMObsContext.attach(outer, null, null, null, null, "outer-span-id", "outer-agent")) { + try (ContextScope innerScope = LLMObsContext.attach(inner, null, null, null, null, "inner-span-id", null)) { + assertEquals("inner-span-id", LLMObsContext.currentParentAgentSpanId()); + assertNull(LLMObsContext.currentParentAgentName()); + } + } + } + + @Test + void attachPropagatesAllFourMechanismsTogether() { + AgentSpanContext parent = mock(AgentSpanContext.class); + AgentSpanContext child = mock(AgentSpanContext.class); + // All four propagation mechanisms coexist on one context and are inherited together. + try (ContextScope parentScope = LLMObsContext.attach( + parent, + "session-abc", + "v7", + "0.5", + LLMObsContext.SAMPLING_DECISION_SAMPLED, + "agent-span-7", + "agent-seven")) { + try (ContextScope childScope = LLMObsContext.attach(child)) { + assertEquals("session-abc", LLMObsContext.currentSessionId()); + assertEquals("v7", LLMObsContext.currentAgentVersion()); + assertEquals(LLMObsContext.SAMPLING_DECISION_SAMPLED, LLMObsContext.currentSamplingDecision()); + assertEquals("0.5", LLMObsContext.currentSampleRate()); + assertEquals("agent-span-7", LLMObsContext.currentParentAgentSpanId()); + assertEquals("agent-seven", LLMObsContext.currentParentAgentName()); + } + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsSamplerTest.java b/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsSamplerTest.java index dcf683b31b9..b7ec9e96180 100644 --- a/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsSamplerTest.java +++ b/internal-api/src/test/java/datadog/trace/api/llmobs/LLMObsSamplerTest.java @@ -12,95 +12,93 @@ class LLMObsSamplerTest { - @Test - void keepsEverythingAtRateOne() { - LLMObsSampler sampler = new LLMObsSampler(1.0); - assertEquals("1", sampler.formattedRate()); - for (long id : new long[] {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, 1234567890123L}) { - assertTrue(sampler.sample(id)); + @Test + void keepsEverythingAtRateOne() { + LLMObsSampler sampler = new LLMObsSampler(1.0); + assertEquals("1", sampler.formattedRate()); + for (long id : new long[] {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, 1234567890123L}) { + assertTrue(sampler.sample(id)); + } } - } - @Test - void dropsEverythingAtRateZero() { - LLMObsSampler sampler = new LLMObsSampler(0.0); - assertEquals("0", sampler.formattedRate()); - // 0 is the one sampling id whose Knuth product is the minimum value, so it sits exactly on the - // cutoff at rate 0. Every other id must be dropped. - for (long id : new long[] {1L, -1L, Long.MAX_VALUE, 1234567890123L}) { - assertFalse(sampler.sample(id)); + @Test + void dropsEverythingAtRateZero() { + LLMObsSampler sampler = new LLMObsSampler(0.0); + assertEquals("0", sampler.formattedRate()); + // 0 is the one sampling id whose Knuth product is the minimum value, so it sits exactly on the + // cutoff at rate 0. Every other id must be dropped. + for (long id : new long[] {1L, -1L, Long.MAX_VALUE, 1234567890123L}) { + assertFalse(sampler.sample(id)); + } } - } - @ParameterizedTest - @ValueSource(doubles = {-1.0, -0.0001, 1.0001, 2.0, Double.NaN}) - void outOfRangeRatesFallBackToKeepingEverything(double rate) { - LLMObsSampler sampler = new LLMObsSampler(rate); - // Including negatives and NaN: a bad rate must never be read as "drop everything". - assertEquals("1", sampler.formattedRate()); - for (long id : new long[] {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, 1234567890123L}) { - assertTrue(sampler.sample(id)); + @ParameterizedTest + @ValueSource(doubles = {-1.0, -0.0001, 1.0001, 2.0, Double.NaN}) + void outOfRangeRatesFallBackToKeepingEverything(double rate) { + LLMObsSampler sampler = new LLMObsSampler(rate); + // Including negatives and NaN: a bad rate must never be read as "drop everything". + assertEquals("1", sampler.formattedRate()); + for (long id : new long[] {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, 1234567890123L}) { + assertTrue(sampler.sample(id)); + } } - } - @ParameterizedTest - @CsvSource({ - "1.0, 1", - "0.0, 0", - "0.5, 0.5", - "0.25, 0.25", - "0.1, 0.1", - "0.123456, 0.123456", - // Beyond 6 digits of precision the rate rounds, matching the _dd.p.ksr format exactly. - "0.1234567, 0.123457", - "0.0000001, 0", - "0.999999, 0.999999", - }) - void formatsRateLikeKnuthSamplingRateTag(double rate, String expected) { - assertEquals(expected, LLMObsSampler.formatRate(rate)); - } + @ParameterizedTest + @CsvSource({ + "1.0, 1", + "0.0, 0", + "0.5, 0.5", + "0.25, 0.25", + "0.1, 0.1", + "0.123456, 0.123456", + // Beyond 6 digits of precision the rate rounds, matching the _dd.p.ksr format exactly. + "0.1234567, 0.123457", + "0.0000001, 0", + "0.999999, 0.999999", + }) + void formatsRateLikeKnuthSamplingRateTag(double rate, String expected) { + assertEquals(expected, LLMObsSampler.formatRate(rate)); + } - @Test - void isDeterministicForTheSameSamplingId() { - LLMObsSampler sampler = new LLMObsSampler(0.5); - long id = 987654321987654321L; - boolean first = sampler.sample(id); - for (int i = 0; i < 100; i++) { - assertEquals(first, sampler.sample(id)); + @Test + void isDeterministicForTheSameSamplingId() { + LLMObsSampler sampler = new LLMObsSampler(0.5); + long id = 987654321987654321L; + boolean first = sampler.sample(id); + for (int i = 0; i < 100; i++) { + assertEquals(first, sampler.sample(id)); + } + // Two samplers configured with the same rate must agree, which is what lets independently + // configured services reach the same decision for a distributed trace. + assertEquals(first, new LLMObsSampler(0.5).sample(id)); } - // Two samplers configured with the same rate must agree, which is what lets independently - // configured services reach the same decision for a distributed trace. - assertEquals(first, new LLMObsSampler(0.5).sample(id)); - } - @ParameterizedTest - @ValueSource(doubles = {0.1, 0.25, 0.5, 0.75, 0.9}) - void keepsRoughlyTheConfiguredFraction(double rate) { - LLMObsSampler sampler = new LLMObsSampler(rate); - int iterations = 100_000; - int kept = 0; - for (int i = 0; i < iterations; i++) { - if (sampler.sample(ThreadLocalRandom.current().nextLong())) { - kept++; - } + @ParameterizedTest + @ValueSource(doubles = {0.1, 0.25, 0.5, 0.75, 0.9}) + void keepsRoughlyTheConfiguredFraction(double rate) { + LLMObsSampler sampler = new LLMObsSampler(rate); + int iterations = 100_000; + int kept = 0; + for (int i = 0; i < iterations; i++) { + if (sampler.sample(ThreadLocalRandom.current().nextLong())) { + kept++; + } + } + double observed = (double) kept / iterations; + assertTrue(Math.abs(observed - rate) < 0.02, "expected ~" + rate + " of traces kept but observed " + observed); } - double observed = (double) kept / iterations; - assertTrue( - Math.abs(observed - rate) < 0.02, - "expected ~" + rate + " of traces kept but observed " + observed); - } - @Test - void higherRatesAreASupersetOfLowerRates() { - // A trace kept at 10% must also be kept at 50%: the cutoff only moves outward as the rate - // rises. This is what makes a service configured at a higher rate safe to add to a trace. - LLMObsSampler low = new LLMObsSampler(0.1); - LLMObsSampler high = new LLMObsSampler(0.5); - for (int i = 0; i < 10_000; i++) { - long id = ThreadLocalRandom.current().nextLong(); - if (low.sample(id)) { - assertTrue(high.sample(id), "id " + id + " kept at 0.1 but dropped at 0.5"); - } + @Test + void higherRatesAreASupersetOfLowerRates() { + // A trace kept at 10% must also be kept at 50%: the cutoff only moves outward as the rate + // rises. This is what makes a service configured at a higher rate safe to add to a trace. + LLMObsSampler low = new LLMObsSampler(0.1); + LLMObsSampler high = new LLMObsSampler(0.5); + for (int i = 0; i < 10_000; i++) { + long id = ThreadLocalRandom.current().nextLong(); + if (low.sample(id)) { + assertTrue(high.sample(id), "id " + id + " kept at 0.1 but dropped at 0.5"); + } + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/api/metrics/StatsMetricsTest.java b/internal-api/src/test/java/datadog/trace/api/metrics/StatsMetricsTest.java index abd1b782843..da163162c5b 100644 --- a/internal-api/src/test/java/datadog/trace/api/metrics/StatsMetricsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/metrics/StatsMetricsTest.java @@ -16,107 +16,107 @@ */ class StatsMetricsTest { - private static Map countersByTag() { - return StatsMetrics.getInstance().getTaggedCounters().stream() - .collect(Collectors.toMap(StatsMetrics.TaggedCounter::getTag, Function.identity())); - } - - @AfterEach - void drainDeltas() { - // StatsMetrics is a process-wide singleton; these tests leave per-reason deltas behind. Drain - // every counter so they do not leak into another collector's expectations when tests share a - // Gradle worker (e.g. CoreMetricCollectorTest asserting an exact span-metric count). - for (StatsMetrics.TaggedCounter counter : StatsMetrics.getInstance().getTaggedCounters()) { - counter.getValueAndReset(); + private static Map countersByTag() { + return StatsMetrics.getInstance().getTaggedCounters().stream() + .collect(Collectors.toMap(StatsMetrics.TaggedCounter::getTag, Function.identity())); + } + + @AfterEach + void drainDeltas() { + // StatsMetrics is a process-wide singleton; these tests leave per-reason deltas behind. Drain + // every counter so they do not leak into another collector's expectations when tests share a + // Gradle worker (e.g. CoreMetricCollectorTest asserting an exact span-metric count). + for (StatsMetrics.TaggedCounter counter : StatsMetrics.getInstance().getTaggedCounters()) { + counter.getValueAndReset(); + } + } + + @Test + void accumulatesPerReasonAndEmitsResetDeltas() { + StatsMetrics metrics = StatsMetrics.getInstance(); + String reason = "collapsed:test_accumulate"; + + metrics.onCollapsedSpans(reason, 3); + metrics.onCollapsedSpans(reason, 4); + + StatsMetrics.TaggedCounter counter = countersByTag().get(reason); + assertEquals(StatsMetrics.COLLAPSED_SPANS, counter.getName()); + assertEquals(reason, counter.getTag()); + assertEquals(7, counter.getValue(), "getValue reports the running total"); + + // First drain returns the whole accumulated delta; a second drain with no activity returns 0. + assertEquals(7, counter.getValueAndReset(), "first drain returns the accumulated delta"); + assertEquals(0, counter.getValueAndReset(), "no new activity -> zero delta"); + + metrics.onCollapsedSpans(reason, 5); + assertEquals(5, counter.getValueAndReset(), "only the post-drain increment is returned"); + } + + @Test + void separateReasonsGetSeparateCounters() { + StatsMetrics metrics = StatsMetrics.getInstance(); + String collapsed = "collapsed:test_separate"; + String oversized = "oversized:test_separate"; + + metrics.onCollapsedSpans(collapsed, 2); + metrics.onCollapsedSpans(oversized, 9); + + Map counters = countersByTag(); + assertEquals(2, counters.get(collapsed).getValue()); + assertEquals(9, counters.get(oversized).getValue()); + } + + @Test + void nonPositiveCountsAreIgnored() { + StatsMetrics metrics = StatsMetrics.getInstance(); + String reason = "collapsed:test_nonpositive"; + + metrics.onCollapsedSpans(reason, 0); + metrics.onCollapsedSpans(reason, -5); + + // No counter is created for a reason that never saw a positive count. + assertNull(countersByTag().get(reason), "no counter created for non-positive counts"); + + metrics.onCollapsedSpans(reason, 4); + assertEquals(4, countersByTag().get(reason).getValue()); + // A later non-positive count leaves the running total untouched. + metrics.onCollapsedSpans(reason, -1); + assertEquals(4, countersByTag().get(reason).getValue()); + } + + @Test + void wholeKeyCollapseIncrementsPreCreatedCounter() { + StatsMetrics metrics = StatsMetrics.getInstance(); + + // The whole_key counter is pre-created at construction, so it is always present in the drain. + StatsMetrics.TaggedCounter counter = countersByTag().get(StatsMetrics.COLLAPSED_WHOLE_KEY); + assertEquals(StatsMetrics.COLLAPSED_SPANS, counter.getName()); + assertEquals(StatsMetrics.COLLAPSED_WHOLE_KEY, counter.getTag()); + + long before = counter.getValue(); + metrics.onWholeKeyCollapse(); + metrics.onWholeKeyCollapse(); + assertEquals(before + 2, counter.getValue(), "each call increments the counter by one"); + + // Routing the same tag through onCollapsedSpans hits the same pre-created counter instance. + assertTrue( + countersByTag().get(StatsMetrics.COLLAPSED_WHOLE_KEY) == counter, + "whole_key resolves to a single stable counter"); + metrics.onCollapsedSpans(StatsMetrics.COLLAPSED_WHOLE_KEY, 3); + assertEquals(before + 5, counter.getValue()); + } + + @Test + void reasonCounterIsStableAcrossLookups() { + StatsMetrics metrics = StatsMetrics.getInstance(); + String reason = "collapsed:test_stable"; + + metrics.onCollapsedSpans(reason, 1); + StatsMetrics.TaggedCounter first = countersByTag().get(reason); + metrics.onCollapsedSpans(reason, 1); + StatsMetrics.TaggedCounter second = countersByTag().get(reason); + + assertTrue(first == second, "same reason maps to the same counter instance"); + assertEquals(2, first.getValue()); } - } - - @Test - void accumulatesPerReasonAndEmitsResetDeltas() { - StatsMetrics metrics = StatsMetrics.getInstance(); - String reason = "collapsed:test_accumulate"; - - metrics.onCollapsedSpans(reason, 3); - metrics.onCollapsedSpans(reason, 4); - - StatsMetrics.TaggedCounter counter = countersByTag().get(reason); - assertEquals(StatsMetrics.COLLAPSED_SPANS, counter.getName()); - assertEquals(reason, counter.getTag()); - assertEquals(7, counter.getValue(), "getValue reports the running total"); - - // First drain returns the whole accumulated delta; a second drain with no activity returns 0. - assertEquals(7, counter.getValueAndReset(), "first drain returns the accumulated delta"); - assertEquals(0, counter.getValueAndReset(), "no new activity -> zero delta"); - - metrics.onCollapsedSpans(reason, 5); - assertEquals(5, counter.getValueAndReset(), "only the post-drain increment is returned"); - } - - @Test - void separateReasonsGetSeparateCounters() { - StatsMetrics metrics = StatsMetrics.getInstance(); - String collapsed = "collapsed:test_separate"; - String oversized = "oversized:test_separate"; - - metrics.onCollapsedSpans(collapsed, 2); - metrics.onCollapsedSpans(oversized, 9); - - Map counters = countersByTag(); - assertEquals(2, counters.get(collapsed).getValue()); - assertEquals(9, counters.get(oversized).getValue()); - } - - @Test - void nonPositiveCountsAreIgnored() { - StatsMetrics metrics = StatsMetrics.getInstance(); - String reason = "collapsed:test_nonpositive"; - - metrics.onCollapsedSpans(reason, 0); - metrics.onCollapsedSpans(reason, -5); - - // No counter is created for a reason that never saw a positive count. - assertNull(countersByTag().get(reason), "no counter created for non-positive counts"); - - metrics.onCollapsedSpans(reason, 4); - assertEquals(4, countersByTag().get(reason).getValue()); - // A later non-positive count leaves the running total untouched. - metrics.onCollapsedSpans(reason, -1); - assertEquals(4, countersByTag().get(reason).getValue()); - } - - @Test - void wholeKeyCollapseIncrementsPreCreatedCounter() { - StatsMetrics metrics = StatsMetrics.getInstance(); - - // The whole_key counter is pre-created at construction, so it is always present in the drain. - StatsMetrics.TaggedCounter counter = countersByTag().get(StatsMetrics.COLLAPSED_WHOLE_KEY); - assertEquals(StatsMetrics.COLLAPSED_SPANS, counter.getName()); - assertEquals(StatsMetrics.COLLAPSED_WHOLE_KEY, counter.getTag()); - - long before = counter.getValue(); - metrics.onWholeKeyCollapse(); - metrics.onWholeKeyCollapse(); - assertEquals(before + 2, counter.getValue(), "each call increments the counter by one"); - - // Routing the same tag through onCollapsedSpans hits the same pre-created counter instance. - assertTrue( - countersByTag().get(StatsMetrics.COLLAPSED_WHOLE_KEY) == counter, - "whole_key resolves to a single stable counter"); - metrics.onCollapsedSpans(StatsMetrics.COLLAPSED_WHOLE_KEY, 3); - assertEquals(before + 5, counter.getValue()); - } - - @Test - void reasonCounterIsStableAcrossLookups() { - StatsMetrics metrics = StatsMetrics.getInstance(); - String reason = "collapsed:test_stable"; - - metrics.onCollapsedSpans(reason, 1); - StatsMetrics.TaggedCounter first = countersByTag().get(reason); - metrics.onCollapsedSpans(reason, 1); - StatsMetrics.TaggedCounter second = countersByTag().get(reason); - - assertTrue(first == second, "same reason maps to the same counter instance"); - assertEquals(2, first.getValue()); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/profiling/ProfilerFlareLoggerTest.java b/internal-api/src/test/java/datadog/trace/api/profiling/ProfilerFlareLoggerTest.java index f42747b5576..5c203445a05 100644 --- a/internal-api/src/test/java/datadog/trace/api/profiling/ProfilerFlareLoggerTest.java +++ b/internal-api/src/test/java/datadog/trace/api/profiling/ProfilerFlareLoggerTest.java @@ -39,372 +39,365 @@ class ProfilerFlareLoggerTest { - private ProfilerFlareLogger logger; - - @BeforeEach - void setUp() throws Exception { - logger = ProfilerFlareLogger.getInstance(); - } - - @AfterEach - void tearDown() throws Exception { - logger.cleanup(); - } - - // Singleton Pattern Tests - @Test - void testSingletonConsistency() { - ProfilerFlareLogger instance1 = ProfilerFlareLogger.getInstance(); - ProfilerFlareLogger instance2 = ProfilerFlareLogger.getInstance(); - - assertSame(instance1, instance2); - } - - @Test - void testSingletonThreadSafety() throws Exception { - int threadCount = 10; - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - List> futures = new ArrayList<>(); - - for (int i = 0; i < threadCount; i++) { - futures.add(executor.submit(ProfilerFlareLogger::getInstance)); - } - - ProfilerFlareLogger firstInstance = futures.get(0).get(); - for (Future future : futures) { - assertSame(firstInstance, future.get()); - } - - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - } - - @Test - void testTracerFlareReporterRegistration() { - try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { - ProfilerFlareLogger loggerInstance = new ProfilerFlareLogger(); - mockedStatic.verify(() -> TracerFlare.addReporter(loggerInstance)); - } - } - - // Logging Functionality Tests - @Test - void testBasicLogging() { - assertTrue(logger.log("Test message")); - } - - @Test - void testLoggingWithArguments() { - assertTrue(logger.log("Test message with {} and {}", "arg1", 42)); - } - - @Test - void testLoggingWithException() { - Exception testException = new RuntimeException("Test exception"); - assertTrue(logger.log("Error occurred: {}", "details", testException)); - } - - @ParameterizedTest - @ValueSource(strings = {"", " ", "Simple message", "Message with special chars: !@#$%^&*()"}) - void testLoggingVariousMessages(String message) { - assertTrue(logger.log(message)); - } - - @Test - void testLoggingNullMessage() { - assertTrue(logger.log(null)); - } - - @Test - void testLoggingNullArguments() { - assertTrue(logger.log("Message with null: {}", (Object) null)); - } - - @ParameterizedTest - @MethodSource("provideLogMessageFormats") - void testSLF4JFormatting(String format, Object[] args, String expectedSubstring) { - assertTrue(logger.log(format, args)); - // Additional verification could be added if we can access the logged content - } - - private static Stream provideLogMessageFormats() { - return Stream.of( - Arguments.of("Simple message", new Object[] {}, "Simple message"), - Arguments.of("Message with {}", new Object[] {"placeholder"}, "placeholder"), - Arguments.of("Multiple {} and {}", new Object[] {"first", "second"}, "first"), - Arguments.of("Number: {}", new Object[] {123}, "123"), - Arguments.of("Boolean: {}", new Object[] {true}, "true")); - } - - // Capacity Management Tests - @Test - void testCapacityLimitEnforcement() throws Exception { - int capacity = logger.getMaxReportCapacity(); - - assertEquals(2 * 1024 * 1024, capacity); // 2MiB - } - - @Test - void testLoggingWithinCapacity() { - for (int i = 0; i < 100; i++) { - assertTrue(logger.log("Message {}", i)); - } - } - - @Test - void testCapacityRejection() { - // Fill up capacity with large messages - StringBuilder largeMessage = new StringBuilder(); - for (int i = 0; i < 10000; i++) { - largeMessage.append("Large message content "); - } - - int iterations = logger.getMaxReportCapacity() / largeMessage.length(); - String message = largeMessage.toString(); - - // We must accept all messages until capacity - for (int i = 0; i < iterations; i++) { - assertTrue(logger.log(message)); + private ProfilerFlareLogger logger; + + @BeforeEach + void setUp() throws Exception { + logger = ProfilerFlareLogger.getInstance(); + } + + @AfterEach + void tearDown() throws Exception { + logger.cleanup(); + } + + // Singleton Pattern Tests + @Test + void testSingletonConsistency() { + ProfilerFlareLogger instance1 = ProfilerFlareLogger.getInstance(); + ProfilerFlareLogger instance2 = ProfilerFlareLogger.getInstance(); + + assertSame(instance1, instance2); + } + + @Test + void testSingletonThreadSafety() throws Exception { + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(ProfilerFlareLogger::getInstance)); + } + + ProfilerFlareLogger firstInstance = futures.get(0).get(); + for (Future future : futures) { + assertSame(firstInstance, future.get()); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + + @Test + void testTracerFlareReporterRegistration() { + try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { + ProfilerFlareLogger loggerInstance = new ProfilerFlareLogger(); + mockedStatic.verify(() -> TracerFlare.addReporter(loggerInstance)); + } + } + + // Logging Functionality Tests + @Test + void testBasicLogging() { + assertTrue(logger.log("Test message")); + } + + @Test + void testLoggingWithArguments() { + assertTrue(logger.log("Test message with {} and {}", "arg1", 42)); + } + + @Test + void testLoggingWithException() { + Exception testException = new RuntimeException("Test exception"); + assertTrue(logger.log("Error occurred: {}", "details", testException)); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "Simple message", "Message with special chars: !@#$%^&*()"}) + void testLoggingVariousMessages(String message) { + assertTrue(logger.log(message)); + } + + @Test + void testLoggingNullMessage() { + assertTrue(logger.log(null)); } - // Next message should be rejected - assertFalse(logger.log(message)); - } - - @Test - void testCapacityAccuracy() throws Exception { - String testMessage = "Test message"; - logger.log(testMessage); - - int usedCapacity = logger.getUsedReportCapacity(); - assertTrue(usedCapacity > testMessage.length()); // Should include timestamp and formatting - } - - // TracerFlare Integration Tests - @Test - void testAddReportToFlareWithEmptyLogs() throws Exception { - ZipOutputStream mockZip = mock(ZipOutputStream.class); - - logger.addReportToFlare(mockZip); - - // Should not add any entries for empty logs - verify(mockZip, never()).putNextEntry(any()); - } - - @Test - void testAddReportToFlareWithLogs() throws Exception { - ZipOutputStream mockZip = mock(ZipOutputStream.class); - - logger.log("Test message 1"); - logger.log("Test message 2"); - - try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { - logger.addReportToFlare(mockZip); - mockedStatic.verify( - () -> TracerFlare.addText(eq(mockZip), eq("profiler_log.txt"), anyString())); - } - } - - @Test - void testAddReportToFlareIOException() throws Exception { - ZipOutputStream mockZip = mock(ZipOutputStream.class); - logger.log("Test message"); - - try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { - mockedStatic - .when(() -> TracerFlare.addText(any(), any(), any())) - .thenThrow(new IOException("Test IO exception")); + @Test + void testLoggingNullArguments() { + assertTrue(logger.log("Message with null: {}", (Object) null)); + } + + @ParameterizedTest + @MethodSource("provideLogMessageFormats") + void testSLF4JFormatting(String format, Object[] args, String expectedSubstring) { + assertTrue(logger.log(format, args)); + // Additional verification could be added if we can access the logged content + } + + private static Stream provideLogMessageFormats() { + return Stream.of( + Arguments.of("Simple message", new Object[] {}, "Simple message"), + Arguments.of("Message with {}", new Object[] {"placeholder"}, "placeholder"), + Arguments.of("Multiple {} and {}", new Object[] {"first", "second"}, "first"), + Arguments.of("Number: {}", new Object[] {123}, "123"), + Arguments.of("Boolean: {}", new Object[] {true}, "true")); + } + + // Capacity Management Tests + @Test + void testCapacityLimitEnforcement() throws Exception { + int capacity = logger.getMaxReportCapacity(); - assertThrows(IOException.class, () -> logger.addReportToFlare(mockZip)); - } - } + assertEquals(2 * 1024 * 1024, capacity); // 2MiB + } + + @Test + void testLoggingWithinCapacity() { + for (int i = 0; i < 100; i++) { + assertTrue(logger.log("Message {}", i)); + } + } - @Test - void testFlareContentFormat() throws Exception { - logger.log("Message 1"); - logger.log("Message 2 with {}", "arg"); + @Test + void testCapacityRejection() { + // Fill up capacity with large messages + StringBuilder largeMessage = new StringBuilder(); + for (int i = 0; i < 10000; i++) { + largeMessage.append("Large message content "); + } - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ZipOutputStream zip = new ZipOutputStream(baos); + int iterations = logger.getMaxReportCapacity() / largeMessage.length(); + String message = largeMessage.toString(); + + // We must accept all messages until capacity + for (int i = 0; i < iterations; i++) { + assertTrue(logger.log(message)); + } + + // Next message should be rejected + assertFalse(logger.log(message)); + } + + @Test + void testCapacityAccuracy() throws Exception { + String testMessage = "Test message"; + logger.log(testMessage); + + int usedCapacity = logger.getUsedReportCapacity(); + assertTrue(usedCapacity > testMessage.length()); // Should include timestamp and formatting + } + + // TracerFlare Integration Tests + @Test + void testAddReportToFlareWithEmptyLogs() throws Exception { + ZipOutputStream mockZip = mock(ZipOutputStream.class); + + logger.addReportToFlare(mockZip); + + // Should not add any entries for empty logs + verify(mockZip, never()).putNextEntry(any()); + } + + @Test + void testAddReportToFlareWithLogs() throws Exception { + ZipOutputStream mockZip = mock(ZipOutputStream.class); + + logger.log("Test message 1"); + logger.log("Test message 2"); + + try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { + logger.addReportToFlare(mockZip); + mockedStatic.verify(() -> TracerFlare.addText(eq(mockZip), eq("profiler_log.txt"), anyString())); + } + } - try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { - mockedStatic - .when(() -> TracerFlare.addText(any(), any(), any())) - .then( - invocation -> { + @Test + void testAddReportToFlareIOException() throws Exception { + ZipOutputStream mockZip = mock(ZipOutputStream.class); + logger.log("Test message"); + + try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { + mockedStatic + .when(() -> TracerFlare.addText(any(), any(), any())) + .thenThrow(new IOException("Test IO exception")); + + assertThrows(IOException.class, () -> logger.addReportToFlare(mockZip)); + } + } + + @Test + void testFlareContentFormat() throws Exception { + logger.log("Message 1"); + logger.log("Message 2 with {}", "arg"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(baos); + + try (MockedStatic mockedStatic = mockStatic(TracerFlare.class)) { + mockedStatic.when(() -> TracerFlare.addText(any(), any(), any())).then(invocation -> { String content = invocation.getArgument(2); assertTrue(content.contains("Message 1")); assertTrue(content.contains("Message 2 with arg")); assertTrue(content.contains("\n")); // Should have newlines between messages return null; - }); - - logger.addReportToFlare(zip); - } - } - - // Cleanup Tests - @Test - void testCleanup() throws Exception { - logger.log("Test message 1"); - logger.log("Test message 2"); - - logger.cleanup(); - - assertEquals(0, logger.linesSize()); - assertEquals(0, logger.getUsedReportCapacity()); - } - - @Test - void testLoggingAfterCleanup() { - logger.log("Before cleanup"); - logger.cleanup(); - - assertTrue(logger.log("After cleanup")); - } - - // Thread Safety Tests - @Test - void testConcurrentLogging() throws Exception { - int threadCount = 20; - int messagesPerThread = 50; - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(threadCount); - AtomicInteger successCount = new AtomicInteger(0); - - for (int i = 0; i < threadCount; i++) { - final int threadId = i; - executor.submit( - () -> { - try { - startLatch.await(); - for (int j = 0; j < messagesPerThread; j++) { - if (logger.log("Thread {} message {}", threadId, j)) { - successCount.incrementAndGet(); + }); + + logger.addReportToFlare(zip); + } + } + + // Cleanup Tests + @Test + void testCleanup() throws Exception { + logger.log("Test message 1"); + logger.log("Test message 2"); + + logger.cleanup(); + + assertEquals(0, logger.linesSize()); + assertEquals(0, logger.getUsedReportCapacity()); + } + + @Test + void testLoggingAfterCleanup() { + logger.log("Before cleanup"); + logger.cleanup(); + + assertTrue(logger.log("After cleanup")); + } + + // Thread Safety Tests + @Test + void testConcurrentLogging() throws Exception { + int threadCount = 20; + int messagesPerThread = 50; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + executor.submit(() -> { + try { + startLatch.await(); + for (int j = 0; j < messagesPerThread; j++) { + if (logger.log("Thread {} message {}", threadId, j)) { + successCount.incrementAndGet(); + } + } + } catch (Exception e) { + fail("Exception in thread: " + e.getMessage()); + } finally { + doneLatch.countDown(); } - } - } catch (Exception e) { - fail("Exception in thread: " + e.getMessage()); - } finally { - doneLatch.countDown(); + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(10, TimeUnit.SECONDS)); + + // At least some messages should succeed + assertTrue(successCount.get() > 0); + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + + @Test + void testConcurrentCleanupAndLogging() throws Exception { + int iterations = 100; + ExecutorService executor = Executors.newFixedThreadPool(3); + + for (int i = 0; i < iterations; i++) { + CountDownLatch latch = new CountDownLatch(2); + + // Logger thread + executor.submit(() -> { + logger.log("Concurrent message"); + latch.countDown(); + }); + + // Cleanup thread + executor.submit(() -> { + logger.cleanup(); + latch.countDown(); + }); + + assertTrue(latch.await(1, TimeUnit.SECONDS)); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + + // Performance and Edge Case Tests + @Test + void testLargeMessageHandling() { + StringBuilder largeMessage = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + largeMessage.append("This is a large message with lots of content. "); + } + + assertTrue(logger.log(largeMessage.toString())); + } + + @Test + void testManySmallMessages() { + for (int i = 0; i < 10000; i++) { + if (!logger.log("Message {}", i)) { + // Hit capacity limit, which is expected + break; } - }); - } - - startLatch.countDown(); - assertTrue(doneLatch.await(10, TimeUnit.SECONDS)); - - // At least some messages should succeed - assertTrue(successCount.get() > 0); - - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - } - - @Test - void testConcurrentCleanupAndLogging() throws Exception { - int iterations = 100; - ExecutorService executor = Executors.newFixedThreadPool(3); - - for (int i = 0; i < iterations; i++) { - CountDownLatch latch = new CountDownLatch(2); - - // Logger thread - executor.submit( - () -> { - logger.log("Concurrent message"); - latch.countDown(); - }); - - // Cleanup thread - executor.submit( - () -> { - logger.cleanup(); - latch.countDown(); - }); - - assertTrue(latch.await(1, TimeUnit.SECONDS)); - } - - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - } - - // Performance and Edge Case Tests - @Test - void testLargeMessageHandling() { - StringBuilder largeMessage = new StringBuilder(); - for (int i = 0; i < 1000; i++) { - largeMessage.append("This is a large message with lots of content. "); - } - - assertTrue(logger.log(largeMessage.toString())); - } - - @Test - void testManySmallMessages() { - for (int i = 0; i < 10000; i++) { - if (!logger.log("Message {}", i)) { - // Hit capacity limit, which is expected - break; - } - } - // Test should complete without throwing exceptions - } - - @Test - void testTimestampFormatting() { - // Log a message and verify it doesn't throw exceptions during timestamp formatting - assertDoesNotThrow(() -> logger.log("Timestamp test")); - } - - @Test - void testExceptionWithNullMessage() { - Exception testException = new RuntimeException(); - assertTrue(logger.log("Exception: {}", testException)); - } - - @Test - void testSpecialCharacterHandling() { - assertTrue(logger.log("Special chars: \n\t\r\\\"'")); - assertTrue(logger.log("Unicode: \u2603 \u2764 \u1F44D")); - } - - @Test - void testEmptyAndWhitespaceMessages() { - assertTrue(logger.log("")); - assertTrue(logger.log(" ")); - assertTrue(logger.log("\t\n\r")); - } - - @Test - void testMultipleExceptions() { - Exception cause = new IllegalStateException("Root cause"); - Exception wrapper = new RuntimeException("Wrapper", cause); - - assertTrue(logger.log("Nested exception: {}", wrapper)); - } - - @Test - void testLogAfterCapacityHit() { - // Fill to capacity - StringBuilder largeMessage = new StringBuilder(); - for (int i = 0; i < 50000; i++) { - largeMessage.append("Large message "); - } - - String message = largeMessage.toString(); - while (logger.log(message)) { - // Keep adding until capacity is hit - } - - // Verify subsequent messages are rejected - assertFalse(logger.log("Should be rejected: " + message)); - assertFalse(logger.log("Also rejected: " + message)); - - // After cleanup, should accept messages again - logger.cleanup(); - assertTrue(logger.log("After cleanup")); - } + } + // Test should complete without throwing exceptions + } + + @Test + void testTimestampFormatting() { + // Log a message and verify it doesn't throw exceptions during timestamp formatting + assertDoesNotThrow(() -> logger.log("Timestamp test")); + } + + @Test + void testExceptionWithNullMessage() { + Exception testException = new RuntimeException(); + assertTrue(logger.log("Exception: {}", testException)); + } + + @Test + void testSpecialCharacterHandling() { + assertTrue(logger.log("Special chars: \n\t\r\\\"'")); + assertTrue(logger.log("Unicode: \u2603 \u2764 \u1F44D")); + } + + @Test + void testEmptyAndWhitespaceMessages() { + assertTrue(logger.log("")); + assertTrue(logger.log(" ")); + assertTrue(logger.log("\t\n\r")); + } + + @Test + void testMultipleExceptions() { + Exception cause = new IllegalStateException("Root cause"); + Exception wrapper = new RuntimeException("Wrapper", cause); + + assertTrue(logger.log("Nested exception: {}", wrapper)); + } + + @Test + void testLogAfterCapacityHit() { + // Fill to capacity + StringBuilder largeMessage = new StringBuilder(); + for (int i = 0; i < 50000; i++) { + largeMessage.append("Large message "); + } + + String message = largeMessage.toString(); + while (logger.log(message)) { + // Keep adding until capacity is hit + } + + // Verify subsequent messages are rejected + assertFalse(logger.log("Should be rejected: " + message)); + assertFalse(logger.log("Also rejected: " + message)); + + // After cleanup, should accept messages again + logger.cleanup(); + assertTrue(logger.log("After cleanup")); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/profiling/ProfilingEnablementTest.java b/internal-api/src/test/java/datadog/trace/api/profiling/ProfilingEnablementTest.java index a1a4233766c..707ea61f4ad 100644 --- a/internal-api/src/test/java/datadog/trace/api/profiling/ProfilingEnablementTest.java +++ b/internal-api/src/test/java/datadog/trace/api/profiling/ProfilingEnablementTest.java @@ -12,66 +12,66 @@ class ProfilingEnablementTest { - @ParameterizedTest - @MethodSource("provideValues") - void of(String enabledValue, ProfilingEnablement expected) { - ProfilingEnablement.validate(enabledValue); // make jacoco happy - assertEquals(expected, ProfilingEnablement.of(enabledValue)); - } - - @ParameterizedTest - @MethodSource("provideValues1") - void from(String enabledValue, String ssi, ProfilingEnablement expected) { - Properties props = new Properties(); - if (enabledValue != null) { - props.setProperty(ProfilingConfig.PROFILING_ENABLED, enabledValue); + @ParameterizedTest + @MethodSource("provideValues") + void of(String enabledValue, ProfilingEnablement expected) { + ProfilingEnablement.validate(enabledValue); // make jacoco happy + assertEquals(expected, ProfilingEnablement.of(enabledValue)); } - if (ssi != null) { - props.setProperty("injection.enabled", ssi); + + @ParameterizedTest + @MethodSource("provideValues1") + void from(String enabledValue, String ssi, ProfilingEnablement expected) { + Properties props = new Properties(); + if (enabledValue != null) { + props.setProperty(ProfilingConfig.PROFILING_ENABLED, enabledValue); + } + if (ssi != null) { + props.setProperty("injection.enabled", ssi); + } + ConfigProvider config = ConfigProvider.withPropertiesOverride(props); + assertEquals(expected, ProfilingEnablement.from(config)); } - ConfigProvider config = ConfigProvider.withPropertiesOverride(props); - assertEquals(expected, ProfilingEnablement.from(config)); - } - private static Stream provideValues() { - return Stream.of( - Arguments.of("true", ProfilingEnablement.ENABLED), - Arguments.of("TRUE", ProfilingEnablement.ENABLED), - Arguments.of("tRuE", ProfilingEnablement.ENABLED), - Arguments.of("1", ProfilingEnablement.ENABLED), - Arguments.of("auto", ProfilingEnablement.AUTO), - Arguments.of("AUTO", ProfilingEnablement.AUTO), - Arguments.of("aUtO", ProfilingEnablement.AUTO), - Arguments.of("false", ProfilingEnablement.DISABLED), - Arguments.of("FALSE", ProfilingEnablement.DISABLED), - Arguments.of("fAlSe", ProfilingEnablement.DISABLED), - Arguments.of("0", ProfilingEnablement.DISABLED), - Arguments.of("anything", ProfilingEnablement.DISABLED), - Arguments.of(null, ProfilingEnablement.DISABLED)); - } + private static Stream provideValues() { + return Stream.of( + Arguments.of("true", ProfilingEnablement.ENABLED), + Arguments.of("TRUE", ProfilingEnablement.ENABLED), + Arguments.of("tRuE", ProfilingEnablement.ENABLED), + Arguments.of("1", ProfilingEnablement.ENABLED), + Arguments.of("auto", ProfilingEnablement.AUTO), + Arguments.of("AUTO", ProfilingEnablement.AUTO), + Arguments.of("aUtO", ProfilingEnablement.AUTO), + Arguments.of("false", ProfilingEnablement.DISABLED), + Arguments.of("FALSE", ProfilingEnablement.DISABLED), + Arguments.of("fAlSe", ProfilingEnablement.DISABLED), + Arguments.of("0", ProfilingEnablement.DISABLED), + Arguments.of("anything", ProfilingEnablement.DISABLED), + Arguments.of(null, ProfilingEnablement.DISABLED)); + } - private static Stream provideValues1() { - return Stream.of( - Arguments.of("true", null, ProfilingEnablement.ENABLED), - Arguments.of("true", "tracer", ProfilingEnablement.ENABLED), - Arguments.of("true", "tracer,profiler", ProfilingEnablement.ENABLED), - Arguments.of("1", null, ProfilingEnablement.ENABLED), - Arguments.of("1", "tracer", ProfilingEnablement.ENABLED), - Arguments.of("1", "tracer,profiler", ProfilingEnablement.ENABLED), - Arguments.of("auto", null, ProfilingEnablement.AUTO), - Arguments.of("auto", "tracer", ProfilingEnablement.AUTO), - Arguments.of("auto", "tracer,profiler", ProfilingEnablement.AUTO), - Arguments.of("false", null, ProfilingEnablement.DISABLED), - Arguments.of("false", "tracer", ProfilingEnablement.DISABLED), - Arguments.of("false", "tracer,profiler", ProfilingEnablement.INJECTED), - Arguments.of("0", null, ProfilingEnablement.DISABLED), - Arguments.of("0", "tracer", ProfilingEnablement.DISABLED), - Arguments.of("0", "tracer,profiler", ProfilingEnablement.INJECTED), - Arguments.of("anything", null, ProfilingEnablement.DISABLED), - Arguments.of("anything", "tracer", ProfilingEnablement.DISABLED), - Arguments.of("anything", "tracer,profiler", ProfilingEnablement.INJECTED), - Arguments.of(null, null, ProfilingEnablement.DISABLED), - Arguments.of(null, "tracer", ProfilingEnablement.DISABLED), - Arguments.of(null, "tracer,profiler", ProfilingEnablement.INJECTED)); - } + private static Stream provideValues1() { + return Stream.of( + Arguments.of("true", null, ProfilingEnablement.ENABLED), + Arguments.of("true", "tracer", ProfilingEnablement.ENABLED), + Arguments.of("true", "tracer,profiler", ProfilingEnablement.ENABLED), + Arguments.of("1", null, ProfilingEnablement.ENABLED), + Arguments.of("1", "tracer", ProfilingEnablement.ENABLED), + Arguments.of("1", "tracer,profiler", ProfilingEnablement.ENABLED), + Arguments.of("auto", null, ProfilingEnablement.AUTO), + Arguments.of("auto", "tracer", ProfilingEnablement.AUTO), + Arguments.of("auto", "tracer,profiler", ProfilingEnablement.AUTO), + Arguments.of("false", null, ProfilingEnablement.DISABLED), + Arguments.of("false", "tracer", ProfilingEnablement.DISABLED), + Arguments.of("false", "tracer,profiler", ProfilingEnablement.INJECTED), + Arguments.of("0", null, ProfilingEnablement.DISABLED), + Arguments.of("0", "tracer", ProfilingEnablement.DISABLED), + Arguments.of("0", "tracer,profiler", ProfilingEnablement.INJECTED), + Arguments.of("anything", null, ProfilingEnablement.DISABLED), + Arguments.of("anything", "tracer", ProfilingEnablement.DISABLED), + Arguments.of("anything", "tracer,profiler", ProfilingEnablement.INJECTED), + Arguments.of(null, null, ProfilingEnablement.DISABLED), + Arguments.of(null, "tracer", ProfilingEnablement.DISABLED), + Arguments.of(null, "tracer,profiler", ProfilingEnablement.INJECTED)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/profiling/RecordingInputStreamTest.java b/internal-api/src/test/java/datadog/trace/api/profiling/RecordingInputStreamTest.java index 812586aa894..c659b641831 100644 --- a/internal-api/src/test/java/datadog/trace/api/profiling/RecordingInputStreamTest.java +++ b/internal-api/src/test/java/datadog/trace/api/profiling/RecordingInputStreamTest.java @@ -8,18 +8,17 @@ class RecordingInputStreamTest { - @Test - void isEmpty() throws Exception { - RecordingInputStream instance = new RecordingInputStream(new ByteArrayInputStream(new byte[0])); - assertTrue(instance.isEmpty()); - } + @Test + void isEmpty() throws Exception { + RecordingInputStream instance = new RecordingInputStream(new ByteArrayInputStream(new byte[0])); + assertTrue(instance.isEmpty()); + } - @Test - void isNotEmpty() throws Exception { - RecordingInputStream instance = - new RecordingInputStream(new ByteArrayInputStream(new byte[] {1, 2, 3})); - assertFalse(instance.isEmpty()); - instance.read(new byte[3], 0, 3); // exhaust the stream - assertFalse(instance.isEmpty()); // still should not report as empty - } + @Test + void isNotEmpty() throws Exception { + RecordingInputStream instance = new RecordingInputStream(new ByteArrayInputStream(new byte[] {1, 2, 3})); + assertFalse(instance.isEmpty()); + instance.read(new byte[3], 0, 3); // exhaust the stream + assertFalse(instance.isEmpty()); // still should not report as empty + } } diff --git a/internal-api/src/test/java/datadog/trace/api/propagation/W3CTraceParentTest.java b/internal-api/src/test/java/datadog/trace/api/propagation/W3CTraceParentTest.java index 6a600cc9244..f1a285fc5e4 100644 --- a/internal-api/src/test/java/datadog/trace/api/propagation/W3CTraceParentTest.java +++ b/internal-api/src/test/java/datadog/trace/api/propagation/W3CTraceParentTest.java @@ -17,75 +17,73 @@ public class W3CTraceParentTest { - @ParameterizedTest(name = "{0}") - @MethodSource("buildProducesCorrectFormatArguments") - void buildProducesCorrectFormat( - String scenario, DDTraceId traceId, long spanId, boolean isSampled, String expected) { - assertEquals(expected, W3CTraceParent.from(traceId, spanId, isSampled)); - } + @ParameterizedTest(name = "{0}") + @MethodSource("buildProducesCorrectFormatArguments") + void buildProducesCorrectFormat( + String scenario, DDTraceId traceId, long spanId, boolean isSampled, String expected) { + assertEquals(expected, W3CTraceParent.from(traceId, spanId, isSampled)); + } - static Stream buildProducesCorrectFormatArguments() { - return Stream.of( - arguments( - "sampled", - DDTraceId.from(1), - 2L, - true, - "00-00000000000000000000000000000001-0000000000000002-01"), - arguments( - "not sampled", - DDTraceId.from(1), - 2L, - false, - "00-00000000000000000000000000000001-0000000000000002-00"), - arguments( - "W3C example", - DDTraceId.fromHex("0af7651916cd43dd8448eb211c80319c"), - 0x00f067aa0ba902b7L, - true, - "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"), - arguments( - "Long.MAX_VALUE ids", - DDTraceId.from(Long.MAX_VALUE), - Long.MAX_VALUE, - true, - "00-00000000000000007fffffffffffffff-7fffffffffffffff-01")); - } + static Stream buildProducesCorrectFormatArguments() { + return Stream.of( + arguments( + "sampled", + DDTraceId.from(1), + 2L, + true, + "00-00000000000000000000000000000001-0000000000000002-01"), + arguments( + "not sampled", + DDTraceId.from(1), + 2L, + false, + "00-00000000000000000000000000000001-0000000000000002-00"), + arguments( + "W3C example", + DDTraceId.fromHex("0af7651916cd43dd8448eb211c80319c"), + 0x00f067aa0ba902b7L, + true, + "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"), + arguments( + "Long.MAX_VALUE ids", + DDTraceId.from(Long.MAX_VALUE), + Long.MAX_VALUE, + true, + "00-00000000000000007fffffffffffffff-7fffffffffffffff-01")); + } - @Test - void buildMatchesW3CTraceparentFormat() { - // W3C format: version-traceId(32 hex)-spanId(16 hex)-flags(2 hex) - String result = W3CTraceParent.from(DDTraceId.from(123456789L), 987654321L, true); - assertTrue(result.matches("00-[0-9a-f]{32}-[0-9a-f]{16}-(00|01)")); - } + @Test + void buildMatchesW3CTraceparentFormat() { + // W3C format: version-traceId(32 hex)-spanId(16 hex)-flags(2 hex) + String result = W3CTraceParent.from(DDTraceId.from(123456789L), 987654321L, true); + assertTrue(result.matches("00-[0-9a-f]{32}-[0-9a-f]{16}-(00|01)")); + } - @Test - void buildFromSpanSampled() { - AgentSpan span = mock(AgentSpan.class); - AgentSpanContext context = mock(AgentSpanContext.class); - DDTraceId traceId = DDTraceId.fromHex("0af7651916cd43dd8448eb211c80319c"); - long spanId = 0x00f067aa0ba902b7L; + @Test + void buildFromSpanSampled() { + AgentSpan span = mock(AgentSpan.class); + AgentSpanContext context = mock(AgentSpanContext.class); + DDTraceId traceId = DDTraceId.fromHex("0af7651916cd43dd8448eb211c80319c"); + long spanId = 0x00f067aa0ba902b7L; - when(span.getTraceId()).thenReturn(traceId); - when(span.getSpanId()).thenReturn(spanId); - when(span.spanContext()).thenReturn(context); - when(context.getSamplingPriority()).thenReturn(1); + when(span.getTraceId()).thenReturn(traceId); + when(span.getSpanId()).thenReturn(spanId); + when(span.spanContext()).thenReturn(context); + when(context.getSamplingPriority()).thenReturn(1); - assertEquals( - "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01", W3CTraceParent.from(span)); - } + assertEquals("00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01", W3CTraceParent.from(span)); + } - @Test - void buildFromSpanNotSampled() { - AgentSpan span = mock(AgentSpan.class); - AgentSpanContext context = mock(AgentSpanContext.class); + @Test + void buildFromSpanNotSampled() { + AgentSpan span = mock(AgentSpan.class); + AgentSpanContext context = mock(AgentSpanContext.class); - when(span.getTraceId()).thenReturn(DDTraceId.from(1)); - when(span.getSpanId()).thenReturn(2L); - when(span.spanContext()).thenReturn(context); - when(context.getSamplingPriority()).thenReturn(0); + when(span.getTraceId()).thenReturn(DDTraceId.from(1)); + when(span.getSpanId()).thenReturn(2L); + when(span.spanContext()).thenReturn(context); + when(context.getSamplingPriority()).thenReturn(0); - assertEquals( - "00-00000000000000000000000000000001-0000000000000002-00", W3CTraceParent.from(span)); - } + assertEquals("00-00000000000000000000000000000001-0000000000000002-00", W3CTraceParent.from(span)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java b/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java index ba561e2e40c..a37469ba9d3 100644 --- a/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java @@ -11,10 +11,10 @@ class RumInjectorConfigTest { - @ParameterizedTest - @CsvSource( - value = { - // spotless:off + @ParameterizedTest + @CsvSource( + value = { + // spotless:off // Minimal configuration ID "appId | token | null | null | null | 6 | null | null | null | null | null | null | null | remote-config-id", // Using site @@ -55,111 +55,110 @@ class RumInjectorConfigTest { "appId | token | null | null | null | 6 | null | null | null | null | null | null | 25.5 | remote-config-id", "appId | token | null | null | null | 6 | null | null | null | null | null | null | 100 | remote-config-id", // spotless:on - }, - delimiterString = "|", - nullValues = "null") - void testValidConfig( - String applicationId, - String clientToken, - String site, - String service, - String env, - int majorVersion, - String version, - Boolean trackUserInteractions, - Boolean trackResources, - Boolean trackLongTask, - PrivacyLevel defaultPrivacyLevel, - Float sessionSampleRate, - Float sessionReplaySampleRate, - String remoteConfigurationId) { - RumInjectorConfig injectorConfig = - new RumInjectorConfig( - applicationId, - clientToken, - site, - service, - env, - majorVersion, - version, - trackUserInteractions, - trackResources, - trackLongTask, - defaultPrivacyLevel, - sessionSampleRate, - sessionReplaySampleRate, - remoteConfigurationId); + }, + delimiterString = "|", + nullValues = "null") + void testValidConfig( + String applicationId, + String clientToken, + String site, + String service, + String env, + int majorVersion, + String version, + Boolean trackUserInteractions, + Boolean trackResources, + Boolean trackLongTask, + PrivacyLevel defaultPrivacyLevel, + Float sessionSampleRate, + Float sessionReplaySampleRate, + String remoteConfigurationId) { + RumInjectorConfig injectorConfig = new RumInjectorConfig( + applicationId, + clientToken, + site, + service, + env, + majorVersion, + version, + trackUserInteractions, + trackResources, + trackLongTask, + defaultPrivacyLevel, + sessionSampleRate, + sessionReplaySampleRate, + remoteConfigurationId); - String jsonPayload = injectorConfig.jsonPayload(); - assertTrue(jsonPayload.contains(applicationId)); - assertTrue(jsonPayload.contains("site")); - assertTrue(jsonPayload.contains(clientToken)); - if (site == null) { - assertTrue(jsonPayload.contains(DEFAULT_RUM_SITE)); - } else { - assertTrue(jsonPayload.contains(site)); - } - if (service == null) { - assertFalse(jsonPayload.contains("service")); - } else { - assertTrue(jsonPayload.contains("service")); - assertTrue(jsonPayload.contains(service)); - } - if (env == null) { - assertFalse(jsonPayload.contains("env")); - } else { - assertTrue(jsonPayload.contains("env")); - assertTrue(jsonPayload.contains(env)); - } - if (version == null) { - assertFalse(jsonPayload.contains("version")); - } else { - assertTrue(jsonPayload.contains("version")); - assertTrue(jsonPayload.contains(version)); - } - if (trackUserInteractions == null) { - assertFalse(jsonPayload.contains("trackUserInteractions")); - } else { - assertTrue(jsonPayload.contains("trackUserInteractions")); - } - if (trackResources == null) { - assertFalse(jsonPayload.contains("trackResources")); - } else { - assertTrue(jsonPayload.contains("trackResources")); - } - if (trackLongTask == null) { - assertFalse(jsonPayload.contains("trackLongTask")); - } else { - assertTrue(jsonPayload.contains("trackLongTask")); + String jsonPayload = injectorConfig.jsonPayload(); + assertTrue(jsonPayload.contains(applicationId)); + assertTrue(jsonPayload.contains("site")); + assertTrue(jsonPayload.contains(clientToken)); + if (site == null) { + assertTrue(jsonPayload.contains(DEFAULT_RUM_SITE)); + } else { + assertTrue(jsonPayload.contains(site)); + } + if (service == null) { + assertFalse(jsonPayload.contains("service")); + } else { + assertTrue(jsonPayload.contains("service")); + assertTrue(jsonPayload.contains(service)); + } + if (env == null) { + assertFalse(jsonPayload.contains("env")); + } else { + assertTrue(jsonPayload.contains("env")); + assertTrue(jsonPayload.contains(env)); + } + if (version == null) { + assertFalse(jsonPayload.contains("version")); + } else { + assertTrue(jsonPayload.contains("version")); + assertTrue(jsonPayload.contains(version)); + } + if (trackUserInteractions == null) { + assertFalse(jsonPayload.contains("trackUserInteractions")); + } else { + assertTrue(jsonPayload.contains("trackUserInteractions")); + } + if (trackResources == null) { + assertFalse(jsonPayload.contains("trackResources")); + } else { + assertTrue(jsonPayload.contains("trackResources")); + } + if (trackLongTask == null) { + assertFalse(jsonPayload.contains("trackLongTask")); + } else { + assertTrue(jsonPayload.contains("trackLongTask")); + } + if (defaultPrivacyLevel == null) { + assertFalse(jsonPayload.contains("defaultPrivacyLevel")); + } else { + assertTrue(jsonPayload.contains("defaultPrivacyLevel")); + assertTrue(jsonPayload.contains(defaultPrivacyLevel.toJson())); + } + if (sessionSampleRate == null) { + assertFalse(jsonPayload.contains("sessionSampleRate")); + } else { + assertTrue(jsonPayload.contains("sessionSampleRate")); + } + if (sessionReplaySampleRate == null) { + assertFalse(jsonPayload.contains("sessionReplaySampleRate")); + } else { + assertTrue(jsonPayload.contains("sessionReplaySampleRate")); + } + if (remoteConfigurationId == null) { + assertFalse(jsonPayload.contains("remoteConfigurationId")); + } else { + assertTrue(jsonPayload.contains("remoteConfigurationId")); + assertTrue(jsonPayload.contains(remoteConfigurationId)); + } } - if (defaultPrivacyLevel == null) { - assertFalse(jsonPayload.contains("defaultPrivacyLevel")); - } else { - assertTrue(jsonPayload.contains("defaultPrivacyLevel")); - assertTrue(jsonPayload.contains(defaultPrivacyLevel.toJson())); - } - if (sessionSampleRate == null) { - assertFalse(jsonPayload.contains("sessionSampleRate")); - } else { - assertTrue(jsonPayload.contains("sessionSampleRate")); - } - if (sessionReplaySampleRate == null) { - assertFalse(jsonPayload.contains("sessionReplaySampleRate")); - } else { - assertTrue(jsonPayload.contains("sessionReplaySampleRate")); - } - if (remoteConfigurationId == null) { - assertFalse(jsonPayload.contains("remoteConfigurationId")); - } else { - assertTrue(jsonPayload.contains("remoteConfigurationId")); - assertTrue(jsonPayload.contains(remoteConfigurationId)); - } - } - @ParameterizedTest - @CsvSource( - value = { - // spotless:off + @ParameterizedTest + @CsvSource( + value = { + // spotless:off // Invalid application ID "null | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | remote-config-id", "'' | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | remote-config-id", @@ -184,41 +183,40 @@ void testValidConfig( // Invalid rates and remote configuration id "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | null", // spotless:on - }, - delimiterString = "|", - nullValues = "null") - void testInvalidConfig( - String applicationId, - String clientToken, - String site, - String service, - String env, - int majorVersion, - String version, - boolean trackUserInteractions, - boolean trackResources, - boolean trackLongTask, - PrivacyLevel defaultPrivacyLevel, - Float sessionSampleRate, - Float sessionReplaySampleRate, - String remoteConfigurationId) { - assertThrows( - IllegalArgumentException.class, - () -> - new RumInjectorConfig( - applicationId, - clientToken, - site, - service, - env, - majorVersion, - version, - trackUserInteractions, - trackResources, - trackLongTask, - defaultPrivacyLevel, - sessionSampleRate, - sessionReplaySampleRate, - remoteConfigurationId)); - } + }, + delimiterString = "|", + nullValues = "null") + void testInvalidConfig( + String applicationId, + String clientToken, + String site, + String service, + String env, + int majorVersion, + String version, + boolean trackUserInteractions, + boolean trackResources, + boolean trackLongTask, + PrivacyLevel defaultPrivacyLevel, + Float sessionSampleRate, + Float sessionReplaySampleRate, + String remoteConfigurationId) { + assertThrows( + IllegalArgumentException.class, + () -> new RumInjectorConfig( + applicationId, + clientToken, + site, + service, + env, + majorVersion, + version, + trackUserInteractions, + trackResources, + trackLongTask, + defaultPrivacyLevel, + sessionSampleRate, + sessionReplaySampleRate, + remoteConfigurationId)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/sampling/AdaptiveSamplerTest.java b/internal-api/src/test/java/datadog/trace/api/sampling/AdaptiveSamplerTest.java index 8b732467622..9d3548fa837 100644 --- a/internal-api/src/test/java/datadog/trace/api/sampling/AdaptiveSamplerTest.java +++ b/internal-api/src/test/java/datadog/trace/api/sampling/AdaptiveSamplerTest.java @@ -43,570 +43,540 @@ */ @ExtendWith(MockitoExtension.class) class AdaptiveSamplerTest { - private static final Logger log = LoggerFactory.getLogger(AdaptiveSamplerTest.class); - private static final Duration WINDOW_DURATION = Duration.ofSeconds(1); - - /** Generates windows with numbers of events according to Poisson distribution */ - private static final class PoissonWindowEventsSupplier implements IntSupplier { - private final PoissonDistribution distribution; + private static final Logger log = LoggerFactory.getLogger(AdaptiveSamplerTest.class); + private static final Duration WINDOW_DURATION = Duration.ofSeconds(1); + + /** Generates windows with numbers of events according to Poisson distribution */ + private static final class PoissonWindowEventsSupplier implements IntSupplier { + private final PoissonDistribution distribution; + + /** + * @param eventsPerWindowMean the average number of events per window + */ + PoissonWindowEventsSupplier(final int eventsPerWindowMean) { + distribution = new PoissonDistribution(eventsPerWindowMean); + distribution.reseedRandomGenerator(12345671); + } + + @Override + public int getAsInt() { + return distribution.sample(); + } + + @Override + public String toString() { + return "Poisson: (" + + "mean=" + + distribution.getMean() + + ", variance=" + + distribution.getNumericalVariance() + + ")"; + } + } /** - * @param eventsPerWindowMean the average number of events per window + * Generates bursty windows - some of the windows have extremely low number of events while the + * others have very hight number of events. */ - PoissonWindowEventsSupplier(final int eventsPerWindowMean) { - distribution = new PoissonDistribution(eventsPerWindowMean); - distribution.reseedRandomGenerator(12345671); + private static final class BurstingWindowsEventsSupplier implements IntSupplier { + private final Random rnd = new Random(176431); + + private final double burstProbability; + private final int minEvents; + private final int maxEvents; + + /** + * @param burstProbability the probability of burst window happening + * @param nonBurstEvents number of events in non-burst window + * @param burstEvents number of events in burst window + */ + BurstingWindowsEventsSupplier(final double burstProbability, final int nonBurstEvents, final int burstEvents) { + this.burstProbability = burstProbability; + this.minEvents = nonBurstEvents; + this.maxEvents = burstEvents; + } + + @Override + public int getAsInt() { + if (rnd.nextDouble() <= burstProbability) { + return maxEvents; + } else { + return minEvents; + } + } + + @Override + public String toString() { + return "Burst: (" + + "probability=" + + burstProbability + + ", minEvents=" + + minEvents + + ", maxEvents=" + + maxEvents + + ')'; + } } - @Override - public int getAsInt() { - return distribution.sample(); + /** Generates windows with constant number of events. */ + private static final class ConstantWindowsEventsSupplier implements IntSupplier { + private final int events; + + /** + * @param events number of events per window + */ + ConstantWindowsEventsSupplier(final int events) { + this.events = events; + } + + @Override + public int getAsInt() { + return events; + } + + @Override + public String toString() { + return "Constant: (" + "events=" + events + ')'; + } } - @Override - public String toString() { - return "Poisson: (" - + "mean=" - + distribution.getMean() - + ", variance=" - + distribution.getNumericalVariance() - + ")"; + /** Generates a pre-configured repeating sequence of window events */ + private static final class RepeatingWindowsEventsSupplier implements IntSupplier { + private final int[] eventsCounts; + private int pointer = 0; + + /** + * @param windowEvents an array of number of events per each window in the sequence + */ + RepeatingWindowsEventsSupplier(final int... windowEvents) { + this.eventsCounts = Arrays.copyOf(windowEvents, windowEvents.length); + } + + @Override + public int getAsInt() { + try { + return eventsCounts[pointer]; + } finally { + pointer = (pointer + 1) % eventsCounts.length; + } + } + + @Override + public String toString() { + return "Repeating: (" + "definition=" + Arrays.toString(eventsCounts) + ')'; + } } - } - /** - * Generates bursty windows - some of the windows have extremely low number of events while the - * others have very hight number of events. - */ - private static final class BurstingWindowsEventsSupplier implements IntSupplier { - private final Random rnd = new Random(176431); + private static class WindowSamplingResult { + final int events; + final int samples; + final double sampleIndexSkew; - private final double burstProbability; - private final int minEvents; - private final int maxEvents; + WindowSamplingResult(int events, int samples, double sampleIndexSkew) { + this.events = events; + this.samples = samples; + this.sampleIndexSkew = sampleIndexSkew; + } + } - /** - * @param burstProbability the probability of burst window happening - * @param nonBurstEvents number of events in non-burst window - * @param burstEvents number of events in burst window - */ - BurstingWindowsEventsSupplier( - final double burstProbability, final int nonBurstEvents, final int burstEvents) { - this.burstProbability = burstProbability; - this.minEvents = nonBurstEvents; - this.maxEvents = burstEvents; + private static final StandardDeviation STANDARD_DEVIATION = new StandardDeviation(); + private static final int WINDOWS = 120; + private static final int SAMPLES_PER_WINDOW = 100; + private static final int AVERAGE_LOOKBACK = 30; + private static final int BUDGET_LOOKBACK = 16; + + @Mock + AgentTaskScheduler taskScheduler; + + @Captor + ArgumentCaptor> rollWindowTaskCaptor; + + @Captor + ArgumentCaptor rollWindowTargetCaptor; + + @BeforeEach + public void setup() { + doNothing() + .when(taskScheduler) + .weakScheduleAtFixedRate( + rollWindowTaskCaptor.capture(), + rollWindowTargetCaptor.capture(), + eq(WINDOW_DURATION.toNanos()), + eq(WINDOW_DURATION.toNanos()), + same(TimeUnit.NANOSECONDS)); } - @Override - public int getAsInt() { - if (rnd.nextDouble() <= burstProbability) { - return maxEvents; - } else { - return minEvents; - } + @Test + public void testBurstLowProbability() throws Exception { + testSampler(new BurstingWindowsEventsSupplier(0.1d, 5, 5000), 40); } - @Override - public String toString() { - return "Burst: (" - + "probability=" - + burstProbability - + ", minEvents=" - + minEvents - + ", maxEvents=" - + maxEvents - + ')'; + @Test + public void testBurstHighProbability() throws Exception { + testSampler(new BurstingWindowsEventsSupplier(0.8d, 5, 5000), 20); } - } - /** Generates windows with constant number of events. */ - private static final class ConstantWindowsEventsSupplier implements IntSupplier { - private final int events; + @Test + public void testPoissonLowFrequency() throws Exception { + testSampler(new PoissonWindowEventsSupplier(153), 15); + } - /** - * @param events number of events per window - */ - ConstantWindowsEventsSupplier(final int events) { - this.events = events; + @Test + public void testPoissonMidFrequency() throws Exception { + testSampler(new PoissonWindowEventsSupplier(283), 15); } - @Override - public int getAsInt() { - return events; + @Test + public void testPoissonHighFrequency() throws Exception { + testSampler(new PoissonWindowEventsSupplier(1013), 15); } - @Override - public String toString() { - return "Constant: (" + "events=" + events + ')'; + @Test + public void testConstantVeryLowLoad() throws Exception { + testSampler(new ConstantWindowsEventsSupplier(1), 10); } - } - /** Generates a pre-configured repeating sequence of window events */ - private static final class RepeatingWindowsEventsSupplier implements IntSupplier { - private final int[] eventsCounts; - private int pointer = 0; + @Test + public void testConstantLowLoad() throws Exception { + testSampler(new ConstantWindowsEventsSupplier(153), 15); + } - /** - * @param windowEvents an array of number of events per each window in the sequence - */ - RepeatingWindowsEventsSupplier(final int... windowEvents) { - this.eventsCounts = Arrays.copyOf(windowEvents, windowEvents.length); + @Test + public void testConstantMediumLoad() throws Exception { + testSampler(new ConstantWindowsEventsSupplier(713), 15); } - @Override - public int getAsInt() { - try { - return eventsCounts[pointer]; - } finally { - pointer = (pointer + 1) % eventsCounts.length; - } + @Test + public void testConstantHighLoad() throws Exception { + testSampler(new ConstantWindowsEventsSupplier(5211), 15); } - @Override - public String toString() { - return "Repeating: (" + "definition=" + Arrays.toString(eventsCounts) + ')'; + @Test + public void testRepeatingSemiRandom() throws Exception { + testSampler(new RepeatingWindowsEventsSupplier(180, 200, 0, 0, 0, 1500, 1000, 430, 200, 115, 115, 900), 15); } - } - private static class WindowSamplingResult { - final int events; - final int samples; - final double sampleIndexSkew; + @Test + public void testRepeatingRegularStartWithBurst() throws Exception { + testSampler(new RepeatingWindowsEventsSupplier(1000, 0, 1000, 0, 1000, 0), 15); + } - WindowSamplingResult(int events, int samples, double sampleIndexSkew) { - this.events = events; - this.samples = samples; - this.sampleIndexSkew = sampleIndexSkew; + @Test + public void testRepeatingRegularStartWithLow() throws Exception { + testSampler(new RepeatingWindowsEventsSupplier(0, 1000, 0, 1000, 0, 1000), 15); } - } - - private static final StandardDeviation STANDARD_DEVIATION = new StandardDeviation(); - private static final int WINDOWS = 120; - private static final int SAMPLES_PER_WINDOW = 100; - private static final int AVERAGE_LOOKBACK = 30; - private static final int BUDGET_LOOKBACK = 16; - - @Mock AgentTaskScheduler taskScheduler; - @Captor ArgumentCaptor> rollWindowTaskCaptor; - @Captor ArgumentCaptor rollWindowTargetCaptor; - - @BeforeEach - public void setup() { - doNothing() - .when(taskScheduler) - .weakScheduleAtFixedRate( - rollWindowTaskCaptor.capture(), - rollWindowTargetCaptor.capture(), - eq(WINDOW_DURATION.toNanos()), - eq(WINDOW_DURATION.toNanos()), - same(TimeUnit.NANOSECONDS)); - } - - @Test - public void testBurstLowProbability() throws Exception { - testSampler(new BurstingWindowsEventsSupplier(0.1d, 5, 5000), 40); - } - - @Test - public void testBurstHighProbability() throws Exception { - testSampler(new BurstingWindowsEventsSupplier(0.8d, 5, 5000), 20); - } - - @Test - public void testPoissonLowFrequency() throws Exception { - testSampler(new PoissonWindowEventsSupplier(153), 15); - } - - @Test - public void testPoissonMidFrequency() throws Exception { - testSampler(new PoissonWindowEventsSupplier(283), 15); - } - - @Test - public void testPoissonHighFrequency() throws Exception { - testSampler(new PoissonWindowEventsSupplier(1013), 15); - } - - @Test - public void testConstantVeryLowLoad() throws Exception { - testSampler(new ConstantWindowsEventsSupplier(1), 10); - } - - @Test - public void testConstantLowLoad() throws Exception { - testSampler(new ConstantWindowsEventsSupplier(153), 15); - } - - @Test - public void testConstantMediumLoad() throws Exception { - testSampler(new ConstantWindowsEventsSupplier(713), 15); - } - - @Test - public void testConstantHighLoad() throws Exception { - testSampler(new ConstantWindowsEventsSupplier(5211), 15); - } - - @Test - public void testRepeatingSemiRandom() throws Exception { - testSampler( - new RepeatingWindowsEventsSupplier(180, 200, 0, 0, 0, 1500, 1000, 430, 200, 115, 115, 900), - 15); - } - - @Test - public void testRepeatingRegularStartWithBurst() throws Exception { - testSampler(new RepeatingWindowsEventsSupplier(1000, 0, 1000, 0, 1000, 0), 15); - } - - @Test - public void testRepeatingRegularStartWithLow() throws Exception { - testSampler(new RepeatingWindowsEventsSupplier(0, 1000, 0, 1000, 0, 1000), 15); - } - - @Test - public void testKeep() { - final AdaptiveSampler sampler = - new AdaptiveSampler(WINDOW_DURATION, 1, 1, 1, null, taskScheduler, true); - long tests = sampler.testCount(); - long samples = sampler.sampleCount(); - assertTrue(sampler.keep()); - assertEquals(tests + 1, sampler.testCount()); - assertEquals(samples + 1, sampler.sampleCount()); - } - - @Test - public void testDrop() { - final AdaptiveSampler sampler = - new AdaptiveSampler(WINDOW_DURATION, 1, 1, 1, null, taskScheduler, true); - long tests = sampler.testCount(); - long samples = sampler.sampleCount(); - assertFalse(sampler.drop()); - assertEquals(tests + 1, sampler.testCount()); - assertEquals(samples, sampler.sampleCount()); - } - - @Test - void testConfigListener() throws Exception { - AtomicInteger counter = new AtomicInteger(0); - final AdaptiveSampler sampler = - new AdaptiveSampler( - WINDOW_DURATION, - 2, - 1, - 1, - (totalCount, sampledCount, budget, totalAverage, probability) -> { - switch (counter.getAndIncrement()) { - case 0: - { - // initial config at the sampler instantiation - assertEquals(0, totalCount); - assertEquals(0, sampledCount); - assertEquals(4, budget); - assertEquals(0.0d, totalAverage); - assertEquals(1.0d, probability); - break; - } - case 1: - { - // after first roll window - assertEquals(2, totalCount); - assertEquals(1, sampledCount); - assertEquals(1, budget); - assertEquals(2.0d, totalAverage); - assertEquals(0.5d, probability); - break; - } - case 2: - { - // after second roll window - assertEquals(3, totalCount); - assertEquals(2, sampledCount); - assertEquals(0, budget); - assertEquals(3.0d, totalAverage); - assertEquals(0.0d, probability); - break; - } - case 3: - { - // after third roll window - assertEquals(3, totalCount); - assertEquals(0, sampledCount); - assertEquals(2, budget); - assertEquals(3.0d, totalAverage); - assertEquals(0.6666d, probability, 0.00007d); - System.err.println( - "==> " - + totalCount - + ", " - + sampledCount - + ", " - + budget - + ", " - + totalAverage - + ", " - + probability); - break; - } - } - }, - taskScheduler, - true); - sampler.keep(); - sampler.drop(); - rollWindow(); - sampler.keep(); - sampler.keep(); - sampler.drop(); - rollWindow(); - sampler.drop(); - sampler.drop(); - sampler.drop(); - rollWindow(); - } - - private void testSampler(final IntSupplier windowEventsSupplier, final int maxErrorPercent) - throws Exception { - int iterations = - Integer.parseInt( - System.getProperty("com.datadog.profiling.exceptions.test-iterations", "1")); - for (int i = 0; i < iterations; i++) { - testSamplerInline(windowEventsSupplier, maxErrorPercent); - for (int numOfThreads = 1; numOfThreads <= 64; numOfThreads *= 2) { - testSamplerConcurrently(numOfThreads, windowEventsSupplier, maxErrorPercent); - } + + @Test + public void testKeep() { + final AdaptiveSampler sampler = new AdaptiveSampler(WINDOW_DURATION, 1, 1, 1, null, taskScheduler, true); + long tests = sampler.testCount(); + long samples = sampler.sampleCount(); + assertTrue(sampler.keep()); + assertEquals(tests + 1, sampler.testCount()); + assertEquals(samples + 1, sampler.sampleCount()); } - } - - private void testSamplerInline( - final IntSupplier windowEventsSupplier, final int maxErrorPercent) { - log.info( - "> mode: {}, windows: {}, SAMPLES_PER_WINDOW: {}, LOOKBACK: {}, max error: {}%", - windowEventsSupplier, WINDOWS, SAMPLES_PER_WINDOW, AVERAGE_LOOKBACK, maxErrorPercent); - final AdaptiveSampler sampler = - new AdaptiveSampler( - WINDOW_DURATION, - SAMPLES_PER_WINDOW, - AVERAGE_LOOKBACK, - BUDGET_LOOKBACK, - null, - taskScheduler, - true); - - // simulate event generation and sampling for the given number of sampling windows - final long expectedSamples = WINDOWS * SAMPLES_PER_WINDOW; - - long allSamples = 0L; - long allEvents = 0L; - - final double[] samplesPerWindow = new double[WINDOWS]; - final double[] sampleIndexSkewPerWindow = new double[WINDOWS]; - final Mean mean = new Mean(); - for (int w = 0; w < WINDOWS; w++) { - final long samplesBase = 0L; - WindowSamplingResult result = - generateWindowEventsAndSample(windowEventsSupplier, sampler, mean); - samplesPerWindow[w] = - (1 - abs((result.samples - samplesBase - expectedSamples) / (double) expectedSamples)); - sampleIndexSkewPerWindow[w] = result.sampleIndexSkew; - allSamples += result.samples; - allEvents += result.events; - - rollWindow(); + + @Test + public void testDrop() { + final AdaptiveSampler sampler = new AdaptiveSampler(WINDOW_DURATION, 1, 1, 1, null, taskScheduler, true); + long tests = sampler.testCount(); + long samples = sampler.sampleCount(); + assertFalse(sampler.drop()); + assertEquals(tests + 1, sampler.testCount()); + assertEquals(samples, sampler.sampleCount()); } - /* - * Turn all events into samples if their number is <= than the expected number of samples. - */ - final double targetSamples = Math.min(allEvents, expectedSamples); + @Test + void testConfigListener() throws Exception { + AtomicInteger counter = new AtomicInteger(0); + final AdaptiveSampler sampler = new AdaptiveSampler( + WINDOW_DURATION, + 2, + 1, + 1, + (totalCount, sampledCount, budget, totalAverage, probability) -> { + switch (counter.getAndIncrement()) { + case 0: { + // initial config at the sampler instantiation + assertEquals(0, totalCount); + assertEquals(0, sampledCount); + assertEquals(4, budget); + assertEquals(0.0d, totalAverage); + assertEquals(1.0d, probability); + break; + } + case 1: { + // after first roll window + assertEquals(2, totalCount); + assertEquals(1, sampledCount); + assertEquals(1, budget); + assertEquals(2.0d, totalAverage); + assertEquals(0.5d, probability); + break; + } + case 2: { + // after second roll window + assertEquals(3, totalCount); + assertEquals(2, sampledCount); + assertEquals(0, budget); + assertEquals(3.0d, totalAverage); + assertEquals(0.0d, probability); + break; + } + case 3: { + // after third roll window + assertEquals(3, totalCount); + assertEquals(0, sampledCount); + assertEquals(2, budget); + assertEquals(3.0d, totalAverage); + assertEquals(0.6666d, probability, 0.00007d); + System.err.println("==> " + + totalCount + + ", " + + sampledCount + + ", " + + budget + + ", " + + totalAverage + + ", " + + probability); + break; + } + } + }, + taskScheduler, + true); + sampler.keep(); + sampler.drop(); + rollWindow(); + sampler.keep(); + sampler.keep(); + sampler.drop(); + rollWindow(); + sampler.drop(); + sampler.drop(); + sampler.drop(); + rollWindow(); + } - /* - * Calculate the percentual error based on the expected and the observed number of samples. - */ - final double percentualError = round(((targetSamples - allSamples) / targetSamples) * 100); - - reportSampleStatistics(samplesPerWindow, targetSamples, percentualError); - reportSampleIndexSkew(sampleIndexSkewPerWindow); - - assertTrue( - abs(percentualError) <= maxErrorPercent, - "abs((" - + targetSamples - + " - " - + allSamples - + ") / " - + targetSamples - + ")% > " - + maxErrorPercent - + "%"); - } - - private void reportSampleStatistics( - double[] samplesPerWindow, double targetSamples, double percentualError) { - final double samplesPerWindowMean = new Mean().evaluate(samplesPerWindow); - final double samplesPerWindowStdev = - STANDARD_DEVIATION.evaluate(samplesPerWindow, samplesPerWindowMean); - - log.info( - "\t per window samples = (avg: {}, stdev: {}, estimated total: {})", - samplesPerWindowMean, - samplesPerWindowStdev, - targetSamples); - - log.info("\t percentual error = {}%", percentualError); - } - - private void reportSampleIndexSkew(double[] sampleIndexSkewPerWindow) { - Pair skewIndicators = calculateSkewIndicators(sampleIndexSkewPerWindow); - log.info( - "\t avg window skew interval = <-{}%, {}%>", - round(skewIndicators.getFirst() * 100), round(skewIndicators.getSecond() * 100)); - } - - /** - * Simulate the number of events per window. Perform sampling and capture the number of observed - * events and samples. - * - * @param windowEventsSupplier events generator implementation - * @param sampler sampler instance - * @param mean a {@code Mean} instance for calculations - * @return a {@linkplain WindowSamplingResult} instance capturing the number of observed events, - * samples and the sample index skew - */ - private WindowSamplingResult generateWindowEventsAndSample( - IntSupplier windowEventsSupplier, AdaptiveSampler sampler, Mean mean) { - int samples = 0; - int events = windowEventsSupplier.getAsInt(); - mean.clear(); - for (int i = 0; i < events; i++) { - if (sampler.sample()) { - mean.increment(i); - samples++; - } + private void testSampler(final IntSupplier windowEventsSupplier, final int maxErrorPercent) throws Exception { + int iterations = Integer.parseInt(System.getProperty("com.datadog.profiling.exceptions.test-iterations", "1")); + for (int i = 0; i < iterations; i++) { + testSamplerInline(windowEventsSupplier, maxErrorPercent); + for (int numOfThreads = 1; numOfThreads <= 64; numOfThreads *= 2) { + testSamplerConcurrently(numOfThreads, windowEventsSupplier, maxErrorPercent); + } + } } - double sampleIndexMean = mean.getResult(); - double sampleIndexSkew = events != 0 ? sampleIndexMean / events : 0; - return new WindowSamplingResult(events, samples, sampleIndexSkew); - } - - /** - * Calculate the sample index skew boundaries. A 'sample index skew' is defined as the distance of - * the average sample index in each window from the mean event index in the same window. Given the - * range of the event indices 1..N, the event index mean M calculated as (N - 1)/2 and the sample - * index mean S the skew K is calculated as 'K = M - S'. This gives the skew range of <-0.5, - * 0.5>. - * - *

If the samples are spread out completely regularly the skew would be 0. If the beginning of - * the window is favored the skew would be negative and if the tail of the window is favored the - * skew would be positive. - * - * @param sampleIndexSkewPerWindow the index skew per window - * @return a min-max boundaries for the sample index skew - */ - private Pair calculateSkewIndicators(double[] sampleIndexSkewPerWindow) { - double skewPositiveAvg = 0d; - double skewNegativeAvg = 0d; - int negativeCount = 0; - for (final double skew : sampleIndexSkewPerWindow) { - if (skew >= 0.5d) { - skewPositiveAvg += skew - 0.5d; - } else { - negativeCount++; - skewNegativeAvg += 0.5d - skew; - } + + private void testSamplerInline(final IntSupplier windowEventsSupplier, final int maxErrorPercent) { + log.info( + "> mode: {}, windows: {}, SAMPLES_PER_WINDOW: {}, LOOKBACK: {}, max error: {}%", + windowEventsSupplier, WINDOWS, SAMPLES_PER_WINDOW, AVERAGE_LOOKBACK, maxErrorPercent); + final AdaptiveSampler sampler = new AdaptiveSampler( + WINDOW_DURATION, SAMPLES_PER_WINDOW, AVERAGE_LOOKBACK, BUDGET_LOOKBACK, null, taskScheduler, true); + + // simulate event generation and sampling for the given number of sampling windows + final long expectedSamples = WINDOWS * SAMPLES_PER_WINDOW; + + long allSamples = 0L; + long allEvents = 0L; + + final double[] samplesPerWindow = new double[WINDOWS]; + final double[] sampleIndexSkewPerWindow = new double[WINDOWS]; + final Mean mean = new Mean(); + for (int w = 0; w < WINDOWS; w++) { + final long samplesBase = 0L; + WindowSamplingResult result = generateWindowEventsAndSample(windowEventsSupplier, sampler, mean); + samplesPerWindow[w] = + (1 - abs((result.samples - samplesBase - expectedSamples) / (double) expectedSamples)); + sampleIndexSkewPerWindow[w] = result.sampleIndexSkew; + allSamples += result.samples; + allEvents += result.events; + + rollWindow(); + } + + /* + * Turn all events into samples if their number is <= than the expected number of samples. + */ + final double targetSamples = Math.min(allEvents, expectedSamples); + + /* + * Calculate the percentual error based on the expected and the observed number of samples. + */ + final double percentualError = round(((targetSamples - allSamples) / targetSamples) * 100); + + reportSampleStatistics(samplesPerWindow, targetSamples, percentualError); + reportSampleIndexSkew(sampleIndexSkewPerWindow); + + assertTrue( + abs(percentualError) <= maxErrorPercent, + "abs((" + + targetSamples + + " - " + + allSamples + + ") / " + + targetSamples + + ")% > " + + maxErrorPercent + + "%"); } - final int positiveCount = sampleIndexSkewPerWindow.length - negativeCount; - if (positiveCount > 0) { - skewPositiveAvg /= sampleIndexSkewPerWindow.length - negativeCount; + + private void reportSampleStatistics(double[] samplesPerWindow, double targetSamples, double percentualError) { + final double samplesPerWindowMean = new Mean().evaluate(samplesPerWindow); + final double samplesPerWindowStdev = STANDARD_DEVIATION.evaluate(samplesPerWindow, samplesPerWindowMean); + + log.info( + "\t per window samples = (avg: {}, stdev: {}, estimated total: {})", + samplesPerWindowMean, + samplesPerWindowStdev, + targetSamples); + + log.info("\t percentual error = {}%", percentualError); } - if (negativeCount > 0) { - skewNegativeAvg /= negativeCount; + + private void reportSampleIndexSkew(double[] sampleIndexSkewPerWindow) { + Pair skewIndicators = calculateSkewIndicators(sampleIndexSkewPerWindow); + log.info( + "\t avg window skew interval = <-{}%, {}%>", + round(skewIndicators.getFirst() * 100), round(skewIndicators.getSecond() * 100)); } - return new Pair<>(skewNegativeAvg, skewPositiveAvg); - } - - private void testSamplerConcurrently( - final int threadCount, final IntSupplier windowEventsSupplier, final int maxErrorPercent) - throws Exception { - log.info( - "> threads: {}, mode: {}, windows: {}, SAMPLES_PER_WINDOW: {}, LOOKBACK: {}, max error: {}", - threadCount, - windowEventsSupplier, - WINDOWS, - SAMPLES_PER_WINDOW, - AVERAGE_LOOKBACK, - maxErrorPercent); - - /* - * This test attempts to simulate concurrent computations by making sure that sampling requests and the window maintenance routine are run in parallel. - * It does not provide coverage of all possible execution sequences but should be good enough for getting the 'ballpark' numbers. + + /** + * Simulate the number of events per window. Perform sampling and capture the number of observed + * events and samples. + * + * @param windowEventsSupplier events generator implementation + * @param sampler sampler instance + * @param mean a {@code Mean} instance for calculations + * @return a {@linkplain WindowSamplingResult} instance capturing the number of observed events, + * samples and the sample index skew */ - final long expectedSamples = SAMPLES_PER_WINDOW * WINDOWS; - final AtomicLong allSamples = new AtomicLong(0); - final AtomicLong receivedEvents = new AtomicLong(0); - - final AdaptiveSampler sampler = - new AdaptiveSampler( - WINDOW_DURATION, - SAMPLES_PER_WINDOW, - AVERAGE_LOOKBACK, - BUDGET_LOOKBACK, - null, - taskScheduler, - true); - final CyclicBarrier startBarrier = new CyclicBarrier(threadCount); - final CyclicBarrier endBarrier = new CyclicBarrier(threadCount, this::rollWindow); - final Mean[] means = new Mean[threadCount]; - final Thread[] threads = new Thread[threadCount]; - for (int i = 0; i < threadCount; i++) { - means[i] = new Mean(); - final Mean mean = means[i]; - threads[i] = - new Thread( - () -> { + private WindowSamplingResult generateWindowEventsAndSample( + IntSupplier windowEventsSupplier, AdaptiveSampler sampler, Mean mean) { + int samples = 0; + int events = windowEventsSupplier.getAsInt(); + mean.clear(); + for (int i = 0; i < events; i++) { + if (sampler.sample()) { + mean.increment(i); + samples++; + } + } + double sampleIndexMean = mean.getResult(); + double sampleIndexSkew = events != 0 ? sampleIndexMean / events : 0; + return new WindowSamplingResult(events, samples, sampleIndexSkew); + } + + /** + * Calculate the sample index skew boundaries. A 'sample index skew' is defined as the distance of + * the average sample index in each window from the mean event index in the same window. Given the + * range of the event indices 1..N, the event index mean M calculated as (N - 1)/2 and the sample + * index mean S the skew K is calculated as 'K = M - S'. This gives the skew range of <-0.5, + * 0.5>. + * + *

If the samples are spread out completely regularly the skew would be 0. If the beginning of + * the window is favored the skew would be negative and if the tail of the window is favored the + * skew would be positive. + * + * @param sampleIndexSkewPerWindow the index skew per window + * @return a min-max boundaries for the sample index skew + */ + private Pair calculateSkewIndicators(double[] sampleIndexSkewPerWindow) { + double skewPositiveAvg = 0d; + double skewNegativeAvg = 0d; + int negativeCount = 0; + for (final double skew : sampleIndexSkewPerWindow) { + if (skew >= 0.5d) { + skewPositiveAvg += skew - 0.5d; + } else { + negativeCount++; + skewNegativeAvg += 0.5d - skew; + } + } + final int positiveCount = sampleIndexSkewPerWindow.length - negativeCount; + if (positiveCount > 0) { + skewPositiveAvg /= sampleIndexSkewPerWindow.length - negativeCount; + } + if (negativeCount > 0) { + skewNegativeAvg /= negativeCount; + } + return new Pair<>(skewNegativeAvg, skewPositiveAvg); + } + + private void testSamplerConcurrently( + final int threadCount, final IntSupplier windowEventsSupplier, final int maxErrorPercent) throws Exception { + log.info( + "> threads: {}, mode: {}, windows: {}, SAMPLES_PER_WINDOW: {}, LOOKBACK: {}, max error: {}", + threadCount, + windowEventsSupplier, + WINDOWS, + SAMPLES_PER_WINDOW, + AVERAGE_LOOKBACK, + maxErrorPercent); + + /* + * This test attempts to simulate concurrent computations by making sure that sampling requests and the window maintenance routine are run in parallel. + * It does not provide coverage of all possible execution sequences but should be good enough for getting the 'ballpark' numbers. + */ + final long expectedSamples = SAMPLES_PER_WINDOW * WINDOWS; + final AtomicLong allSamples = new AtomicLong(0); + final AtomicLong receivedEvents = new AtomicLong(0); + + final AdaptiveSampler sampler = new AdaptiveSampler( + WINDOW_DURATION, SAMPLES_PER_WINDOW, AVERAGE_LOOKBACK, BUDGET_LOOKBACK, null, taskScheduler, true); + final CyclicBarrier startBarrier = new CyclicBarrier(threadCount); + final CyclicBarrier endBarrier = new CyclicBarrier(threadCount, this::rollWindow); + final Mean[] means = new Mean[threadCount]; + final Thread[] threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) { + means[i] = new Mean(); + final Mean mean = means[i]; + threads[i] = new Thread(() -> { try { - for (int w = 0; w < WINDOWS; w++) { - startBarrier.await(10, TimeUnit.SECONDS); - WindowSamplingResult samplingResult = - generateWindowEventsAndSample(windowEventsSupplier, sampler, mean); - allSamples.addAndGet(samplingResult.samples); - receivedEvents.addAndGet(samplingResult.events); - endBarrier.await(10, TimeUnit.SECONDS); - } + for (int w = 0; w < WINDOWS; w++) { + startBarrier.await(10, TimeUnit.SECONDS); + WindowSamplingResult samplingResult = + generateWindowEventsAndSample(windowEventsSupplier, sampler, mean); + allSamples.addAndGet(samplingResult.samples); + receivedEvents.addAndGet(samplingResult.events); + endBarrier.await(10, TimeUnit.SECONDS); + } } catch (Throwable ignored) { } - }); - threads[i].start(); - } - for (final Thread t : threads) { - t.join(); + }); + threads[i].start(); + } + for (final Thread t : threads) { + t.join(); + } + + final long samples = allSamples.get(); + /* + * Turn all events into samples if their number is <= than the expected number of samples. + */ + final long targetSamples = Math.min(expectedSamples, receivedEvents.get()); + /* + * Calculate the percentual error based on the expected and the observed number of samples. + */ + final int percentualError = round(((targetSamples - samples) / (float) targetSamples) * 100); + log.info("\t percentual error = {}%", percentualError); + + assertTrue( + abs(percentualError) <= maxErrorPercent, + "abs((" + + expectedSamples + + " - " + + samples + + ") / " + + expectedSamples + + ")% > " + + maxErrorPercent + + "%"); } - final long samples = allSamples.get(); - /* - * Turn all events into samples if their number is <= than the expected number of samples. - */ - final long targetSamples = Math.min(expectedSamples, receivedEvents.get()); - /* - * Calculate the percentual error based on the expected and the observed number of samples. - */ - final int percentualError = round(((targetSamples - samples) / (float) targetSamples) * 100); - log.info("\t percentual error = {}%", percentualError); - - assertTrue( - abs(percentualError) <= maxErrorPercent, - "abs((" - + expectedSamples - + " - " - + samples - + ") / " - + expectedSamples - + ")% > " - + maxErrorPercent - + "%"); - } - - private void rollWindow() { - rollWindowTaskCaptor.getValue().run(rollWindowTargetCaptor.getValue()); - } + private void rollWindow() { + rollWindowTaskCaptor.getValue().run(rollWindowTargetCaptor.getValue()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/sampling/PerRecordingRateLimiterTest.java b/internal-api/src/test/java/datadog/trace/api/sampling/PerRecordingRateLimiterTest.java index 2b32c9c2cb5..bd3c931f533 100644 --- a/internal-api/src/test/java/datadog/trace/api/sampling/PerRecordingRateLimiterTest.java +++ b/internal-api/src/test/java/datadog/trace/api/sampling/PerRecordingRateLimiterTest.java @@ -9,26 +9,25 @@ public class PerRecordingRateLimiterTest { - @Test - public void testLimitApplied() { - Duration window = Duration.ofMillis(100); - int limit = 20; - Duration recordingDurationSeconds = Duration.ofSeconds(1); - PerRecordingRateLimiter rateLimiter = - new PerRecordingRateLimiter(window, limit, recordingDurationSeconds, 5); - // no rate limiting is applied during the first window - int[] slots = new int[(int) (recordingDurationSeconds.toMillis() / window.toMillis())]; - long start = System.nanoTime(); - while (true) { - int slot = (int) (NANOSECONDS.toMillis(System.nanoTime() - start) / window.toMillis()); - if (slot >= slots.length) { - break; - } - if (rateLimiter.permit()) { - slots[slot]++; - } + @Test + public void testLimitApplied() { + Duration window = Duration.ofMillis(100); + int limit = 20; + Duration recordingDurationSeconds = Duration.ofSeconds(1); + PerRecordingRateLimiter rateLimiter = new PerRecordingRateLimiter(window, limit, recordingDurationSeconds, 5); + // no rate limiting is applied during the first window + int[] slots = new int[(int) (recordingDurationSeconds.toMillis() / window.toMillis())]; + long start = System.nanoTime(); + while (true) { + int slot = (int) (NANOSECONDS.toMillis(System.nanoTime() - start) / window.toMillis()); + if (slot >= slots.length) { + break; + } + if (rateLimiter.permit()) { + slots[slot]++; + } + } + assertTrue(Arrays.stream(slots).max().orElse(limit + 1) <= limit); + assertTrue(Arrays.stream(slots).sum() <= 2 * limit); } - assertTrue(Arrays.stream(slots).max().orElse(limit + 1) <= limit); - assertTrue(Arrays.stream(slots).sum() <= 2 * limit); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/CoreMetricCollectorQueueFullTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/CoreMetricCollectorQueueFullTest.java index d9f32fdfc22..c3ea9db4c7e 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/CoreMetricCollectorQueueFullTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/CoreMetricCollectorQueueFullTest.java @@ -20,69 +20,67 @@ */ class CoreMetricCollectorQueueFullTest { - /** - * Fills the drain past its queue capacity and verifies that every counter's delta is reported - * exactly once across as many cycles as it takes to empty — none dropped while the queue is full. - * - *

{@link CoreMetricCollector} is a singleton over the global {@link SpanMetricRegistryImpl}, - * so this test can't assume a clean registry. It stays robust by tagging its own instrumentations - * with a unique prefix and counting only those, and it drains cycle-by-cycle until the queue - * yields nothing at all (so it never terminates early just because a cycle happened to surface - * only unrelated counters left behind by other tests). The sum of our own metrics across all - * cycles must equal the number we created; a dropped delta would make the sum come up short. - */ - @Test - void skippedCountersRetainDeltaAcrossCycles() { - SpanMetricRegistryImpl registry = SpanMetricRegistryImpl.getInstance(); - CoreMetricCollector collector = CoreMetricCollector.getInstance(); + /** + * Fills the drain past its queue capacity and verifies that every counter's delta is reported + * exactly once across as many cycles as it takes to empty — none dropped while the queue is full. + * + *

{@link CoreMetricCollector} is a singleton over the global {@link SpanMetricRegistryImpl}, + * so this test can't assume a clean registry. It stays robust by tagging its own instrumentations + * with a unique prefix and counting only those, and it drains cycle-by-cycle until the queue + * yields nothing at all (so it never terminates early just because a cycle happened to surface + * only unrelated counters left behind by other tests). The sum of our own metrics across all + * cycles must equal the number we created; a dropped delta would make the sum come up short. + */ + @Test + void skippedCountersRetainDeltaAcrossCycles() { + SpanMetricRegistryImpl registry = SpanMetricRegistryImpl.getInstance(); + CoreMetricCollector collector = CoreMetricCollector.getInstance(); - // Discard anything left in the queue by other tests sharing the singleton. - collector.drain(); + // Discard anything left in the queue by other tests sharing the singleton. + collector.drain(); - // Unique instrumentation-name prefix so we count only the instrumentations this test creates, - // regardless of whatever else has already polluted the shared registry. CoreMetricCollector - // emits these as "integration_name:" tags. - String namePrefix = "queue-full-test-" + System.nanoTime() + "-"; - String tagPrefix = "integration_name:" + namePrefix; + // Unique instrumentation-name prefix so we count only the instrumentations this test creates, + // regardless of whatever else has already polluted the shared registry. CoreMetricCollector + // emits these as "integration_name:" tags. + String namePrefix = "queue-full-test-" + System.nanoTime() + "-"; + String tagPrefix = "integration_name:" + namePrefix; - // Create more non-zero counters than the queue can hold so at least one collection cycle - // overflows. A single onSpanCreated() per instrumentation leaves exactly one non-zero counter - // (spans_created) each; the rest read as 0 and are skipped. - int created = RAW_QUEUE_SIZE + 200; - for (int i = 0; i < created; i++) { - registry.get(namePrefix + i).onSpanCreated(); - } + // Create more non-zero counters than the queue can hold so at least one collection cycle + // overflows. A single onSpanCreated() per instrumentation leaves exactly one non-zero counter + // (spans_created) each; the rest read as 0 and are skipped. + int created = RAW_QUEUE_SIZE + 200; + for (int i = 0; i < created; i++) { + registry.get(namePrefix + i).onSpanCreated(); + } - // Drain cycle-by-cycle until the queue is fully emptied, summing how many of our own metrics - // surface. Guard the loop so a bug that never converges fails loudly instead of hanging. - int seen = 0; - int cycles = 0; - while (true) { - collector.prepareMetrics(); - Collection drained = collector.drain(); - if (drained.isEmpty()) { - break; - } - for (CoreMetric metric : drained) { - if (isOurs(metric, tagPrefix)) { - seen++; + // Drain cycle-by-cycle until the queue is fully emptied, summing how many of our own metrics + // surface. Guard the loop so a bug that never converges fails loudly instead of hanging. + int seen = 0; + int cycles = 0; + while (true) { + collector.prepareMetrics(); + Collection drained = collector.drain(); + if (drained.isEmpty()) { + break; + } + for (CoreMetric metric : drained) { + if (isOurs(metric, tagPrefix)) { + seen++; + } + } + assertTrue(++cycles < 20, "drain did not converge; possible lost/looping delta"); } - } - assertTrue(++cycles < 20, "drain did not converge; possible lost/looping delta"); - } - assertEquals( - created, - seen, - "every counter's delta must be reported exactly once; a full queue must not drop any"); - } + assertEquals( + created, seen, "every counter's delta must be reported exactly once; a full queue must not drop any"); + } - private static boolean isOurs(CoreMetric metric, String tagPrefix) { - for (String tag : metric.tags) { - if (tag.startsWith(tagPrefix)) { - return true; - } + private static boolean isOurs(CoreMetric metric, String tagPrefix) { + for (String tag : metric.tags) { + if (tag.startsWith(tagPrefix)) { + return true; + } + } + return false; } - return false; - } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsFeedbackMetricTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsFeedbackMetricTest.java index 5cfc28aaa84..3d196064962 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsFeedbackMetricTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsFeedbackMetricTest.java @@ -15,57 +15,57 @@ */ class LLMObsFeedbackMetricTest { - private LLMObsMetricCollector collector; + private LLMObsMetricCollector collector; - @BeforeEach - void setup() { - collector = LLMObsMetricCollector.get(); - // The collector is a singleton shared with the rest of the suite. - collector.drain(); - } + @BeforeEach + void setup() { + collector = LLMObsMetricCollector.get(); + // The collector is a singleton shared with the rest of the suite. + collector.drain(); + } - private LLMObsMetricCollector.LLMObsMetric drainOne() { - Collection drained = collector.drain(); - assertEquals(1, drained.size(), drained.toString()); - return drained.iterator().next(); - } + private LLMObsMetricCollector.LLMObsMetric drainOne() { + Collection drained = collector.drain(); + assertEquals(1, drained.size(), drained.toString()); + return drained.iterator().next(); + } - @Test - void testAnAcceptedSubmissionIsCountedWithoutError() { - collector.recordFeedbackSubmitted("boolean", "span_id", null); + @Test + void testAnAcceptedSubmissionIsCountedWithoutError() { + collector.recordFeedbackSubmitted("boolean", "span_id", null); - LLMObsMetricCollector.LLMObsMetric metric = drainOne(); - assertEquals("mlobs", metric.namespace); - assertEquals("feedback_submitted", metric.metricName); - assertEquals("count", metric.type); - assertEquals(1L, metric.value.longValue()); + LLMObsMetricCollector.LLMObsMetric metric = drainOne(); + assertEquals("mlobs", metric.namespace); + assertEquals("feedback_submitted", metric.metricName); + assertEquals("count", metric.type); + assertEquals(1L, metric.value.longValue()); - List tags = metric.tags; - assertTrue(tags.contains("error:0"), tags.toString()); - assertTrue(tags.contains("metric_type:boolean"), tags.toString()); - assertTrue(tags.contains("target_type:span_id"), tags.toString()); - assertFalse(tags.toString().contains("error_type"), tags.toString()); - } + List tags = metric.tags; + assertTrue(tags.contains("error:0"), tags.toString()); + assertTrue(tags.contains("metric_type:boolean"), tags.toString()); + assertTrue(tags.contains("target_type:span_id"), tags.toString()); + assertFalse(tags.toString().contains("error_type"), tags.toString()); + } - @Test - void testARejectedSubmissionCarriesItsErrorType() { - collector.recordFeedbackSubmitted("text", "feedback_join_key", "invalid_submitter"); + @Test + void testARejectedSubmissionCarriesItsErrorType() { + collector.recordFeedbackSubmitted("text", "feedback_join_key", "invalid_submitter"); - List tags = drainOne().tags; - assertTrue(tags.contains("error:1"), tags.toString()); - assertTrue(tags.contains("error_type:invalid_submitter"), tags.toString()); - assertTrue(tags.contains("metric_type:text"), tags.toString()); - assertTrue(tags.contains("target_type:feedback_join_key"), tags.toString()); - } + List tags = drainOne().tags; + assertTrue(tags.contains("error:1"), tags.toString()); + assertTrue(tags.contains("error_type:invalid_submitter"), tags.toString()); + assertTrue(tags.contains("metric_type:text"), tags.toString()); + assertTrue(tags.contains("target_type:feedback_join_key"), tags.toString()); + } - @Test - void testUndeterminedMetricAndTargetTypesFallBackToOther() { - // A feedback rejected before its target or value was set still gets counted. - collector.recordFeedbackSubmitted(null, null, "invalid_target_count"); + @Test + void testUndeterminedMetricAndTargetTypesFallBackToOther() { + // A feedback rejected before its target or value was set still gets counted. + collector.recordFeedbackSubmitted(null, null, "invalid_target_count"); - List tags = drainOne().tags; - assertTrue(tags.contains("error:1"), tags.toString()); - assertTrue(tags.contains("metric_type:other"), tags.toString()); - assertTrue(tags.contains("target_type:other"), tags.toString()); - } + List tags = drainOne().tags; + assertTrue(tags.contains("error:1"), tags.toString()); + assertTrue(tags.contains("metric_type:other"), tags.toString()); + assertTrue(tags.contains("target_type:other"), tags.toString()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorTest.java index efce543cee3..0608d37c5b7 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorTest.java @@ -13,155 +13,151 @@ import org.junit.jupiter.api.Test; class LLMObsMetricCollectorTest { - private final LLMObsMetricCollector collector = LLMObsMetricCollector.get(); - - @BeforeEach - void clearStaleMetrics() { - collector.resetForTesting(); - } - - @AfterEach - void clearMetrics() { - collector.resetForTesting(); - } - - @Test - void noMetricsDrainsEmptyList() { - collector.prepareMetrics(); - - assertTrue(collector.drain().isEmpty()); - } - - @Test - void recordsOneMetricPerDistinctTagCombination() { - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.recordSpanFinished("openai", "llm", false, true, false, true); - collector.recordSpanFinished("anthropic", "embedding", true, false, true, false); - collector.prepareMetrics(); - - List metrics = sorted(collector.drain()); - - assertEquals(3, metrics.size()); - for (LLMObsMetricCollector.LLMObsMetric metric : metrics) { - assertEquals("mlobs", metric.namespace); - assertEquals("span.finished", metric.metricName); - assertEquals("count", metric.type); - assertEquals(1L, metric.value); + private final LLMObsMetricCollector collector = LLMObsMetricCollector.get(); + + @BeforeEach + void clearStaleMetrics() { + collector.resetForTesting(); } - assertEquals( - new HashSet<>( - Arrays.asList( - new HashSet<>( - Arrays.asList( - "integration:openai", - "span_kind:llm", - "is_root_span:1", - "autoinstrumented:1", - "error:0", - "has_session_id:0")), - new HashSet<>( - Arrays.asList( - "integration:openai", - "span_kind:llm", - "is_root_span:0", - "autoinstrumented:1", - "error:0", - "has_session_id:1")), - new HashSet<>( - Arrays.asList( - "integration:anthropic", - "span_kind:embedding", - "is_root_span:1", - "autoinstrumented:0", - "error:1", - "has_session_id:0")))), - tagSets(metrics)); - } - - @Test - void aggregatesIdenticalTagCombinationsIntoASingleCount() { - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - - assertEquals(1, metrics.size()); - LLMObsMetricCollector.LLMObsMetric metric = metrics.iterator().next(); - assertEquals("count", metric.type); - assertEquals(3L, metric.value); - } - - /** - * Regression test for the reported ~100x under-count: emitting one raw metric per span both - * overflowed the bounded raw queue and collapsed to ~1/s per series at the metrics intake, since - * points are timestamped at second granularity. The count must survive well past {@link - * MetricCollector#RAW_QUEUE_SIZE} spans in a single interval. - */ - @Test - void reportsExactCountWellBeyondRawQueueSize() { - int spans = MetricCollector.RAW_QUEUE_SIZE * 10; - for (int i = 0; i < spans; i++) { - collector.recordSpanFinished("openai", "llm", false, true, false, false); + + @AfterEach + void clearMetrics() { + collector.resetForTesting(); } - collector.prepareMetrics(); - Collection metrics = collector.drain(); + @Test + void noMetricsDrainsEmptyList() { + collector.prepareMetrics(); - assertEquals(1, metrics.size()); - assertEquals((long) spans, metrics.iterator().next().value); - } + assertTrue(collector.drain().isEmpty()); + } - @Test - void countersResetBetweenIntervals() { - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.prepareMetrics(); - assertEquals(1, collector.drain().size()); + @Test + void recordsOneMetricPerDistinctTagCombination() { + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.recordSpanFinished("openai", "llm", false, true, false, true); + collector.recordSpanFinished("anthropic", "embedding", true, false, true, false); + collector.prepareMetrics(); + + List metrics = sorted(collector.drain()); + + assertEquals(3, metrics.size()); + for (LLMObsMetricCollector.LLMObsMetric metric : metrics) { + assertEquals("mlobs", metric.namespace); + assertEquals("span.finished", metric.metricName); + assertEquals("count", metric.type); + assertEquals(1L, metric.value); + } + assertEquals( + new HashSet<>(Arrays.asList( + new HashSet<>(Arrays.asList( + "integration:openai", + "span_kind:llm", + "is_root_span:1", + "autoinstrumented:1", + "error:0", + "has_session_id:0")), + new HashSet<>(Arrays.asList( + "integration:openai", + "span_kind:llm", + "is_root_span:0", + "autoinstrumented:1", + "error:0", + "has_session_id:1")), + new HashSet<>(Arrays.asList( + "integration:anthropic", + "span_kind:embedding", + "is_root_span:1", + "autoinstrumented:0", + "error:1", + "has_session_id:0")))), + tagSets(metrics)); + } - collector.prepareMetrics(); + @Test + void aggregatesIdenticalTagCombinationsIntoASingleCount() { + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.prepareMetrics(); - assertTrue(collector.drain().isEmpty(), "an idle interval must not re-report a stale count"); - } + Collection metrics = collector.drain(); - @Test - void reportsCountsAccumulatedAcrossIntervalsWithoutADrain() { - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.prepareMetrics(); - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.recordSpanFinished("openai", "llm", true, true, false, false); - collector.prepareMetrics(); + assertEquals(1, metrics.size()); + LLMObsMetricCollector.LLMObsMetric metric = metrics.iterator().next(); + assertEquals("count", metric.type); + assertEquals(3L, metric.value); + } - List metrics = sorted(collector.drain()); + /** + * Regression test for the reported ~100x under-count: emitting one raw metric per span both + * overflowed the bounded raw queue and collapsed to ~1/s per series at the metrics intake, since + * points are timestamped at second granularity. The count must survive well past {@link + * MetricCollector#RAW_QUEUE_SIZE} spans in a single interval. + */ + @Test + void reportsExactCountWellBeyondRawQueueSize() { + int spans = MetricCollector.RAW_QUEUE_SIZE * 10; + for (int i = 0; i < spans; i++) { + collector.recordSpanFinished("openai", "llm", false, true, false, false); + } + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + + assertEquals(1, metrics.size()); + assertEquals((long) spans, metrics.iterator().next().value); + } + + @Test + void countersResetBetweenIntervals() { + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.prepareMetrics(); + assertEquals(1, collector.drain().size()); - assertEquals(2, metrics.size()); - assertEquals(1L, metrics.get(0).value); - assertEquals(2L, metrics.get(1).value); - } + collector.prepareMetrics(); - @Test - void boundsTheNumberOfTrackedTagCombinations() { - for (int i = 0; i < LLMObsMetricCollector.MAX_TAG_COMBINATIONS * 2; i++) { - collector.recordSpanFinished("integration-" + i, "llm", true, true, false, false); + assertTrue(collector.drain().isEmpty(), "an idle interval must not re-report a stale count"); } - collector.prepareMetrics(); - - assertEquals(LLMObsMetricCollector.MAX_TAG_COMBINATIONS, collector.drain().size()); - } - - private static List sorted( - Collection metrics) { - List sorted = new ArrayList<>(metrics); - sorted.sort((a, b) -> Long.compare(a.value.longValue(), b.value.longValue())); - return sorted; - } - - private static HashSet> tagSets( - Collection metrics) { - HashSet> tagSets = new HashSet<>(); - for (LLMObsMetricCollector.LLMObsMetric metric : metrics) { - tagSets.add(new HashSet<>(metric.tags)); + + @Test + void reportsCountsAccumulatedAcrossIntervalsWithoutADrain() { + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.prepareMetrics(); + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.recordSpanFinished("openai", "llm", true, true, false, false); + collector.prepareMetrics(); + + List metrics = sorted(collector.drain()); + + assertEquals(2, metrics.size()); + assertEquals(1L, metrics.get(0).value); + assertEquals(2L, metrics.get(1).value); + } + + @Test + void boundsTheNumberOfTrackedTagCombinations() { + for (int i = 0; i < LLMObsMetricCollector.MAX_TAG_COMBINATIONS * 2; i++) { + collector.recordSpanFinished("integration-" + i, "llm", true, true, false, false); + } + collector.prepareMetrics(); + + assertEquals( + LLMObsMetricCollector.MAX_TAG_COMBINATIONS, collector.drain().size()); + } + + private static List sorted( + Collection metrics) { + List sorted = new ArrayList<>(metrics); + sorted.sort((a, b) -> Long.compare(a.value.longValue(), b.value.longValue())); + return sorted; + } + + private static HashSet> tagSets(Collection metrics) { + HashSet> tagSets = new HashSet<>(); + for (LLMObsMetricCollector.LLMObsMetric metric : metrics) { + tagSets.add(new HashSet<>(metric.tags)); + } + return tagSets; } - return tagSets; - } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorUserProcessorTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorUserProcessorTest.java index a5c2daff6a3..e5050969e83 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorUserProcessorTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/LLMObsMetricCollectorUserProcessorTest.java @@ -9,26 +9,26 @@ import org.junit.jupiter.api.Test; class LLMObsMetricCollectorUserProcessorTest { - private final LLMObsMetricCollector collector = LLMObsMetricCollector.get(); + private final LLMObsMetricCollector collector = LLMObsMetricCollector.get(); - @BeforeEach - void clearMetrics() { - collector.drain(); - } + @BeforeEach + void clearMetrics() { + collector.drain(); + } - @Test - void recordsUserProcessorCalledMetrics() { - collector.recordUserProcessorCalled(false); - collector.recordUserProcessorCalled(true); + @Test + void recordsUserProcessorCalledMetrics() { + collector.recordUserProcessorCalled(false); + collector.recordUserProcessorCalled(true); - List metrics = new ArrayList<>(collector.drain()); + List metrics = new ArrayList<>(collector.drain()); - assertEquals(2, metrics.size()); - assertEquals(LLMObsMetricCollector.USER_PROCESSOR_CALLED_METRIC, metrics.get(0).metricName); - assertEquals(LLMObsMetricCollector.COUNT_METRIC_TYPE, metrics.get(0).type); - assertEquals(singletonList("error:0"), metrics.get(0).tags); - assertEquals(LLMObsMetricCollector.USER_PROCESSOR_CALLED_METRIC, metrics.get(1).metricName); - assertEquals(LLMObsMetricCollector.COUNT_METRIC_TYPE, metrics.get(1).type); - assertEquals(singletonList("error:1"), metrics.get(1).tags); - } + assertEquals(2, metrics.size()); + assertEquals(LLMObsMetricCollector.USER_PROCESSOR_CALLED_METRIC, metrics.get(0).metricName); + assertEquals(LLMObsMetricCollector.COUNT_METRIC_TYPE, metrics.get(0).type); + assertEquals(singletonList("error:0"), metrics.get(0).tags); + assertEquals(LLMObsMetricCollector.USER_PROCESSOR_CALLED_METRIC, metrics.get(1).metricName); + assertEquals(LLMObsMetricCollector.COUNT_METRIC_TYPE, metrics.get(1).type); + assertEquals(singletonList("error:1"), metrics.get(1).tags); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java index 05ab493fd86..b1a73602e3a 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/LogCollectorTest.java @@ -16,375 +16,354 @@ import org.junit.jupiter.api.Test; class LogCollectorTest { - private static final long TIMEOUT_SECONDS = 10; - - @Test - void recordsLogGroupTimestamp() { - // Given - LogCollector logCollector = new LogCollector(1); - long before = System.currentTimeMillis() / 1000; - - // When - logCollector.addLogMessage("ERROR", "Message 1", null); - - long after = System.currentTimeMillis() / 1000; - LogCollector.RawLogMessage logGroup = singleLogGroup(logCollector.drain()); - - // Then - assertThat(logGroup.timestamp).isBetween(before, after); - } - - @Test - void limitsLogGroupsToCapacity() { - // Given - LogCollector logCollector = new LogCollector(3); - - // When - logCollector.addLogMessage("ERROR", "Message 1", null); - logCollector.addLogMessage("ERROR", "Message 2", null); - logCollector.addLogMessage("ERROR", "Message 3", null); - logCollector.addLogMessage("ERROR", "Message 4", null); - - Collection logGroups = logCollector.drain(); - - // Then - assertThat(logGroups).hasSize(3); - assertContainsLogGroup(logGroups, "Message 1", 1); - assertContainsLogGroup(logGroups, "Message 2", 1); - assertContainsLogGroup(logGroups, "Message 3", 1); - assertThat(logGroups).extracting(logGroup -> logGroup.message).doesNotContain("Message 4"); - } - - @Test - void groupsEquivalentMessages() { - // Given - LogCollector logCollector = new LogCollector(10); - - // When - logCollector.addLogMessage("ERROR", "Foo Message", null); - logCollector.addLogMessage("ERROR", "Bar Message", null); - logCollector.addLogMessage("ERROR", "Baz Message", null); - logCollector.addLogMessage("ERROR", "Qux Message", null); - logCollector.addLogMessage("ERROR", "Bar Message", null); - logCollector.addLogMessage("ERROR", "Baz Message", null); - logCollector.addLogMessage("ERROR", "Qux Message", null); - logCollector.addLogMessage("ERROR", "Baz Message", null); - logCollector.addLogMessage("ERROR", "Qux Message", null); - logCollector.addLogMessage("ERROR", "Qux Message", null); - - Collection logGroups = logCollector.drain(); - - // Then - assertThat(logGroups).hasSize(4); - assertContainsLogGroup(logGroups, "Foo Message", 1); - assertContainsLogGroup(logGroups, "Bar Message", 2); - assertContainsLogGroup(logGroups, "Baz Message", 3); - assertContainsLogGroup(logGroups, "Qux Message", 4); - } - - @Test - void countsEquivalentMessagesWhenTableIsFull() { - // Given - LogCollector logCollector = new LogCollector(1); - logCollector.addLogMessage("ERROR", "Message", null); - - // When - logCollector.addLogMessage("ERROR", "Message", null); - - // Then - assertThat(singleLogGroup(logCollector.drain()).count).isEqualTo(2); - } - - @Test - void dropsNewLogGroupWhenTableIsFull() { - // Given - LogCollector logCollector = new LogCollector(1); - logCollector.addLogMessage("ERROR", "Existing message", null); - - // When - logCollector.addLogMessage("ERROR", "New message", null); - - // Then - assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("Existing message"); - } - - @Test - void reusesLogGroupCapacityAfterDrain() { - // Given - LogCollector logCollector = new LogCollector(1); - - // When - logCollector.addLogMessage("ERROR", "First", null); - - // Then - assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("First"); - - // When - logCollector.addLogMessage("ERROR", "Second", null); - - // Then - assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("Second"); - assertThat(logCollector.drain()).isEmpty(); - } - - @Test - void acceptsNewLogGroupAfterDrainDetachesFullBucket() throws Exception { - // Given - LogCollector logCollector = new LogCollector(1); - logCollector.addLogMessage("ERROR", "First", null); - ConcurrentHashtable.State state = logCollector.rawLogMessages; - CountDownLatch bucketDetached = new CountDownLatch(1); - CountDownLatch releaseDrain = new CountDownLatch(1); - FutureTask> drainTask = - new FutureTask<>( - () -> { - List drainedLogGroups = new ArrayList<>(); - ConcurrentHashtable.drain( - state, - drainedLogGroups, - (logGroups, logGroup) -> { - bucketDetached.countDown(); - await(releaseDrain); - logGroups.add(logGroup); - }); - return drainedLogGroups; - }); - Thread drainThread = new Thread(drainTask, "log-collector-drain"); - FutureTask writerTask = - new FutureTask<>( - () -> { - logCollector.addLogMessage("ERROR", "Second", null); - return null; + private static final long TIMEOUT_SECONDS = 10; + + @Test + void recordsLogGroupTimestamp() { + // Given + LogCollector logCollector = new LogCollector(1); + long before = System.currentTimeMillis() / 1000; + + // When + logCollector.addLogMessage("ERROR", "Message 1", null); + + long after = System.currentTimeMillis() / 1000; + LogCollector.RawLogMessage logGroup = singleLogGroup(logCollector.drain()); + + // Then + assertThat(logGroup.timestamp).isBetween(before, after); + } + + @Test + void limitsLogGroupsToCapacity() { + // Given + LogCollector logCollector = new LogCollector(3); + + // When + logCollector.addLogMessage("ERROR", "Message 1", null); + logCollector.addLogMessage("ERROR", "Message 2", null); + logCollector.addLogMessage("ERROR", "Message 3", null); + logCollector.addLogMessage("ERROR", "Message 4", null); + + Collection logGroups = logCollector.drain(); + + // Then + assertThat(logGroups).hasSize(3); + assertContainsLogGroup(logGroups, "Message 1", 1); + assertContainsLogGroup(logGroups, "Message 2", 1); + assertContainsLogGroup(logGroups, "Message 3", 1); + assertThat(logGroups).extracting(logGroup -> logGroup.message).doesNotContain("Message 4"); + } + + @Test + void groupsEquivalentMessages() { + // Given + LogCollector logCollector = new LogCollector(10); + + // When + logCollector.addLogMessage("ERROR", "Foo Message", null); + logCollector.addLogMessage("ERROR", "Bar Message", null); + logCollector.addLogMessage("ERROR", "Baz Message", null); + logCollector.addLogMessage("ERROR", "Qux Message", null); + logCollector.addLogMessage("ERROR", "Bar Message", null); + logCollector.addLogMessage("ERROR", "Baz Message", null); + logCollector.addLogMessage("ERROR", "Qux Message", null); + logCollector.addLogMessage("ERROR", "Baz Message", null); + logCollector.addLogMessage("ERROR", "Qux Message", null); + logCollector.addLogMessage("ERROR", "Qux Message", null); + + Collection logGroups = logCollector.drain(); + + // Then + assertThat(logGroups).hasSize(4); + assertContainsLogGroup(logGroups, "Foo Message", 1); + assertContainsLogGroup(logGroups, "Bar Message", 2); + assertContainsLogGroup(logGroups, "Baz Message", 3); + assertContainsLogGroup(logGroups, "Qux Message", 4); + } + + @Test + void countsEquivalentMessagesWhenTableIsFull() { + // Given + LogCollector logCollector = new LogCollector(1); + logCollector.addLogMessage("ERROR", "Message", null); + + // When + logCollector.addLogMessage("ERROR", "Message", null); + + // Then + assertThat(singleLogGroup(logCollector.drain()).count).isEqualTo(2); + } + + @Test + void dropsNewLogGroupWhenTableIsFull() { + // Given + LogCollector logCollector = new LogCollector(1); + logCollector.addLogMessage("ERROR", "Existing message", null); + + // When + logCollector.addLogMessage("ERROR", "New message", null); + + // Then + assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("Existing message"); + } + + @Test + void reusesLogGroupCapacityAfterDrain() { + // Given + LogCollector logCollector = new LogCollector(1); + + // When + logCollector.addLogMessage("ERROR", "First", null); + + // Then + assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("First"); + + // When + logCollector.addLogMessage("ERROR", "Second", null); + + // Then + assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("Second"); + assertThat(logCollector.drain()).isEmpty(); + } + + @Test + void acceptsNewLogGroupAfterDrainDetachesFullBucket() throws Exception { + // Given + LogCollector logCollector = new LogCollector(1); + logCollector.addLogMessage("ERROR", "First", null); + ConcurrentHashtable.State state = logCollector.rawLogMessages; + CountDownLatch bucketDetached = new CountDownLatch(1); + CountDownLatch releaseDrain = new CountDownLatch(1); + FutureTask> drainTask = new FutureTask<>(() -> { + List drainedLogGroups = new ArrayList<>(); + ConcurrentHashtable.drain(state, drainedLogGroups, (logGroups, logGroup) -> { + bucketDetached.countDown(); + await(releaseDrain); + logGroups.add(logGroup); }); - Thread writerThread = new Thread(writerTask, "log-collector-writer"); - - // When - try { - drainThread.start(); - await(bucketDetached); - // Capacity for the detached entry is released before its consumer runs, so the writer's - // lock-free isFull() check sees room and then waits for the table lock while the drain - // consumer remains paused. - writerThread.start(); - new PollingConditions(TIMEOUT_SECONDS) - .eventually(() -> assertThat(writerThread.getState()).isEqualTo(Thread.State.WAITING)); - } finally { - releaseDrain.countDown(); + return drainedLogGroups; + }); + Thread drainThread = new Thread(drainTask, "log-collector-drain"); + FutureTask writerTask = new FutureTask<>(() -> { + logCollector.addLogMessage("ERROR", "Second", null); + return null; + }); + Thread writerThread = new Thread(writerTask, "log-collector-writer"); + + // When + try { + drainThread.start(); + await(bucketDetached); + // Capacity for the detached entry is released before its consumer runs, so the writer's + // lock-free isFull() check sees room and then waits for the table lock while the drain + // consumer remains paused. + writerThread.start(); + new PollingConditions(TIMEOUT_SECONDS) + .eventually(() -> assertThat(writerThread.getState()).isEqualTo(Thread.State.WAITING)); + } finally { + releaseDrain.countDown(); + } + + // Then + Collection firstDrainedLogGroups = await(drainTask); + await(writerTask); + assertThat(singleLogGroup(firstDrainedLogGroups).message).isEqualTo("First"); + assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("Second"); } - // Then - Collection firstDrainedLogGroups = await(drainTask); - await(writerTask); - assertThat(singleLogGroup(firstDrainedLogGroups).message).isEqualTo("First"); - assertThat(singleLogGroup(logCollector.drain()).message).isEqualTo("Second"); - } - - @Test - void groupsMessagesWithEquivalentThrowablesAndKeepsFirstMetadata() { - // Given - LogCollector logCollector = new LogCollector(2); - Throwable firstThrowable = throwableWithMethod("run", 10); - Throwable equivalentThrowable = throwableWithMethod("run", 10); - - // When - logCollector.addLogMessage("ERROR", "Message", firstThrowable, "source:first"); - logCollector.addLogMessage("ERROR", "Message", equivalentThrowable, "source:second"); - - LogCollector.RawLogMessage logGroup = singleLogGroup(logCollector.drain()); - - // Then - assertThat(logGroup.count).isEqualTo(2); - assertThat(logGroup.throwable).isSameAs(firstThrowable); - assertThat(logGroup.tags).isEqualTo("source:first"); - } - - @Test - void keepsMessagesWithDifferentStackTracesInSeparateGroups() { - // Given - LogCollector logCollector = new LogCollector(2); - - // When - logCollector.addLogMessage("ERROR", "Message", throwableWithMethod("run", 10)); - logCollector.addLogMessage("ERROR", "Message", throwableWithMethod("run", 20)); - - // Then - assertThat(logCollector.drain()).hasSize(2); - } - - @Test - void rawLogMessageEqualityMatchesGrouping() { - // Given - LogCollector.RawLogMessage first = - new LogCollector.RawLogMessage( - "ERROR", "Message", throwableWithMethod("run", 10), "first", 1); - LogCollector.RawLogMessage equivalent = - new LogCollector.RawLogMessage( - "ERROR", "Message", throwableWithMethod("run", 10), "second", 2); - LogCollector.RawLogMessage different = - new LogCollector.RawLogMessage( - "ERROR", "Message", throwableWithMethod("runIt", 20), "first", 1); - - // Then - assertThat(first).isEqualTo(equivalent); - assertThat(first).hasSameHashCodeAs(equivalent); - assertThat(first).isNotEqualTo(different); - } - - @Test - void countsConcurrentEquivalentMessages() throws Exception { - // Given - int threadCount = 16; - int messagesPerThread = 1_000; - LogCollector logCollector = new LogCollector(2); - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch start = new CountDownLatch(1); - Future[] futures = new Future[threadCount]; - try { - for (int i = 0; i < threadCount; i++) { - futures[i] = - executor.submit( - () -> { - await(start); - for (int occurrence = 0; occurrence < messagesPerThread; occurrence++) { - logCollector.addLogMessage("ERROR", "Message", null); - } - return null; + @Test + void groupsMessagesWithEquivalentThrowablesAndKeepsFirstMetadata() { + // Given + LogCollector logCollector = new LogCollector(2); + Throwable firstThrowable = throwableWithMethod("run", 10); + Throwable equivalentThrowable = throwableWithMethod("run", 10); + + // When + logCollector.addLogMessage("ERROR", "Message", firstThrowable, "source:first"); + logCollector.addLogMessage("ERROR", "Message", equivalentThrowable, "source:second"); + + LogCollector.RawLogMessage logGroup = singleLogGroup(logCollector.drain()); + + // Then + assertThat(logGroup.count).isEqualTo(2); + assertThat(logGroup.throwable).isSameAs(firstThrowable); + assertThat(logGroup.tags).isEqualTo("source:first"); + } + + @Test + void keepsMessagesWithDifferentStackTracesInSeparateGroups() { + // Given + LogCollector logCollector = new LogCollector(2); + + // When + logCollector.addLogMessage("ERROR", "Message", throwableWithMethod("run", 10)); + logCollector.addLogMessage("ERROR", "Message", throwableWithMethod("run", 20)); + + // Then + assertThat(logCollector.drain()).hasSize(2); + } + + @Test + void rawLogMessageEqualityMatchesGrouping() { + // Given + LogCollector.RawLogMessage first = + new LogCollector.RawLogMessage("ERROR", "Message", throwableWithMethod("run", 10), "first", 1); + LogCollector.RawLogMessage equivalent = + new LogCollector.RawLogMessage("ERROR", "Message", throwableWithMethod("run", 10), "second", 2); + LogCollector.RawLogMessage different = + new LogCollector.RawLogMessage("ERROR", "Message", throwableWithMethod("runIt", 20), "first", 1); + + // Then + assertThat(first).isEqualTo(equivalent); + assertThat(first).hasSameHashCodeAs(equivalent); + assertThat(first).isNotEqualTo(different); + } + + @Test + void countsConcurrentEquivalentMessages() throws Exception { + // Given + int threadCount = 16; + int messagesPerThread = 1_000; + LogCollector logCollector = new LogCollector(2); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[threadCount]; + try { + for (int i = 0; i < threadCount; i++) { + futures[i] = executor.submit(() -> { + await(start); + for (int occurrence = 0; occurrence < messagesPerThread; occurrence++) { + logCollector.addLogMessage("ERROR", "Message", null); + } + return null; }); - } + } - // When - release(start); - await(futures); - } finally { - shutdown(executor); + // When + release(start); + await(futures); + } finally { + shutdown(executor); + } + + // Then + assertThat(singleLogGroup(logCollector.drain()).count).isEqualTo(threadCount * messagesPerThread); } - // Then - assertThat(singleLogGroup(logCollector.drain()).count) - .isEqualTo(threadCount * messagesPerThread); - } - - @Test - void groupsConcurrentMessagesWithEquivalentThrowables() throws Exception { - // Given - int threadCount = 16; - LogCollector logCollector = new LogCollector(2); - Throwable firstThrowable = throwableWithMethod("run", 10); - logCollector.addLogMessage("ERROR", "Message", firstThrowable); - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch start = new CountDownLatch(1); - Future[] futures = new Future[threadCount]; - try { - for (int i = 0; i < threadCount; i++) { - futures[i] = - executor.submit( - () -> { - await(start); - logCollector.addLogMessage("ERROR", "Message", throwableWithMethod("run", 10)); - return null; + @Test + void groupsConcurrentMessagesWithEquivalentThrowables() throws Exception { + // Given + int threadCount = 16; + LogCollector logCollector = new LogCollector(2); + Throwable firstThrowable = throwableWithMethod("run", 10); + logCollector.addLogMessage("ERROR", "Message", firstThrowable); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[threadCount]; + try { + for (int i = 0; i < threadCount; i++) { + futures[i] = executor.submit(() -> { + await(start); + logCollector.addLogMessage("ERROR", "Message", throwableWithMethod("run", 10)); + return null; }); - } + } + + // When + release(start); + await(futures); + } finally { + shutdown(executor); + } + + LogCollector.RawLogMessage logGroup = singleLogGroup(logCollector.drain()); - // When - release(start); - await(futures); - } finally { - shutdown(executor); + // Then + assertThat(logGroup.count).isEqualTo(threadCount + 1); + assertThat(logGroup.throwable).isSameAs(firstThrowable); } - LogCollector.RawLogMessage logGroup = singleLogGroup(logCollector.drain()); - - // Then - assertThat(logGroup.count).isEqualTo(threadCount + 1); - assertThat(logGroup.throwable).isSameAs(firstThrowable); - } - - @Test - void limitsConcurrentLogGroupsToCapacity() throws Exception { - // Given - int capacity = 3; - int threadCount = 16; - LogCollector logCollector = new LogCollector(capacity); - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch start = new CountDownLatch(1); - Future[] futures = new Future[threadCount]; - try { - for (int i = 0; i < threadCount; i++) { - String message = "Message " + i; - futures[i] = - executor.submit( - () -> { - await(start); - logCollector.addLogMessage("ERROR", message, null); - return null; + @Test + void limitsConcurrentLogGroupsToCapacity() throws Exception { + // Given + int capacity = 3; + int threadCount = 16; + LogCollector logCollector = new LogCollector(capacity); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[threadCount]; + try { + for (int i = 0; i < threadCount; i++) { + String message = "Message " + i; + futures[i] = executor.submit(() -> { + await(start); + logCollector.addLogMessage("ERROR", message, null); + return null; }); - } + } - // When - release(start); - await(futures); - } finally { - shutdown(executor); + // When + release(start); + await(futures); + } finally { + shutdown(executor); + } + + // Then + assertThat(logCollector.drain()).hasSize(capacity); } - // Then - assertThat(logCollector.drain()).hasSize(capacity); - } + private static Throwable throwableWithMethod(String methodName, int lineNumber) { + Throwable throwable = new IllegalStateException("ignored when grouping"); + throwable.setStackTrace( + new StackTraceElement[] {new StackTraceElement("Example", methodName, "Example.java", lineNumber)}); + return throwable; + } - private static Throwable throwableWithMethod(String methodName, int lineNumber) { - Throwable throwable = new IllegalStateException("ignored when grouping"); - throwable.setStackTrace( - new StackTraceElement[] { - new StackTraceElement("Example", methodName, "Example.java", lineNumber) - }); - return throwable; - } - - private static void await(CountDownLatch latch) { - try { - assertThat(latch.await(TIMEOUT_SECONDS, SECONDS)).isTrue(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new AssertionError(e); + private static void await(CountDownLatch latch) { + try { + assertThat(latch.await(TIMEOUT_SECONDS, SECONDS)).isTrue(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } } - } - private static void release(CountDownLatch latch) { - latch.countDown(); - } + private static void release(CountDownLatch latch) { + latch.countDown(); + } - private static T await(Future future) throws Exception { - return future.get(TIMEOUT_SECONDS, SECONDS); - } + private static T await(Future future) throws Exception { + return future.get(TIMEOUT_SECONDS, SECONDS); + } - private static void await(Future[] futures) throws Exception { - for (Future future : futures) { - await(future); + private static void await(Future[] futures) throws Exception { + for (Future future : futures) { + await(future); + } + } + + private static void shutdown(ExecutorService executor) throws InterruptedException { + executor.shutdownNow(); + assertThat(executor.awaitTermination(TIMEOUT_SECONDS, SECONDS)).isTrue(); + } + + private static LogCollector.RawLogMessage singleLogGroup(Collection logGroups) { + assertThat(logGroups).hasSize(1); + return logGroups.iterator().next(); + } + + private static void assertContainsLogGroup( + Collection logGroups, String message, int count) { + assertThat(logGroups) + .as("log group for message %s", message) + .filteredOn(candidate -> message.equals(candidate.message)) + .singleElement() + .satisfies(logGroup -> { + assertThat(logGroup.logLevel).isEqualTo("ERROR"); + assertThat(logGroup.count).isEqualTo(count); + assertThat(logGroup.throwable).isNull(); + }); } - } - - private static void shutdown(ExecutorService executor) throws InterruptedException { - executor.shutdownNow(); - assertThat(executor.awaitTermination(TIMEOUT_SECONDS, SECONDS)).isTrue(); - } - - private static LogCollector.RawLogMessage singleLogGroup( - Collection logGroups) { - assertThat(logGroups).hasSize(1); - return logGroups.iterator().next(); - } - - private static void assertContainsLogGroup( - Collection logGroups, String message, int count) { - assertThat(logGroups) - .as("log group for message %s", message) - .filteredOn(candidate -> message.equals(candidate.message)) - .singleElement() - .satisfies( - logGroup -> { - assertThat(logGroup.logLevel).isEqualTo("ERROR"); - assertThat(logGroup.count).isEqualTo(count); - assertThat(logGroup.throwable).isNull(); - }); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/OtelSpiCollectorTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/OtelSpiCollectorTest.java index f1ddf2f2dea..95dea60ff5f 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/OtelSpiCollectorTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/OtelSpiCollectorTest.java @@ -10,60 +10,56 @@ public class OtelSpiCollectorTest { - private final OtelSpiCollector collector = OtelSpiCollector.getInstance(); + private final OtelSpiCollector collector = OtelSpiCollector.getInstance(); - @BeforeEach - public void clearQueue() { - collector.drain(); - } + @BeforeEach + public void clearQueue() { + collector.drain(); + } - @Test - public void singletonReturnsSameInstance() { - assertSame(OtelSpiCollector.getInstance(), OtelSpiCollector.getInstance()); - } + @Test + public void singletonReturnsSameInstance() { + assertSame(OtelSpiCollector.getInstance(), OtelSpiCollector.getInstance()); + } - @Test - public void recordedMetricSurfacesOnDrain() { - collector.recordSpiDetected( - "io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider", "extensions_path"); + @Test + public void recordedMetricSurfacesOnDrain() { + collector.recordSpiDetected( + "io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider", "extensions_path"); - Iterator drained = collector.drain().iterator(); - assertTrue(drained.hasNext()); - OtelSpiCollector.OtelSpiMetric metric = drained.next(); - assertEquals("tracers", metric.namespace); - assertEquals("otel.spi.detected", metric.metricName); - assertEquals("count", metric.type); - assertTrue(metric.common); - assertEquals(1, metric.value); - assertTrue( - metric.tags.contains( - "spi_class:io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider")); - assertTrue(metric.tags.contains("source:extensions_path")); - } + Iterator drained = collector.drain().iterator(); + assertTrue(drained.hasNext()); + OtelSpiCollector.OtelSpiMetric metric = drained.next(); + assertEquals("tracers", metric.namespace); + assertEquals("otel.spi.detected", metric.metricName); + assertEquals("count", metric.type); + assertTrue(metric.common); + assertEquals(1, metric.value); + assertTrue(metric.tags.contains( + "spi_class:io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider")); + assertTrue(metric.tags.contains("source:extensions_path")); + } - @Test - public void multipleRecordsDrainAsDistinctMetrics() { - collector.recordSpiDetected( - "io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider", "app_classpath"); - collector.recordSpiDetected( - "io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider", "app_classpath"); - collector.recordSpiDetected( - "io.opentelemetry.sdk.autoconfigure.spi.ConfigurableSamplerProvider", "extensions_path"); + @Test + public void multipleRecordsDrainAsDistinctMetrics() { + collector.recordSpiDetected("io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider", "app_classpath"); + collector.recordSpiDetected("io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider", "app_classpath"); + collector.recordSpiDetected( + "io.opentelemetry.sdk.autoconfigure.spi.ConfigurableSamplerProvider", "extensions_path"); - assertEquals(3, collector.drain().size()); - } + assertEquals(3, collector.drain().size()); + } - @Test - public void drainWithoutRecordsReturnsEmpty() { - assertEquals(0, collector.drain().size()); - } + @Test + public void drainWithoutRecordsReturnsEmpty() { + assertEquals(0, collector.drain().size()); + } - @Test - public void drainClearsQueue() { - collector.recordSpiDetected( - "io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider", - "extensions_path"); - assertEquals(1, collector.drain().size()); - assertEquals(0, collector.drain().size()); - } + @Test + public void drainClearsQueue() { + collector.recordSpiDetected( + "io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider", "extensions_path"); + assertEquals(1, collector.drain().size()); + assertEquals(0, collector.drain().size()); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/OtlpTelemetryTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/OtlpTelemetryTest.java index 53877bd8dc6..d280b4f0d0f 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/OtlpTelemetryTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/OtlpTelemetryTest.java @@ -10,115 +10,115 @@ import org.junit.jupiter.api.Test; class OtlpTelemetryTest { - private final OtlpTelemetry collector = OtlpTelemetry.getInstance(); - - @BeforeEach - void drainStaleMetrics() { - collector.prepareMetrics(); - collector.drain(); - } - - @Test - void onTracesExportAttemptQueuesCountMetricWithTags() { - collector.onTracesExportAttempt(); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - - assertEquals(1, metrics.size()); - OtlpTelemetry.OtlpMetric metric = metrics.iterator().next(); - assertEquals("tracers", metric.namespace); - assertEquals("otel.traces_export_attempts", metric.metricName); - assertEquals("count", metric.type); - assertEquals(1L, metric.value); - assertEquals(2, metric.tags.size()); - assertTrue(metric.tags.contains("protocol:http")); - assertTrue(metric.tags.contains("encoding:protobuf")); - } - - @Test - void tracesExportMetricsUseExpectedNames() { - collector.onTracesExportAttempt(); - collector.onTracesExportAttempt(); - collector.onTracesExportComplete(true); - collector.onTracesExportComplete(false); - collector.prepareMetrics(); - - Map valuesByName = drainToMap(); - - assertEquals(3, valuesByName.size()); - assertEquals(2L, valuesByName.get("otel.traces_export_attempts")); - assertEquals(1L, valuesByName.get("otel.traces_export_successes")); - assertEquals(1L, valuesByName.get("otel.traces_export_failures")); - } - - @Test - void onMetricsExportAttemptQueuesCountMetricWithTags() { - collector.onMetricsExportAttempt(); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - - assertEquals(1, metrics.size()); - OtlpTelemetry.OtlpMetric metric = metrics.iterator().next(); - assertEquals("tracers", metric.namespace); - assertEquals("otel.metrics_export_attempts", metric.metricName); - assertEquals("count", metric.type); - assertEquals(1L, metric.value); - assertEquals(2, metric.tags.size()); - assertTrue(metric.tags.contains("protocol:http")); - assertTrue(metric.tags.contains("encoding:protobuf")); - } - - @Test - void metricsExportMetricsUseExpectedNames() { - collector.onMetricsExportAttempt(); - collector.onMetricsExportAttempt(); - collector.onMetricsExportComplete(true); - collector.onMetricsExportComplete(false); - collector.prepareMetrics(); - - Map valuesByName = drainToMap(); - - assertEquals(3, valuesByName.size()); - assertEquals(2L, valuesByName.get("otel.metrics_export_attempts")); - assertEquals(1L, valuesByName.get("otel.metrics_export_successes")); - assertEquals(1L, valuesByName.get("otel.metrics_export_failures")); - } - - @Test - void onLogRecordsSubmittedQueuesCountWithGivenValue() { - collector.onLogRecordsSubmitted(5); - collector.prepareMetrics(); - - Collection metrics = collector.drain(); - - assertEquals(1, metrics.size()); - OtlpTelemetry.OtlpMetric metric = metrics.iterator().next(); - assertEquals("otel.log_records", metric.metricName); - assertEquals(5L, metric.value); - assertTrue(metric.tags.contains("protocol:http")); - assertTrue(metric.tags.contains("encoding:protobuf")); - } - - @Test - void onLogRecordsSubmittedIgnoresNonPositiveCounts() { - collector.onLogRecordsSubmitted(0); - collector.onLogRecordsSubmitted(-1); - - assertTrue(collector.drain().isEmpty()); - } - - @Test - void drainReturnsEmptyCollectionWhenNoMetricsQueued() { - assertTrue(collector.drain().isEmpty()); - } - - private Map drainToMap() { - Map valuesByName = new HashMap<>(); - for (OtlpTelemetry.OtlpMetric metric : collector.drain()) { - valuesByName.put(metric.metricName, metric.value); + private final OtlpTelemetry collector = OtlpTelemetry.getInstance(); + + @BeforeEach + void drainStaleMetrics() { + collector.prepareMetrics(); + collector.drain(); + } + + @Test + void onTracesExportAttemptQueuesCountMetricWithTags() { + collector.onTracesExportAttempt(); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + + assertEquals(1, metrics.size()); + OtlpTelemetry.OtlpMetric metric = metrics.iterator().next(); + assertEquals("tracers", metric.namespace); + assertEquals("otel.traces_export_attempts", metric.metricName); + assertEquals("count", metric.type); + assertEquals(1L, metric.value); + assertEquals(2, metric.tags.size()); + assertTrue(metric.tags.contains("protocol:http")); + assertTrue(metric.tags.contains("encoding:protobuf")); + } + + @Test + void tracesExportMetricsUseExpectedNames() { + collector.onTracesExportAttempt(); + collector.onTracesExportAttempt(); + collector.onTracesExportComplete(true); + collector.onTracesExportComplete(false); + collector.prepareMetrics(); + + Map valuesByName = drainToMap(); + + assertEquals(3, valuesByName.size()); + assertEquals(2L, valuesByName.get("otel.traces_export_attempts")); + assertEquals(1L, valuesByName.get("otel.traces_export_successes")); + assertEquals(1L, valuesByName.get("otel.traces_export_failures")); + } + + @Test + void onMetricsExportAttemptQueuesCountMetricWithTags() { + collector.onMetricsExportAttempt(); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + + assertEquals(1, metrics.size()); + OtlpTelemetry.OtlpMetric metric = metrics.iterator().next(); + assertEquals("tracers", metric.namespace); + assertEquals("otel.metrics_export_attempts", metric.metricName); + assertEquals("count", metric.type); + assertEquals(1L, metric.value); + assertEquals(2, metric.tags.size()); + assertTrue(metric.tags.contains("protocol:http")); + assertTrue(metric.tags.contains("encoding:protobuf")); + } + + @Test + void metricsExportMetricsUseExpectedNames() { + collector.onMetricsExportAttempt(); + collector.onMetricsExportAttempt(); + collector.onMetricsExportComplete(true); + collector.onMetricsExportComplete(false); + collector.prepareMetrics(); + + Map valuesByName = drainToMap(); + + assertEquals(3, valuesByName.size()); + assertEquals(2L, valuesByName.get("otel.metrics_export_attempts")); + assertEquals(1L, valuesByName.get("otel.metrics_export_successes")); + assertEquals(1L, valuesByName.get("otel.metrics_export_failures")); + } + + @Test + void onLogRecordsSubmittedQueuesCountWithGivenValue() { + collector.onLogRecordsSubmitted(5); + collector.prepareMetrics(); + + Collection metrics = collector.drain(); + + assertEquals(1, metrics.size()); + OtlpTelemetry.OtlpMetric metric = metrics.iterator().next(); + assertEquals("otel.log_records", metric.metricName); + assertEquals(5L, metric.value); + assertTrue(metric.tags.contains("protocol:http")); + assertTrue(metric.tags.contains("encoding:protobuf")); + } + + @Test + void onLogRecordsSubmittedIgnoresNonPositiveCounts() { + collector.onLogRecordsSubmitted(0); + collector.onLogRecordsSubmitted(-1); + + assertTrue(collector.drain().isEmpty()); + } + + @Test + void drainReturnsEmptyCollectionWhenNoMetricsQueued() { + assertTrue(collector.drain().isEmpty()); + } + + private Map drainToMap() { + Map valuesByName = new HashMap<>(); + for (OtlpTelemetry.OtlpMetric metric : collector.drain()) { + valuesByName.put(metric.metricName, metric.value); + } + return valuesByName; } - return valuesByName; - } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistryTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistryTest.java index 3d022cc9144..474f2281889 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistryTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/ScaReachabilityDependencyRegistryTest.java @@ -18,266 +18,261 @@ class ScaReachabilityDependencyRegistryTest { - @BeforeEach - void setUp() { - ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); - } - - @AfterEach - void tearDown() { - ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); - } - - /** - * Regression test for the first-hit-wins thread-safety race. - * - *

Before the fix (volatile + check-then-set), two threads calling {@code recordHit} for the - * same CVE from different methods could both observe {@code hit == null} simultaneously and both - * write, with the second overwriting the first. The fix uses {@link - * java.util.concurrent.atomic.AtomicReference#compareAndSet} to guarantee exactly one thread - * wins. - * - *

This test starts N threads simultaneously, each recording a hit for the same CVE but from a - * different callsite. After all threads complete, exactly one callsite must be recorded. - */ - @Test - void recordHit_concurrentCallsForSameCve_exactlyOneCallsiteStored() throws InterruptedException { - int threadCount = 20; - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-test"); - - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(threadCount); - ExecutorService pool = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - final int idx = i; - pool.submit( - () -> { - try { - startLatch.await(); // wait until all threads are ready - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", - "1.0.0", - "GHSA-test", - "com.myapp.Controller" + idx, - "method" + idx, - idx); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); + @BeforeEach + void setUp() { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + } + + @AfterEach + void tearDown() { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + } + + /** + * Regression test for the first-hit-wins thread-safety race. + * + *

Before the fix (volatile + check-then-set), two threads calling {@code recordHit} for the + * same CVE from different methods could both observe {@code hit == null} simultaneously and both + * write, with the second overwriting the first. The fix uses {@link + * java.util.concurrent.atomic.AtomicReference#compareAndSet} to guarantee exactly one thread + * wins. + * + *

This test starts N threads simultaneously, each recording a hit for the same CVE but from a + * different callsite. After all threads complete, exactly one callsite must be recorded. + */ + @Test + void recordHit_concurrentCallsForSameCve_exactlyOneCallsiteStored() throws InterruptedException { + int threadCount = 20; + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-test"); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int idx = i; + pool.submit(() -> { + try { + startLatch.await(); // wait until all threads are ready + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-test", "com.myapp.Controller" + idx, "method" + idx, idx); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); // release all threads simultaneously + doneLatch.await(10, TimeUnit.SECONDS); + pool.shutdown(); + + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + + assertEquals(1, snapshots.size(), "exactly one dep snapshot"); + ScaReachabilityDependencyRegistry.DependencySnapshot dep = snapshots.get(0); + assertEquals(1, dep.cves.size(), "exactly one CVE"); + + ScaReachabilityDependencyRegistry.CveSnapshot cve = dep.cves.get(0); + assertNotNull(cve.hit, "exactly one hit must have been recorded"); + + // Verify the recorded callsite is one of the N valid options + String recordedClass = cve.hit.className(); + String recordedSymbol = cve.hit.symbolName(); + boolean isValidCallsite = false; + for (int i = 0; i < threadCount; i++) { + if (("com.myapp.Controller" + i).equals(recordedClass) && ("method" + i).equals(recordedSymbol)) { + isValidCallsite = true; + break; } - }); + } + assertTrue(isValidCallsite, "recorded callsite must be one of the " + threadCount + " valid options"); } - startLatch.countDown(); // release all threads simultaneously - doneLatch.await(10, TimeUnit.SECONDS); - pool.shutdown(); + @Test + void registerCve_addsEntryAndMarksPending() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "2.0.0", "GHSA-0001"); - List snapshots = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertEquals(1, snapshots.size(), "exactly one dep snapshot"); - ScaReachabilityDependencyRegistry.DependencySnapshot dep = snapshots.get(0); - assertEquals(1, dep.cves.size(), "exactly one CVE"); - - ScaReachabilityDependencyRegistry.CveSnapshot cve = dep.cves.get(0); - assertNotNull(cve.hit, "exactly one hit must have been recorded"); - - // Verify the recorded callsite is one of the N valid options - String recordedClass = cve.hit.className(); - String recordedSymbol = cve.hit.symbolName(); - boolean isValidCallsite = false; - for (int i = 0; i < threadCount; i++) { - if (("com.myapp.Controller" + i).equals(recordedClass) - && ("method" + i).equals(recordedSymbol)) { - isValidCallsite = true; - break; - } + assertEquals(1, snapshots.size()); + ScaReachabilityDependencyRegistry.DependencySnapshot dep = snapshots.get(0); + assertEquals("com.example:lib", dep.artifact); + assertEquals("2.0.0", dep.version); + assertEquals(1, dep.cves.size()); + assertEquals("GHSA-0001", dep.cves.get(0).vulnId); + assertNull(dep.cves.get(0).hit, "class-load registration has no callsite yet"); } - assertTrue( - isValidCallsite, "recorded callsite must be one of the " + threadCount + " valid options"); - } - @Test - void registerCve_addsEntryAndMarksPending() { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "2.0.0", "GHSA-0001"); + @Test + void recordHit_snapshotContainsFullHitMetadata() { + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Ctrl", "handle", 42); + + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + + assertEquals(1, snapshots.size()); + ScaReachabilityDependencyRegistry.DependencySnapshot dep = snapshots.get(0); + assertEquals("com.example:lib", dep.artifact); + assertEquals("2.0.0", dep.version); + assertEquals(1, dep.cves.size()); + + ScaReachabilityDependencyRegistry.CveSnapshot cve = dep.cves.get(0); + assertEquals("GHSA-0001", cve.vulnId); + assertNotNull(cve.hit); + assertEquals("GHSA-0001", cve.hit.vulnId()); + assertEquals("com.example:lib", cve.hit.artifact()); + assertEquals("2.0.0", cve.hit.version()); + assertEquals("com.myapp.Ctrl", cve.hit.className()); + assertEquals("handle", cve.hit.symbolName()); + assertEquals(42, cve.hit.line()); + } - List snapshots = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + @Test + void peekSnapshot_returnsCurrentStateWithoutClearingPendingFlag() { + assertNull(ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot("missing", "1.0.0")); - assertEquals(1, snapshots.size()); - ScaReachabilityDependencyRegistry.DependencySnapshot dep = snapshots.get(0); - assertEquals("com.example:lib", dep.artifact); - assertEquals("2.0.0", dep.version); - assertEquals(1, dep.cves.size()); - assertEquals("GHSA-0001", dep.cves.get(0).vulnId); - assertNull(dep.cves.get(0).hit, "class-load registration has no callsite yet"); - } - - @Test - void recordHit_snapshotContainsFullHitMetadata() { - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Ctrl", "handle", 42); - - List snapshots = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "2.0.0", "GHSA-0001"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Ctrl", "handle", 42); - assertEquals(1, snapshots.size()); - ScaReachabilityDependencyRegistry.DependencySnapshot dep = snapshots.get(0); - assertEquals("com.example:lib", dep.artifact); - assertEquals("2.0.0", dep.version); - assertEquals(1, dep.cves.size()); - - ScaReachabilityDependencyRegistry.CveSnapshot cve = dep.cves.get(0); - assertEquals("GHSA-0001", cve.vulnId); - assertNotNull(cve.hit); - assertEquals("GHSA-0001", cve.hit.vulnId()); - assertEquals("com.example:lib", cve.hit.artifact()); - assertEquals("2.0.0", cve.hit.version()); - assertEquals("com.myapp.Ctrl", cve.hit.className()); - assertEquals("handle", cve.hit.symbolName()); - assertEquals(42, cve.hit.line()); - } - - @Test - void peekSnapshot_returnsCurrentStateWithoutClearingPendingFlag() { - assertNull(ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot("missing", "1.0.0")); - - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "2.0.0", "GHSA-0001"); - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Ctrl", "handle", 42); - - ScaReachabilityDependencyRegistry.DependencySnapshot peeked = - ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot("com.example:lib", "2.0.0"); - - assertNotNull(peeked); - assertEquals("com.example:lib", peeked.artifact); - assertEquals("2.0.0", peeked.version); - assertEquals(1, peeked.cves.size()); - assertEquals("GHSA-0001", peeked.cves.get(0).vulnId); - assertNotNull(peeked.cves.get(0).hit); - - List snapshots = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertEquals(1, snapshots.size(), "peek must not clear pending state"); - } + ScaReachabilityDependencyRegistry.DependencySnapshot peeked = + ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot("com.example:lib", "2.0.0"); - @Test - void drainPendingDependencies_secondDrainEmpty_untilNewHit() { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "2.0.0", "GHSA-0001"); + assertNotNull(peeked); + assertEquals("com.example:lib", peeked.artifact); + assertEquals("2.0.0", peeked.version); + assertEquals(1, peeked.cves.size()); + assertEquals("GHSA-0001", peeked.cves.get(0).vulnId); + assertNotNull(peeked.cves.get(0).hit); - // First drain returns the pending dep - List first = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertEquals(1, first.size()); + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertEquals(1, snapshots.size(), "peek must not clear pending state"); + } - // Second drain with no new state change returns empty - List second = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertTrue(second.isEmpty(), "no pending changes since last drain"); + @Test + void drainPendingDependencies_secondDrainEmpty_untilNewHit() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "2.0.0", "GHSA-0001"); + + // First drain returns the pending dep + List first = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertEquals(1, first.size()); + + // Second drain with no new state change returns empty + List second = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertTrue(second.isEmpty(), "no pending changes since last drain"); + + // A new hit marks the dep pending again + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Ctrl", "handle", 42); + List third = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertEquals(1, third.size(), "dep must be pending again after a hit"); + assertNotNull(third.get(0).cves.get(0).hit, "hit callsite must be recorded"); + } - // A new hit marks the dep pending again - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Ctrl", "handle", 42); - List third = + @Test + void recordHit_firstHitWinsAndDuplicateDoesNotMarkPendingAgain() { + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.First", "first", 1); ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertEquals(1, third.size(), "dep must be pending again after a hit"); - assertNotNull(third.get(0).cves.get(0).hit, "hit callsite must be recorded"); - } - - @Test - void recordHit_firstHitWinsAndDuplicateDoesNotMarkPendingAgain() { - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.First", "first", 1); - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Second", "second", 2); - - assertTrue( - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies().isEmpty(), - "duplicate hit for the same CVE must not mark the dependency pending"); - - ScaReachabilityDependencyRegistry.DependencySnapshot snapshot = - ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot("com.example:lib", "2.0.0"); - assertNotNull(snapshot); - assertEquals("com.myapp.First", snapshot.cves.get(0).hit.className()); - assertEquals("first", snapshot.cves.get(0).hit.symbolName()); - } - - @Test - void registerCve_atCap_newKeysRejected() { - int cap = Config.get().getAppSecScaMaxTrackedDependencies(); - - // Fill registry to cap - for (int i = 0; i < cap; i++) { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art" + i, "1.0", "GHSA-" + i); + + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "2.0.0", "GHSA-0001", "com.myapp.Second", "second", 2); + + assertTrue( + ScaReachabilityDependencyRegistry.INSTANCE + .drainPendingDependencies() + .isEmpty(), + "duplicate hit for the same CVE must not mark the dependency pending"); + + ScaReachabilityDependencyRegistry.DependencySnapshot snapshot = + ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot("com.example:lib", "2.0.0"); + assertNotNull(snapshot); + assertEquals("com.myapp.First", snapshot.cves.get(0).hit.className()); + assertEquals("first", snapshot.cves.get(0).hit.symbolName()); } - // One more unique key — must be rejected - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art-over-cap", "1.0", "GHSA-over"); + @Test + void registerCve_atCap_newKeysRejected() { + int cap = Config.get().getAppSecScaMaxTrackedDependencies(); - List snapshots = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertEquals(cap, snapshots.size(), "registry must not exceed cap"); - boolean found = snapshots.stream().anyMatch(s -> s.artifact.equals("art-over-cap")); - assertFalse(found, "over-cap dep must be rejected"); - } - - @Test - void registerCve_atCap_existingKeyStillUpdated() { - int cap = Config.get().getAppSecScaMaxTrackedDependencies(); - - // Fill registry to cap - for (int i = 0; i < cap; i++) { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art" + i, "1.0", "GHSA-" + i); + // Fill registry to cap + for (int i = 0; i < cap; i++) { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art" + i, "1.0", "GHSA-" + i); + } + + // One more unique key — must be rejected + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art-over-cap", "1.0", "GHSA-over"); + + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertEquals(cap, snapshots.size(), "registry must not exceed cap"); + boolean found = snapshots.stream().anyMatch(s -> s.artifact.equals("art-over-cap")); + assertFalse(found, "over-cap dep must be rejected"); } - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - // Adding a NEW CVE to an EXISTING key must still succeed (key already present, cap not - // exceeded) - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art0", "1.0", "GHSA-second-cve"); + @Test + void registerCve_atCap_existingKeyStillUpdated() { + int cap = Config.get().getAppSecScaMaxTrackedDependencies(); - List snapshots = + // Fill registry to cap + for (int i = 0; i < cap; i++) { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art" + i, "1.0", "GHSA-" + i); + } ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertEquals(1, snapshots.size(), "only the updated dep must be pending"); - assertEquals(2, snapshots.get(0).cves.size(), "both CVEs must be present"); - } - @Test - void recordHit_atCap_newKeysRejectedButExistingKeyStillUpdated() { - int cap = Config.get().getAppSecScaMaxTrackedDependencies(); + // Adding a NEW CVE to an EXISTING key must still succeed (key already present, cap not + // exceeded) + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art0", "1.0", "GHSA-second-cve"); - for (int i = 0; i < cap; i++) { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art" + i, "1.0", "GHSA-" + i); + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertEquals(1, snapshots.size(), "only the updated dep must be pending"); + assertEquals(2, snapshots.get(0).cves.size(), "both CVEs must be present"); } - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "art-over-cap", "1.0", "GHSA-over", "com.myapp.Ctrl", "handle", 42); - assertTrue( - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies().isEmpty(), - "over-cap hit for a new dependency must be rejected"); + @Test + void recordHit_atCap_newKeysRejectedButExistingKeyStillUpdated() { + int cap = Config.get().getAppSecScaMaxTrackedDependencies(); - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "art0", "1.0", "GHSA-0", "com.myapp.Ctrl", "handle", 42); - List snapshots = + for (int i = 0; i < cap; i++) { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("art" + i, "1.0", "GHSA-" + i); + } ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - assertEquals(1, snapshots.size(), "existing dependency can still be updated at cap"); - assertEquals("art0", snapshots.get(0).artifact); - assertNotNull(snapshots.get(0).cves.get(0).hit); - } - - @Test - void resetForTesting_clearsPeriodicWorkCallback() { - ScaReachabilityDependencyRegistry.INSTANCE.setPeriodicWorkCallback(() -> {}); - assertNotNull(ScaReachabilityDependencyRegistry.INSTANCE.getPeriodicWorkCallback()); - - ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); - - assertNull( - ScaReachabilityDependencyRegistry.INSTANCE.getPeriodicWorkCallback(), - "resetForTesting must clear periodicWorkCallback"); - } + + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "art-over-cap", "1.0", "GHSA-over", "com.myapp.Ctrl", "handle", 42); + assertTrue( + ScaReachabilityDependencyRegistry.INSTANCE + .drainPendingDependencies() + .isEmpty(), + "over-cap hit for a new dependency must be rejected"); + + ScaReachabilityDependencyRegistry.INSTANCE.recordHit("art0", "1.0", "GHSA-0", "com.myapp.Ctrl", "handle", 42); + List snapshots = + ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + assertEquals(1, snapshots.size(), "existing dependency can still be updated at cap"); + assertEquals("art0", snapshots.get(0).artifact); + assertNotNull(snapshots.get(0).cves.get(0).hit); + } + + @Test + void resetForTesting_clearsPeriodicWorkCallback() { + ScaReachabilityDependencyRegistry.INSTANCE.setPeriodicWorkCallback(() -> {}); + assertNotNull(ScaReachabilityDependencyRegistry.INSTANCE.getPeriodicWorkCallback()); + + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + + assertNull( + ScaReachabilityDependencyRegistry.INSTANCE.getPeriodicWorkCallback(), + "resetForTesting must clear periodicWorkCallback"); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/telemetry/WafMetricCollectorContextClosedRaceTest.java b/internal-api/src/test/java/datadog/trace/api/telemetry/WafMetricCollectorContextClosedRaceTest.java index c1b8a06a99c..e3c3124a1d3 100644 --- a/internal-api/src/test/java/datadog/trace/api/telemetry/WafMetricCollectorContextClosedRaceTest.java +++ b/internal-api/src/test/java/datadog/trace/api/telemetry/WafMetricCollectorContextClosedRaceTest.java @@ -9,21 +9,22 @@ class WafMetricCollectorContextClosedRaceTest { - @Test - void reportsContextClosedRaceCount() { - WafMetricCollector collector = WafMetricCollector.get(); + @Test + void reportsContextClosedRaceCount() { + WafMetricCollector collector = WafMetricCollector.get(); - collector.wafContextClosedRace(); - collector.wafContextClosedRace(); - collector.prepareMetrics(); + collector.wafContextClosedRace(); + collector.wafContextClosedRace(); + collector.prepareMetrics(); - Collection metrics = collector.drain(); - Optional raceMetric = - metrics.stream().filter(m -> "waf.context_closed_race".equals(m.metricName)).findFirst(); + Collection metrics = collector.drain(); + Optional raceMetric = metrics.stream() + .filter(m -> "waf.context_closed_race".equals(m.metricName)) + .findFirst(); - assertTrue(raceMetric.isPresent(), "expected waf.context_closed_race to be reported"); - assertEquals("count", raceMetric.get().type); - assertEquals("appsec", raceMetric.get().namespace); - assertEquals(2L, raceMetric.get().value.longValue()); - } + assertTrue(raceMetric.isPresent(), "expected waf.context_closed_race to be reported"); + assertEquals("count", raceMetric.get().type); + assertEquals("appsec", raceMetric.get().namespace); + assertEquals(2L, raceMetric.get().value.longValue()); + } } diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpanTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpanTest.java index 852e0142ba2..70a52b5d6c0 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpanTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ExtractedSpanTest.java @@ -17,75 +17,76 @@ class ExtractedSpanTest { - @Test - void extractedSpanFromPartialTracingContext() { - Map tagValues = new HashMap<>(); - tagValues.put("tag-1", "value-1"); - tagValues.put("tag-2", "value-2"); - TagMap tags = TagMap.fromMap(tagValues); - Map baggage = new HashMap<>(); - baggage.put("baggage-1", "value-1"); - baggage.put("baggage-2", "value-2"); - DDTraceId traceId = DDTraceId.from(12345); - TagContext context = new TagContext("origin", tags, null, baggage, 0, null, null, traceId); - ExtractedSpan extractedSpan = new ExtractedSpan(context); + @Test + void extractedSpanFromPartialTracingContext() { + Map tagValues = new HashMap<>(); + tagValues.put("tag-1", "value-1"); + tagValues.put("tag-2", "value-2"); + TagMap tags = TagMap.fromMap(tagValues); + Map baggage = new HashMap<>(); + baggage.put("baggage-1", "value-1"); + baggage.put("baggage-2", "value-2"); + DDTraceId traceId = DDTraceId.from(12345); + TagContext context = new TagContext("origin", tags, null, baggage, 0, null, null, traceId); + ExtractedSpan extractedSpan = new ExtractedSpan(context); - assertEquals(traceId, extractedSpan.getTraceId()); - assertEquals(context.getSpanId(), extractedSpan.getSpanId()); - assertEquals(context, extractedSpan.spanContext()); - assertEquals(tags, extractedSpan.getTags()); - assertEquals("value-1", extractedSpan.getTag("tag-1")); - assertEquals("value-2", extractedSpan.getBaggageItem("baggage-2")); - assertTrue(extractedSpan.isSameTrace(new ExtractedSpan(context))); - assertNotNull(extractedSpan.toString()); + assertEquals(traceId, extractedSpan.getTraceId()); + assertEquals(context.getSpanId(), extractedSpan.getSpanId()); + assertEquals(context, extractedSpan.spanContext()); + assertEquals(tags, extractedSpan.getTags()); + assertEquals("value-1", extractedSpan.getTag("tag-1")); + assertEquals("value-2", extractedSpan.getBaggageItem("baggage-2")); + assertTrue(extractedSpan.isSameTrace(new ExtractedSpan(context))); + assertNotNull(extractedSpan.toString()); - extractedSpan.setTag("tag-1", "updated"); - extractedSpan.setBaggageItem("baggage-2", "updated"); + extractedSpan.setTag("tag-1", "updated"); + extractedSpan.setBaggageItem("baggage-2", "updated"); - assertEquals("value-1", extractedSpan.getTag("tag-1")); - assertEquals("value-2", extractedSpan.getBaggageItem("baggage-2")); - } + assertEquals("value-1", extractedSpan.getTag("tag-1")); + assertEquals("value-2", extractedSpan.getBaggageItem("baggage-2")); + } - @Test - void extractedSpanFromCustomSpanContext() { - AgentSpanContext context = mock(AgentSpanContext.class); - when(context.getTraceId()).thenReturn(DDTraceId.from(12345)); - when(context.getSpanId()).thenReturn(67890L); - when(context.baggageItems()).thenReturn(Collections.emptyMap().entrySet()); - ExtractedSpan extractedSpan = new ExtractedSpan(context); + @Test + void extractedSpanFromCustomSpanContext() { + AgentSpanContext context = mock(AgentSpanContext.class); + when(context.getTraceId()).thenReturn(DDTraceId.from(12345)); + when(context.getSpanId()).thenReturn(67890L); + when(context.baggageItems()) + .thenReturn(Collections.emptyMap().entrySet()); + ExtractedSpan extractedSpan = new ExtractedSpan(context); - assertEquals(context.getTraceId(), extractedSpan.getTraceId()); - assertEquals(context.getSpanId(), extractedSpan.getSpanId()); - assertEquals(context, extractedSpan.spanContext()); - assertTrue(extractedSpan.getTags().isEmpty()); - assertNull(extractedSpan.getTag("tag-1")); - assertNull(extractedSpan.getBaggageItem("baggage-2")); - assertTrue(extractedSpan.isSameTrace(new ExtractedSpan(context))); - assertNotNull(extractedSpan.toString()); - } + assertEquals(context.getTraceId(), extractedSpan.getTraceId()); + assertEquals(context.getSpanId(), extractedSpan.getSpanId()); + assertEquals(context, extractedSpan.spanContext()); + assertTrue(extractedSpan.getTags().isEmpty()); + assertNull(extractedSpan.getTag("tag-1")); + assertNull(extractedSpan.getBaggageItem("baggage-2")); + assertTrue(extractedSpan.isSameTrace(new ExtractedSpan(context))); + assertNotNull(extractedSpan.toString()); + } - @Test - void traceConfigReturnsExtractedSnapshotWhenPresent() { - TraceConfig snapshot = mock(TraceConfig.class); - TagContext context = new TagContext(null, null, null, null, 0, snapshot, null, DDTraceId.ZERO); - ExtractedSpan extractedSpan = new ExtractedSpan(context); + @Test + void traceConfigReturnsExtractedSnapshotWhenPresent() { + TraceConfig snapshot = mock(TraceConfig.class); + TagContext context = new TagContext(null, null, null, null, 0, snapshot, null, DDTraceId.ZERO); + ExtractedSpan extractedSpan = new ExtractedSpan(context); - assertEquals(snapshot, extractedSpan.traceConfig()); - } + assertEquals(snapshot, extractedSpan.traceConfig()); + } - @Test - void traceConfigFallsBackToCurrentConfigWhenSnapshotAbsent() { - TagContext context = new TagContext(); - ExtractedSpan extractedSpan = new ExtractedSpan(context); + @Test + void traceConfigFallsBackToCurrentConfigWhenSnapshotAbsent() { + TagContext context = new TagContext(); + ExtractedSpan extractedSpan = new ExtractedSpan(context); - assertNotNull(extractedSpan.traceConfig()); - } + assertNotNull(extractedSpan.traceConfig()); + } - @Test - void traceConfigFallsBackToCurrentConfigForCustomSpanContext() { - AgentSpanContext context = mock(AgentSpanContext.class); - ExtractedSpan extractedSpan = new ExtractedSpan(context); + @Test + void traceConfigFallsBackToCurrentConfigForCustomSpanContext() { + AgentSpanContext context = mock(AgentSpanContext.class); + ExtractedSpan extractedSpan = new ExtractedSpan(context); - assertNotNull(extractedSpan.traceConfig()); - } + assertNotNull(extractedSpan.traceConfig()); + } } diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/KeyClassifierTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/KeyClassifierTest.java index 35e085a7892..35fb0b9ee3c 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/KeyClassifierTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/KeyClassifierTest.java @@ -10,69 +10,62 @@ class KeyClassifierTest { - static class RecordingClassifier implements AgentPropagation.KeyClassifier { - String lastKey; - String lastValue; - boolean returnValue; - - RecordingClassifier(boolean returnValue) { - this.returnValue = returnValue; + static class RecordingClassifier implements AgentPropagation.KeyClassifier { + String lastKey; + String lastValue; + boolean returnValue; + + RecordingClassifier(boolean returnValue) { + this.returnValue = returnValue; + } + + @Override + public boolean accept(String key, String value) { + lastKey = key; + lastValue = value; + return returnValue; + } } - @Override - public boolean accept(String key, String value) { - lastKey = key; - lastValue = value; - return returnValue; + @Test + void defaultTransformerMethodAppliesTransformerAndDelegates() { + RecordingClassifier classifier = new RecordingClassifier(true); + + boolean result = classifier.accept( + "my-key", "raw".getBytes(StandardCharsets.UTF_8), bytes -> new String(bytes, StandardCharsets.UTF_8)); + + assertEquals("my-key", classifier.lastKey); + assertEquals("raw", classifier.lastValue); + assertTrue(result); } - } - - @Test - void defaultTransformerMethodAppliesTransformerAndDelegates() { - RecordingClassifier classifier = new RecordingClassifier(true); - - boolean result = - classifier.accept( - "my-key", - "raw".getBytes(StandardCharsets.UTF_8), - bytes -> new String(bytes, StandardCharsets.UTF_8)); - - assertEquals("my-key", classifier.lastKey); - assertEquals("raw", classifier.lastValue); - assertTrue(result); - } - - @Test - void transformerIsCalledExactlyOnce() { - AtomicInteger callCount = new AtomicInteger(0); - AtomicReference transformed = new AtomicReference<>(); - - AgentPropagation.KeyClassifier classifier = - (key, value) -> { - transformed.set(value); - return true; + + @Test + void transformerIsCalledExactlyOnce() { + AtomicInteger callCount = new AtomicInteger(0); + AtomicReference transformed = new AtomicReference<>(); + + AgentPropagation.KeyClassifier classifier = (key, value) -> { + transformed.set(value); + return true; }; - classifier.accept( - "key", - "input", - v -> { - callCount.incrementAndGet(); - return v.toUpperCase(); + classifier.accept("key", "input", v -> { + callCount.incrementAndGet(); + return v.toUpperCase(); }); - assertEquals(1, callCount.get()); - assertEquals("INPUT", transformed.get()); - } + assertEquals(1, callCount.get()); + assertEquals("INPUT", transformed.get()); + } - @Test - void existingAcceptStringStringContractUnchanged() { - RecordingClassifier classifier = new RecordingClassifier(true); + @Test + void existingAcceptStringStringContractUnchanged() { + RecordingClassifier classifier = new RecordingClassifier(true); - boolean result = classifier.accept("trace-id", "abc123"); + boolean result = classifier.accept("trace-id", "abc123"); - assertEquals("trace-id", classifier.lastKey); - assertEquals("abc123", classifier.lastValue); - assertTrue(result); - } + assertEquals("trace-id", classifier.lastKey); + assertEquals("abc123", classifier.lastValue); + assertTrue(result); + } } diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java index e01ba8e8591..e5a38ae1c3b 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototypeTest.java @@ -9,172 +9,172 @@ class SpanPrototypeTest { - @Test - void noneHasNoIdentityAndNoTags() { - assertNull(SpanPrototype.NONE.instrumentationName()); - assertNull(SpanPrototype.NONE.operationName()); - assertNull(SpanPrototype.NONE.spanType()); - assertNull(SpanPrototype.NONE.integrationName()); - assertTrue(SpanPrototype.NONE.tags().isEmpty()); - } - - @Test - void gettersReflectBuilderState() { - final SpanPrototype proto = - SpanPrototype.builder() - .initInstrumentationName("instr") - .initOperationName("op") - .initSpanType("web") - .initComponentAndIntegration("netty") // sets integration name + component tag - .build(); - - assertEquals("instr", proto.instrumentationName()); - assertEquals("op", proto.operationName()); - assertEquals("web", proto.spanType()); - assertEquals("netty", proto.integrationName()); - assertEquals("netty", proto.tags().getString(Tags.COMPONENT)); - } - - @Test - void initInstrumentationNamesTakesFirstElement() { - final SpanPrototype proto = - SpanPrototype.builder().initInstrumentationNames(new String[] {"first", "second"}).build(); - - assertEquals("first", proto.instrumentationName()); - } - - @Test - void initInstrumentationNamesNullArrayLeavesNameUnset() { - final SpanPrototype proto = SpanPrototype.builder().initInstrumentationNames(null).build(); - - assertNull(proto.instrumentationName()); - } - - @Test - void initInstrumentationNamesEmptyArrayLeavesNameUnset() { - final SpanPrototype proto = - SpanPrototype.builder().initInstrumentationNames(new String[0]).build(); - - assertNull(proto.instrumentationName()); - } - - @Test - void extendsNullBaseIsNoOp() { - final SpanPrototype proto = SpanPrototype.builder().extends_(null).build(); - - assertNull(proto.instrumentationName()); - assertNull(proto.operationName()); - assertNull(proto.spanType()); - assertNull(proto.integrationName()); - assertTrue(proto.tags().isEmpty()); - } - - @Test - void extendsCopiesAllIdentityAndTags() { - final SpanPrototype base = - SpanPrototype.builder() - .initInstrumentationName("base") - .initOperationName("base.op") - .initSpanType("base-type") - .initComponentAndIntegration("base-comp") // integration name + component tag - .initKind("server") - .build(); - final SpanPrototype derived = SpanPrototype.builder().extends_(base).build(); - - assertEquals("base", derived.instrumentationName()); - assertEquals("base.op", derived.operationName()); - assertEquals("base-type", derived.spanType()); - assertEquals("base-comp", derived.integrationName()); - assertEquals("base-comp", derived.tags().getString(Tags.COMPONENT)); - assertEquals("server", derived.tags().getString(Tags.SPAN_KIND)); - } - - @Test - void extendsInheritsBaseIdentityAndTagsThenOverrides() { - final SpanPrototype base = - SpanPrototype.builder() - .initInstrumentationName("base") - .initSpanType("base-type") - .initKind("server") - .build(); - final SpanPrototype derived = - SpanPrototype.builder() - .extends_(base) - .initComponentOnly("netty") - .initSpanType("http") - .build(); - - assertEquals("base", derived.instrumentationName()); // inherited - assertEquals("http", derived.spanType()); // overridden - assertEquals("server", derived.tags().getString(Tags.SPAN_KIND)); // inherited tag - assertEquals("netty", derived.tags().getString(Tags.COMPONENT)); // added tag - } - - @Test - void initComponentAndIntegrationSetsBothComponentTagAndIntegrationName() { - final SpanPrototype proto = - SpanPrototype.builder().initComponentAndIntegration("netty").build(); - - assertEquals("netty", proto.tags().getString(Tags.COMPONENT)); - assertEquals("netty", proto.integrationName()); - } - - @Test - void initComponentAndIntegrationEmptyIsNoOpForBoth() { - final SpanPrototype proto = SpanPrototype.builder().initComponentAndIntegration("").build(); - - assertNull(proto.tags().getString(Tags.COMPONENT)); - assertNull(proto.integrationName()); - } - - @Test - void initKindSetsSpanKindTag() { - final SpanPrototype proto = SpanPrototype.builder().initKind("client").build(); - - assertEquals("client", proto.tags().getString(Tags.SPAN_KIND)); - } - - @Test - void emptyOrNullConstantsAreDroppedNotBaked() { - // Match AgentSpan.setTag / the cached-Entry path: a null or empty constant is "no tag", not an - // empty tag. A raw tags.set would otherwise bake a tag that per-span stamping never emits. - final SpanPrototype proto = - SpanPrototype.builder() - .initComponentOnly("") // empty -> dropped - .initKind("") // empty -> dropped - .initTag("empty.cs", "") // empty CharSequence -> dropped - .initTag("null.cs", (CharSequence) null) // null -> dropped - .initTag("null.obj", (Object) null) // null -> dropped - .initTag("kept", "v") // non-empty -> present - .build(); - - assertNull(proto.tags().getString(Tags.COMPONENT)); - assertNull(proto.tags().getString(Tags.SPAN_KIND)); - assertNull(proto.tags().getString("empty.cs")); - assertNull(proto.tags().getString("null.cs")); - assertNull(proto.tags().getString("null.obj")); - assertEquals("v", proto.tags().getString("kept")); // sanity: non-empty still stored - } - - @Test - void initTagObjectStoresNonEmptyValue() { - final SpanPrototype proto = SpanPrototype.builder().initTag("count", (Object) 42).build(); - - assertEquals(42, proto.tags().get("count")); - } - - @Test - void initTagEntryReaderStoresEntry() { - final TagMap.EntryReader entry = TagMap.Entry.create("cached", "value"); - final SpanPrototype proto = SpanPrototype.builder().initTag(entry).build(); - - assertEquals("value", proto.tags().getString("cached")); - } - - @Test - void initTagNullEntryReaderIsNoOp() { - final SpanPrototype proto = SpanPrototype.builder().initTag((TagMap.EntryReader) null).build(); - - assertTrue(proto.tags().isEmpty()); - } + @Test + void noneHasNoIdentityAndNoTags() { + assertNull(SpanPrototype.NONE.instrumentationName()); + assertNull(SpanPrototype.NONE.operationName()); + assertNull(SpanPrototype.NONE.spanType()); + assertNull(SpanPrototype.NONE.integrationName()); + assertTrue(SpanPrototype.NONE.tags().isEmpty()); + } + + @Test + void gettersReflectBuilderState() { + final SpanPrototype proto = SpanPrototype.builder() + .initInstrumentationName("instr") + .initOperationName("op") + .initSpanType("web") + .initComponentAndIntegration("netty") // sets integration name + component tag + .build(); + + assertEquals("instr", proto.instrumentationName()); + assertEquals("op", proto.operationName()); + assertEquals("web", proto.spanType()); + assertEquals("netty", proto.integrationName()); + assertEquals("netty", proto.tags().getString(Tags.COMPONENT)); + } + + @Test + void initInstrumentationNamesTakesFirstElement() { + final SpanPrototype proto = SpanPrototype.builder() + .initInstrumentationNames(new String[] {"first", "second"}) + .build(); + + assertEquals("first", proto.instrumentationName()); + } + + @Test + void initInstrumentationNamesNullArrayLeavesNameUnset() { + final SpanPrototype proto = + SpanPrototype.builder().initInstrumentationNames(null).build(); + + assertNull(proto.instrumentationName()); + } + + @Test + void initInstrumentationNamesEmptyArrayLeavesNameUnset() { + final SpanPrototype proto = + SpanPrototype.builder().initInstrumentationNames(new String[0]).build(); + + assertNull(proto.instrumentationName()); + } + + @Test + void extendsNullBaseIsNoOp() { + final SpanPrototype proto = SpanPrototype.builder().extends_(null).build(); + + assertNull(proto.instrumentationName()); + assertNull(proto.operationName()); + assertNull(proto.spanType()); + assertNull(proto.integrationName()); + assertTrue(proto.tags().isEmpty()); + } + + @Test + void extendsCopiesAllIdentityAndTags() { + final SpanPrototype base = SpanPrototype.builder() + .initInstrumentationName("base") + .initOperationName("base.op") + .initSpanType("base-type") + .initComponentAndIntegration("base-comp") // integration name + component tag + .initKind("server") + .build(); + final SpanPrototype derived = SpanPrototype.builder().extends_(base).build(); + + assertEquals("base", derived.instrumentationName()); + assertEquals("base.op", derived.operationName()); + assertEquals("base-type", derived.spanType()); + assertEquals("base-comp", derived.integrationName()); + assertEquals("base-comp", derived.tags().getString(Tags.COMPONENT)); + assertEquals("server", derived.tags().getString(Tags.SPAN_KIND)); + } + + @Test + void extendsInheritsBaseIdentityAndTagsThenOverrides() { + final SpanPrototype base = SpanPrototype.builder() + .initInstrumentationName("base") + .initSpanType("base-type") + .initKind("server") + .build(); + final SpanPrototype derived = SpanPrototype.builder() + .extends_(base) + .initComponentOnly("netty") + .initSpanType("http") + .build(); + + assertEquals("base", derived.instrumentationName()); // inherited + assertEquals("http", derived.spanType()); // overridden + assertEquals("server", derived.tags().getString(Tags.SPAN_KIND)); // inherited tag + assertEquals("netty", derived.tags().getString(Tags.COMPONENT)); // added tag + } + + @Test + void initComponentAndIntegrationSetsBothComponentTagAndIntegrationName() { + final SpanPrototype proto = + SpanPrototype.builder().initComponentAndIntegration("netty").build(); + + assertEquals("netty", proto.tags().getString(Tags.COMPONENT)); + assertEquals("netty", proto.integrationName()); + } + + @Test + void initComponentAndIntegrationEmptyIsNoOpForBoth() { + final SpanPrototype proto = + SpanPrototype.builder().initComponentAndIntegration("").build(); + + assertNull(proto.tags().getString(Tags.COMPONENT)); + assertNull(proto.integrationName()); + } + + @Test + void initKindSetsSpanKindTag() { + final SpanPrototype proto = SpanPrototype.builder().initKind("client").build(); + + assertEquals("client", proto.tags().getString(Tags.SPAN_KIND)); + } + + @Test + void emptyOrNullConstantsAreDroppedNotBaked() { + // Match AgentSpan.setTag / the cached-Entry path: a null or empty constant is "no tag", not an + // empty tag. A raw tags.set would otherwise bake a tag that per-span stamping never emits. + final SpanPrototype proto = SpanPrototype.builder() + .initComponentOnly("") // empty -> dropped + .initKind("") // empty -> dropped + .initTag("empty.cs", "") // empty CharSequence -> dropped + .initTag("null.cs", (CharSequence) null) // null -> dropped + .initTag("null.obj", (Object) null) // null -> dropped + .initTag("kept", "v") // non-empty -> present + .build(); + + assertNull(proto.tags().getString(Tags.COMPONENT)); + assertNull(proto.tags().getString(Tags.SPAN_KIND)); + assertNull(proto.tags().getString("empty.cs")); + assertNull(proto.tags().getString("null.cs")); + assertNull(proto.tags().getString("null.obj")); + assertEquals("v", proto.tags().getString("kept")); // sanity: non-empty still stored + } + + @Test + void initTagObjectStoresNonEmptyValue() { + final SpanPrototype proto = + SpanPrototype.builder().initTag("count", (Object) 42).build(); + + assertEquals(42, proto.tags().get("count")); + } + + @Test + void initTagEntryReaderStoresEntry() { + final TagMap.EntryReader entry = TagMap.Entry.create("cached", "value"); + final SpanPrototype proto = SpanPrototype.builder().initTag(entry).build(); + + assertEquals("value", proto.tags().getString("cached")); + } + + @Test + void initTagNullEntryReaderIsNoOp() { + final SpanPrototype proto = + SpanPrototype.builder().initTag((TagMap.EntryReader) null).build(); + + assertTrue(proto.tags().isEmpty()); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java index 46963236366..b228b61c084 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD1Test.java @@ -18,491 +18,451 @@ class ConcurrentHashtableD1Test { - @Test - void getReturnsMappedEntry() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - StringEntry e = table.tryGetOrCreateOrNull("hello", k -> new StringEntry(k, 42)); - assertSame(e, table.get("hello")); - assertNull(table.get("world")); - } - - @Test - void getOrCreateOnMissBuildsEntry() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - int[] createCount = {0}; - StringEntry created = - table.tryGetOrCreateOrNull( - "a", - k -> { - createCount[0]++; - return new StringEntry(k, 1); - }); - assertNotNull(created); - assertEquals(1, table.size()); - assertEquals(1, createCount[0]); - assertSame(created, table.get("a")); - } - - @Test - void getOrCreateOnHitSkipsCreator() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - StringEntry seeded = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 100)); - int[] createCount = {0}; - StringEntry got = - table.tryGetOrCreateOrNull( - "a", - k -> { - createCount[0]++; - return new StringEntry(k, 999); - }); - assertSame(seeded, got); - assertEquals(1, table.size()); - assertEquals(0, createCount[0]); - } - - @Test - void nullKeyIsSupported() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - StringEntry e = table.tryGetOrCreateOrNull(null, k -> new StringEntry(k, 0)); - assertNotNull(e); - assertSame(e, table.get(null)); - } - - @Test - void forEachVisitsAllEntries() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); - table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); - Set seen = new HashSet<>(); - table.forEach(e -> seen.add(e.key())); - assertEquals(3, seen.size()); - assertTrue(seen.contains("a")); - assertTrue(seen.contains("b")); - assertTrue(seen.contains("c")); - } - - @Test - void forEachWithContextPassesContext() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - table.tryGetOrCreateOrNull("x", k -> new StringEntry(k, 10)); - table.tryGetOrCreateOrNull("y", k -> new StringEntry(k, 20)); - Set seen = new HashSet<>(); - table.forEach(seen, (ctx, e) -> ctx.add(e.key())); - assertEquals(2, seen.size()); - assertTrue(seen.contains("x")); - assertTrue(seen.contains("y")); - } - - @Test - void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - int threads = 16; - CountDownLatch ready = new CountDownLatch(threads); - CountDownLatch go = new CountDownLatch(1); - AtomicInteger createCount = new AtomicInteger(); - - Thread[] workers = new Thread[threads]; - for (int i = 0; i < threads; i++) { - workers[i] = - new Thread( - () -> { + @Test + void getReturnsMappedEntry() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry e = table.tryGetOrCreateOrNull("hello", k -> new StringEntry(k, 42)); + assertSame(e, table.get("hello")); + assertNull(table.get("world")); + } + + @Test + void getOrCreateOnMissBuildsEntry() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + int[] createCount = {0}; + StringEntry created = table.tryGetOrCreateOrNull("a", k -> { + createCount[0]++; + return new StringEntry(k, 1); + }); + assertNotNull(created); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("a")); + } + + @Test + void getOrCreateOnHitSkipsCreator() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry seeded = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 100)); + int[] createCount = {0}; + StringEntry got = table.tryGetOrCreateOrNull("a", k -> { + createCount[0]++; + return new StringEntry(k, 999); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); + } + + @Test + void nullKeyIsSupported() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry e = table.tryGetOrCreateOrNull(null, k -> new StringEntry(k, 0)); + assertNotNull(e); + assertSame(e, table.get(null)); + } + + @Test + void forEachVisitsAllEntries() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key())); + assertEquals(3, seen.size()); + assertTrue(seen.contains("a")); + assertTrue(seen.contains("b")); + assertTrue(seen.contains("c")); + } + + @Test + void forEachWithContextPassesContext() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("x", k -> new StringEntry(k, 10)); + table.tryGetOrCreateOrNull("y", k -> new StringEntry(k, 20)); + Set seen = new HashSet<>(); + table.forEach(seen, (ctx, e) -> ctx.add(e.key())); + assertEquals(2, seen.size()); + assertTrue(seen.contains("x")); + assertTrue(seen.contains("y")); + } + + @Test + void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + int threads = 16; + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger createCount = new AtomicInteger(); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + workers[i] = new Thread(() -> { ready.countDown(); try { - go.await(); + go.await(); } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return; + Thread.currentThread().interrupt(); + return; } - table.tryGetOrCreateOrNull( - "shared", - k -> { - createCount.incrementAndGet(); - return new StringEntry(k, 1); - }); - }); - workers[i].start(); - } - ready.await(); - go.countDown(); - for (Thread w : workers) { - w.join(); + table.tryGetOrCreateOrNull("shared", k -> { + createCount.incrementAndGet(); + return new StringEntry(k, 1); + }); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(1, table.size()); + assertEquals(1, createCount.get()); } - assertEquals(1, table.size()); - assertEquals(1, createCount.get()); - } - - @Test - void chainedEntriesInSameBucketAreAllReachable() { - // All three keys share hash 0, so they land in the same bucket regardless of table size. - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); - CollidingKey a = new CollidingKey("a", 0); - CollidingKey b = new CollidingKey("b", 0); // same bucket as a - CollidingKey c = new CollidingKey("c", 0); // same bucket - CollidingEntry ea = table.tryGetOrCreateOrNull(a, CollidingEntry::new); - CollidingEntry eb = table.tryGetOrCreateOrNull(b, CollidingEntry::new); - CollidingEntry ec = table.tryGetOrCreateOrNull(c, CollidingEntry::new); - assertEquals(3, table.size()); - assertSame(ea, table.get(a)); - assertSame(eb, table.get(b)); - assertSame(ec, table.get(c)); - assertNull(table.get(new CollidingKey("d", 0))); // same bucket, different label → miss - } - - @Test - void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException { - int threads = 16; - String[] keys = new String[threads]; - for (int i = 0; i < threads; i++) { - keys[i] = "key-" + i; + @Test + void chainedEntriesInSameBucketAreAllReachable() { + // All three keys share hash 0, so they land in the same bucket regardless of table size. + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); + CollidingKey a = new CollidingKey("a", 0); + CollidingKey b = new CollidingKey("b", 0); // same bucket as a + CollidingKey c = new CollidingKey("c", 0); // same bucket + CollidingEntry ea = table.tryGetOrCreateOrNull(a, CollidingEntry::new); + CollidingEntry eb = table.tryGetOrCreateOrNull(b, CollidingEntry::new); + CollidingEntry ec = table.tryGetOrCreateOrNull(c, CollidingEntry::new); + assertEquals(3, table.size()); + assertSame(ea, table.get(a)); + assertSame(eb, table.get(b)); + assertSame(ec, table.get(c)); + assertNull(table.get(new CollidingKey("d", 0))); // same bucket, different label → miss } - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, threads * 2); - CountDownLatch ready = new CountDownLatch(threads); - CountDownLatch go = new CountDownLatch(1); - - Thread[] workers = new Thread[threads]; - for (int i = 0; i < threads; i++) { - final String key = keys[i]; - workers[i] = - new Thread( - () -> { + + @Test + void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException { + int threads = 16; + String[] keys = new String[threads]; + for (int i = 0; i < threads; i++) { + keys[i] = "key-" + i; + } + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(StringEntry.class, threads * 2); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + final String key = keys[i]; + workers[i] = new Thread(() -> { ready.countDown(); try { - go.await(); + go.await(); } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return; + Thread.currentThread().interrupt(); + return; } table.tryGetOrCreateOrNull(key, k -> new StringEntry(k, 1)); - }); - workers[i].start(); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(threads, table.size()); + for (String key : keys) { + assertNotNull(table.get(key)); + } + } + + @Test + void removeReturnsEntryAndShrinks() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + assertSame(a, table.remove("a")); + assertEquals(1, table.size()); + assertNull(table.get("a")); + assertNotNull(table.get("b")); + } + + @Test + void removeAbsentKeyReturnsNull() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + assertNull(table.remove("missing")); + assertEquals(1, table.size()); } - ready.await(); - go.countDown(); - for (Thread w : workers) { - w.join(); + + @Test + void removeHeadMiddleAndTailOfSameBucketChain() { + // All three keys share hash 0, so a, b, c land in the same bucket and form one chain. + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); + CollidingKey a = new CollidingKey("a", 0); + CollidingKey b = new CollidingKey("b", 0); + CollidingKey c = new CollidingKey("c", 0); + table.tryGetOrCreateOrNull(a, CollidingEntry::new); + table.tryGetOrCreateOrNull(b, CollidingEntry::new); + table.tryGetOrCreateOrNull(c, CollidingEntry::new); + + // Remove a middle element; the other two stay reachable. + assertNotNull(table.remove(b)); + assertNull(table.get(b)); + assertNotNull(table.get(a)); + assertNotNull(table.get(c)); + assertEquals(2, table.size()); + + // Drain the rest. + assertNotNull(table.remove(c)); + assertNotNull(table.remove(a)); + assertEquals(0, table.size()); + assertNull(table.get(a)); } - assertEquals(threads, table.size()); - for (String key : keys) { - assertNotNull(table.get(key)); + @Test + void removeIfRemovesMatchingEntries() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 16); + for (int i = 0; i < 10; i++) { + final int v = i; + table.tryGetOrCreateOrNull("k" + i, k -> new StringEntry(k, v)); + } + boolean removed = table.removeIf(e -> e.value % 2 == 0); // removes values 0,2,4,6,8 + assertTrue(removed); + assertEquals(5, table.size()); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key())); + assertEquals(5, seen.size()); + for (String key : seen) { + assertNotNull(table.get(key)); + } } - } - - @Test - void removeReturnsEntryAndShrinks() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); - assertSame(a, table.remove("a")); - assertEquals(1, table.size()); - assertNull(table.get("a")); - assertNotNull(table.get("b")); - } - - @Test - void removeAbsentKeyReturnsNull() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - assertNull(table.remove("missing")); - assertEquals(1, table.size()); - } - - @Test - void removeHeadMiddleAndTailOfSameBucketChain() { - // All three keys share hash 0, so a, b, c land in the same bucket and form one chain. - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 8); - CollidingKey a = new CollidingKey("a", 0); - CollidingKey b = new CollidingKey("b", 0); - CollidingKey c = new CollidingKey("c", 0); - table.tryGetOrCreateOrNull(a, CollidingEntry::new); - table.tryGetOrCreateOrNull(b, CollidingEntry::new); - table.tryGetOrCreateOrNull(c, CollidingEntry::new); - - // Remove a middle element; the other two stay reachable. - assertNotNull(table.remove(b)); - assertNull(table.get(b)); - assertNotNull(table.get(a)); - assertNotNull(table.get(c)); - assertEquals(2, table.size()); - - // Drain the rest. - assertNotNull(table.remove(c)); - assertNotNull(table.remove(a)); - assertEquals(0, table.size()); - assertNull(table.get(a)); - } - - @Test - void removeIfRemovesMatchingEntries() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 16); - for (int i = 0; i < 10; i++) { - final int v = i; - table.tryGetOrCreateOrNull("k" + i, k -> new StringEntry(k, v)); + + @Test + void removeIfReturnsFalseWhenNothingMatches() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + assertFalse(table.removeIf(e -> false)); + assertEquals(1, table.size()); } - boolean removed = table.removeIf(e -> e.value % 2 == 0); // removes values 0,2,4,6,8 - assertTrue(removed); - assertEquals(5, table.size()); - Set seen = new HashSet<>(); - table.forEach(e -> seen.add(e.key())); - assertEquals(5, seen.size()); - for (String key : seen) { - assertNotNull(table.get(key)); + + @Test + void clearEmptiesTableAndLeavesItUsable() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a")); + assertNull(table.get("b")); + StringEntry c = table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); + assertSame(c, table.get("c")); + assertEquals(1, table.size()); } - } - - @Test - void removeIfReturnsFalseWhenNothingMatches() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - assertFalse(table.removeIf(e -> false)); - assertEquals(1, table.size()); - } - - @Test - void clearEmptiesTableAndLeavesItUsable() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); - table.clear(); - assertEquals(0, table.size()); - assertNull(table.get("a")); - assertNull(table.get("b")); - StringEntry c = table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); - assertSame(c, table.get("c")); - assertEquals(1, table.size()); - } - - @Test - void drainRemovesEveryEntryAndFeedsSink() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); - table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); - - Set drained = new HashSet<>(); - int[] sum = {0}; - table.drain( - e -> { - drained.add(e.key()); - sum[0] += e.value; + + @Test + void drainRemovesEveryEntryAndFeedsSink() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + table.tryGetOrCreateOrNull("c", k -> new StringEntry(k, 3)); + + Set drained = new HashSet<>(); + int[] sum = {0}; + table.drain(e -> { + drained.add(e.key()); + sum[0] += e.value; }); - assertEquals(new HashSet<>(Arrays.asList("a", "b", "c")), drained); - assertEquals(6, sum[0]); - assertEquals(0, table.size()); - assertNull(table.get("a")); - // table remains usable after drain - StringEntry d = table.tryGetOrCreateOrNull("d", k -> new StringEntry(k, 4)); - assertSame(d, table.get("d")); - assertEquals(1, table.size()); - } - - @Test - void drainWithContextFeedsSink() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); - - Set drained = new HashSet<>(); - table.drain(drained, (ctx, e) -> ctx.add(e.key())); - - assertEquals(new HashSet<>(Arrays.asList("a", "b")), drained); - assertEquals(0, table.size()); - } - - @Test - void drainOnEmptyTableInvokesSinkZeroTimes() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - int[] count = {0}; - table.drain(e -> count[0]++); - assertEquals(0, count[0]); - assertEquals(0, table.size()); - } - - /** - * Exercises the volatile-{@code next} removal contract: while one key is repeatedly removed and - * re-added in a shared collision chain, the other keys in that chain must remain continuously - * visible to a concurrent lock-free reader. - */ - @Test - void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedException { - // All keys share hash 0, putting every key in one bucket so removal splices a chain the - // reader is walking. - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 16); - int n = 8; - CollidingKey[] keys = new CollidingKey[n]; - for (int i = 0; i < n; i++) { - keys[i] = new CollidingKey("k" + i, 0); - table.tryGetOrCreateOrNull(keys[i], CollidingEntry::new); + assertEquals(new HashSet<>(Arrays.asList("a", "b", "c")), drained); + assertEquals(6, sum[0]); + assertEquals(0, table.size()); + assertNull(table.get("a")); + // table remains usable after drain + StringEntry d = table.tryGetOrCreateOrNull("d", k -> new StringEntry(k, 4)); + assertSame(d, table.get("d")); + assertEquals(1, table.size()); + } + + @Test + void drainWithContextFeedsSink() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + table.tryGetOrCreateOrNull("b", k -> new StringEntry(k, 2)); + + Set drained = new HashSet<>(); + table.drain(drained, (ctx, e) -> ctx.add(e.key())); + + assertEquals(new HashSet<>(Arrays.asList("a", "b")), drained); + assertEquals(0, table.size()); } - CollidingKey churn = keys[0]; // keys[1..] are stable and must never vanish - - AtomicBoolean stop = new AtomicBoolean(false); - AtomicInteger missed = new AtomicInteger(); - Thread reader = - new Thread( - () -> { - while (!stop.get()) { + + @Test + void drainOnEmptyTableInvokesSinkZeroTimes() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + int[] count = {0}; + table.drain(e -> count[0]++); + assertEquals(0, count[0]); + assertEquals(0, table.size()); + } + + /** + * Exercises the volatile-{@code next} removal contract: while one key is repeatedly removed and + * re-added in a shared collision chain, the other keys in that chain must remain continuously + * visible to a concurrent lock-free reader. + */ + @Test + void concurrentReadsStaySafeWhileOneChainMemberChurns() throws InterruptedException { + // All keys share hash 0, putting every key in one bucket so removal splices a chain the + // reader is walking. + ConcurrentHashtable.D1 table = + ConcurrentHashtable.D1.createBounded(CollidingEntry.class, 16); + int n = 8; + CollidingKey[] keys = new CollidingKey[n]; + for (int i = 0; i < n; i++) { + keys[i] = new CollidingKey("k" + i, 0); + table.tryGetOrCreateOrNull(keys[i], CollidingEntry::new); + } + CollidingKey churn = keys[0]; // keys[1..] are stable and must never vanish + + AtomicBoolean stop = new AtomicBoolean(false); + AtomicInteger missed = new AtomicInteger(); + Thread reader = new Thread(() -> { + while (!stop.get()) { for (int i = 1; i < n; i++) { - if (table.get(keys[i]) == null) { - missed.incrementAndGet(); - } + if (table.get(keys[i]) == null) { + missed.incrementAndGet(); + } } - } - }); - reader.start(); - for (int r = 0; r < 100_000; r++) { - table.remove(churn); - table.tryGetOrCreateOrNull(churn, CollidingEntry::new); + } + }); + reader.start(); + for (int r = 0; r < 100_000; r++) { + table.remove(churn); + table.tryGetOrCreateOrNull(churn, CollidingEntry::new); + } + stop.set(true); + reader.join(); + + assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); } - stop.set(true); - reader.join(); - - assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); - } - - @Test - void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); - Maybe created = - table.tryGetOrCreateOrEvict("a", k -> new StringEntry(k, 1), e -> true); - assertTrue(created.isPresent()); - assertEquals(1, table.size()); - assertSame(created.getOrNull(), table.get("a")); - } - - @Test - void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); - StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); - Maybe got = - table.tryGetOrCreateOrEvict( - "a", - k -> { - throw new AssertionError("creator must not run on a hit"); - }, - e -> { - throw new AssertionError("evictable must not run on a hit"); - }); - assertSame(a, got.getOrNull()); - assertEquals(1, table.size()); - } - - @Test - void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); - table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); - assertTrue(table.isFull()); - - Maybe created = - table.tryGetOrCreateOrEvict("new", k -> new StringEntry(k, 2), e -> true); - assertTrue(created.isPresent()); - assertEquals("new", created.getOrNull().key()); - assertEquals(1, table.size()); - assertNull(table.get("old")); - assertSame(created.getOrNull(), table.get("new")); - } - - @Test - void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); - table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); - - StringEntry result = - table.tryGetOrCreateOrEvictOrNull("new", k -> new StringEntry(k, 2), e -> false); - assertNull(result); - assertEquals(1, table.size()); - assertNotNull(table.get("old")); - assertNull(table.get("new")); - } - - @Test - void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { - ConcurrentHashtable.D1 table = - ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); - table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); - - assertThrows( - RuntimeException.class, - () -> - table.tryGetOrCreateOrEvictOrNull( - "new", + + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 8); + Maybe created = table.tryGetOrCreateOrEvict("a", k -> new StringEntry(k, 1), e -> true); + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertSame(created.getOrNull(), table.get("a")); + } + + @Test + void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + StringEntry a = table.tryGetOrCreateOrNull("a", k -> new StringEntry(k, 1)); + Maybe got = table.tryGetOrCreateOrEvict( + "a", k -> { - throw new RuntimeException("boom"); + throw new AssertionError("creator must not run on a hit"); }, - e -> true)); - - // Eviction already happened before the creator threw: the table is left one entry smaller, - // not corrupted or double-booked. - assertEquals(0, table.size()); - assertNull(table.get("old")); - assertNull(table.get("new")); - } - - /** Entry holding a key plus one mutable {@code int} payload. */ - private static final class StringEntry extends ConcurrentHashtable.D1.Entry { - volatile int value; - - StringEntry(String key, int value) { - super(key); - this.value = value; + e -> { + throw new AssertionError("evictable must not run on a hit"); + }); + assertSame(a, got.getOrNull()); + assertEquals(1, table.size()); + } + + @Test + void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); + assertTrue(table.isFull()); + + Maybe created = table.tryGetOrCreateOrEvict("new", k -> new StringEntry(k, 2), e -> true); + assertTrue(created.isPresent()); + assertEquals("new", created.getOrNull().key()); + assertEquals(1, table.size()); + assertNull(table.get("old")); + assertSame(created.getOrNull(), table.get("new")); + } + + @Test + void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); + + StringEntry result = table.tryGetOrCreateOrEvictOrNull("new", k -> new StringEntry(k, 2), e -> false); + assertNull(result); + assertEquals(1, table.size()); + assertNotNull(table.get("old")); + assertNull(table.get("new")); } - } - /** Entry with no payload, used for the bucket-chain/collision tests. */ - private static final class CollidingEntry extends ConcurrentHashtable.D1.Entry { - CollidingEntry(CollidingKey key) { - super(key); + @Test + void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { + ConcurrentHashtable.D1 table = ConcurrentHashtable.D1.createBounded(StringEntry.class, 1); + table.tryGetOrCreateOrNull("old", k -> new StringEntry(k, 1)); + + assertThrows( + RuntimeException.class, + () -> table.tryGetOrCreateOrEvictOrNull( + "new", + k -> { + throw new RuntimeException("boom"); + }, + e -> true)); + + // Eviction already happened before the creator threw: the table is left one entry smaller, + // not corrupted or double-booked. + assertEquals(0, table.size()); + assertNull(table.get("old")); + assertNull(table.get("new")); } - } - /** Key with a fixed hashCode to force deterministic bucket placement. */ - private static final class CollidingKey { - final String label; - final int fixedHash; + /** Entry holding a key plus one mutable {@code int} payload. */ + private static final class StringEntry extends ConcurrentHashtable.D1.Entry { + volatile int value; - CollidingKey(String label, int fixedHash) { - this.label = label; - this.fixedHash = fixedHash; + StringEntry(String key, int value) { + super(key); + this.value = value; + } } - @Override - public int hashCode() { - return fixedHash; + /** Entry with no payload, used for the bucket-chain/collision tests. */ + private static final class CollidingEntry extends ConcurrentHashtable.D1.Entry { + CollidingEntry(CollidingKey key) { + super(key); + } } - @Override - public boolean equals(Object o) { - if (!(o instanceof CollidingKey)) { - return false; - } - CollidingKey that = (CollidingKey) o; - return fixedHash == that.fixedHash && label.equals(that.label); + /** Key with a fixed hashCode to force deterministic bucket placement. */ + private static final class CollidingKey { + final String label; + final int fixedHash; + + CollidingKey(String label, int fixedHash) { + this.label = label; + this.fixedHash = fixedHash; + } + + @Override + public int hashCode() { + return fixedHash; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof CollidingKey)) { + return false; + } + CollidingKey that = (CollidingKey) o; + return fixedHash == that.fixedHash && label.equals(that.label); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java index e2b3f8dd353..adbfdcdc279 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableD2Test.java @@ -17,384 +17,367 @@ class ConcurrentHashtableD2Test { - @Test - void pairKeysParticipateInIdentity() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - PairEntry ac = table.tryGetOrCreateOrNull("a", 2, PairEntry::new); - PairEntry bb = table.tryGetOrCreateOrNull("b", 1, PairEntry::new); - assertEquals(3, table.size()); - assertSame(ab, table.get("a", 1)); - assertSame(ac, table.get("a", 2)); - assertSame(bb, table.get("b", 1)); - assertNull(table.get("a", 3)); - } - - @Test - void getOrCreateOnMissBuildsEntryViaCreator() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - int[] createCount = {0}; - PairEntry created = - table.tryGetOrCreateOrNull( - "a", - 1, - (k1, k2) -> { - createCount[0]++; - return new PairEntry(k1, k2); - }); - assertNotNull(created); - assertEquals("a", created.key1()); - assertEquals(Integer.valueOf(1), created.key2()); - assertEquals(1, table.size()); - assertEquals(1, createCount[0]); - assertSame(created, table.get("a", 1)); - } - - @Test - void getOrCreateOnHitSkipsCreator() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - PairEntry seeded = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - int[] createCount = {0}; - PairEntry got = - table.tryGetOrCreateOrNull( - "a", - 1, - (k1, k2) -> { - createCount[0]++; - return new PairEntry(k1, k2); - }); - assertSame(seeded, got); - assertEquals(1, table.size()); - assertEquals(0, createCount[0]); - } - - @Test - void forEachVisitsBothPairs() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - table.tryGetOrCreateOrNull("b", 2, PairEntry::new); - Set seen = new HashSet<>(); - table.forEach(e -> seen.add(e.key1() + ":" + e.key2())); - assertEquals(2, seen.size()); - assertTrue(seen.contains("a:1")); - assertTrue(seen.contains("b:2")); - } - - @Test - void forEachWithContextPassesContextToConsumer() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - table.tryGetOrCreateOrNull("b", 2, PairEntry::new); - Set seen = new HashSet<>(); - table.forEach(seen, (ctx, e) -> ctx.add(e.key1() + ":" + e.key2())); - assertEquals(2, seen.size()); - assertTrue(seen.contains("a:1")); - assertTrue(seen.contains("b:2")); - } - - @Test - void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - int threads = 16; - CountDownLatch ready = new CountDownLatch(threads); - CountDownLatch go = new CountDownLatch(1); - AtomicInteger createCount = new AtomicInteger(); - - Thread[] workers = new Thread[threads]; - for (int i = 0; i < threads; i++) { - workers[i] = - new Thread( - () -> { + @Test + void pairKeysParticipateInIdentity() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + PairEntry ac = table.tryGetOrCreateOrNull("a", 2, PairEntry::new); + PairEntry bb = table.tryGetOrCreateOrNull("b", 1, PairEntry::new); + assertEquals(3, table.size()); + assertSame(ab, table.get("a", 1)); + assertSame(ac, table.get("a", 2)); + assertSame(bb, table.get("b", 1)); + assertNull(table.get("a", 3)); + } + + @Test + void getOrCreateOnMissBuildsEntryViaCreator() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + int[] createCount = {0}; + PairEntry created = table.tryGetOrCreateOrNull("a", 1, (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2); + }); + assertNotNull(created); + assertEquals("a", created.key1()); + assertEquals(Integer.valueOf(1), created.key2()); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("a", 1)); + } + + @Test + void getOrCreateOnHitSkipsCreator() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry seeded = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + int[] createCount = {0}; + PairEntry got = table.tryGetOrCreateOrNull("a", 1, (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); + } + + @Test + void forEachVisitsBothPairs() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key1() + ":" + e.key2())); + assertEquals(2, seen.size()); + assertTrue(seen.contains("a:1")); + assertTrue(seen.contains("b:2")); + } + + @Test + void forEachWithContextPassesContextToConsumer() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + Set seen = new HashSet<>(); + table.forEach(seen, (ctx, e) -> ctx.add(e.key1() + ":" + e.key2())); + assertEquals(2, seen.size()); + assertTrue(seen.contains("a:1")); + assertTrue(seen.contains("b:2")); + } + + @Test + void concurrentGetOrCreateProducesExactlyOneEntry() throws InterruptedException { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + int threads = 16; + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger createCount = new AtomicInteger(); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + workers[i] = new Thread(() -> { ready.countDown(); try { - go.await(); + go.await(); } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return; + Thread.currentThread().interrupt(); + return; } - table.tryGetOrCreateOrNull( - "shared", - 42, - (k1, k2) -> { - createCount.incrementAndGet(); - return new PairEntry(k1, k2); - }); - }); - workers[i].start(); - } - ready.await(); - go.countDown(); - for (Thread w : workers) { - w.join(); + table.tryGetOrCreateOrNull("shared", 42, (k1, k2) -> { + createCount.incrementAndGet(); + return new PairEntry(k1, k2); + }); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(1, table.size()); + assertEquals(1, createCount.get()); } - assertEquals(1, table.size()); - assertEquals(1, createCount.get()); - } - - @Test - void chainedEntriesInSameBucketAreAllReachable() { - // key2 = -31 * key1.hashCode() zeroes the combined hash, so all four land in bucket 0 - // regardless of table size. - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - PairEntry e1 = table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); - PairEntry e2 = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); - PairEntry e3 = table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); - PairEntry e4 = table.tryGetOrCreateOrNull("d", -31 * "d".hashCode(), PairEntry::new); - assertEquals(4, table.size()); - assertSame(e1, table.get("a", -31 * "a".hashCode())); - assertSame(e2, table.get("b", -31 * "b".hashCode())); - assertSame(e3, table.get("c", -31 * "c".hashCode())); - assertSame(e4, table.get("d", -31 * "d".hashCode())); - assertNull(table.get("a", 3)); - } - - @Test - void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException { - int threads = 16; - String[] k1s = new String[threads]; - Integer[] k2s = new Integer[threads]; - for (int i = 0; i < threads; i++) { - k1s[i] = "key-" + i; - k2s[i] = i; + @Test + void chainedEntriesInSameBucketAreAllReachable() { + // key2 = -31 * key1.hashCode() zeroes the combined hash, so all four land in bucket 0 + // regardless of table size. + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry e1 = table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); + PairEntry e2 = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); + PairEntry e3 = table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); + PairEntry e4 = table.tryGetOrCreateOrNull("d", -31 * "d".hashCode(), PairEntry::new); + assertEquals(4, table.size()); + assertSame(e1, table.get("a", -31 * "a".hashCode())); + assertSame(e2, table.get("b", -31 * "b".hashCode())); + assertSame(e3, table.get("c", -31 * "c".hashCode())); + assertSame(e4, table.get("d", -31 * "d".hashCode())); + assertNull(table.get("a", 3)); } - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, threads * 2); - CountDownLatch ready = new CountDownLatch(threads); - CountDownLatch go = new CountDownLatch(1); - - Thread[] workers = new Thread[threads]; - for (int i = 0; i < threads; i++) { - final String k1 = k1s[i]; - final Integer k2 = k2s[i]; - workers[i] = - new Thread( - () -> { + + @Test + void concurrentDistinctKeyInsertionsAreAllRetained() throws InterruptedException { + int threads = 16; + String[] k1s = new String[threads]; + Integer[] k2s = new Integer[threads]; + for (int i = 0; i < threads; i++) { + k1s[i] = "key-" + i; + k2s[i] = i; + } + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, threads * 2); + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + final String k1 = k1s[i]; + final Integer k2 = k2s[i]; + workers[i] = new Thread(() -> { ready.countDown(); try { - go.await(); + go.await(); } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return; + Thread.currentThread().interrupt(); + return; } table.tryGetOrCreateOrNull(k1, k2, PairEntry::new); - }); - workers[i].start(); + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } + + assertEquals(threads, table.size()); + for (int i = 0; i < threads; i++) { + assertNotNull(table.get(k1s[i], k2s[i])); + } } - ready.await(); - go.countDown(); - for (Thread w : workers) { - w.join(); + + @Test + void removeReturnsEntryAndShrinks() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("a", 2, PairEntry::new); + assertSame(ab, table.remove("a", 1)); + assertEquals(1, table.size()); + assertNull(table.get("a", 1)); + assertNotNull(table.get("a", 2)); } - assertEquals(threads, table.size()); - for (int i = 0; i < threads; i++) { - assertNotNull(table.get(k1s[i], k2s[i])); + @Test + void removeAbsentKeyReturnsNull() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + assertNull(table.remove("a", 99)); + assertNull(table.remove("z", 1)); + assertEquals(1, table.size()); } - } - - @Test - void removeReturnsEntryAndShrinks() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - PairEntry ab = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - table.tryGetOrCreateOrNull("a", 2, PairEntry::new); - assertSame(ab, table.remove("a", 1)); - assertEquals(1, table.size()); - assertNull(table.get("a", 1)); - assertNotNull(table.get("a", 2)); - } - - @Test - void removeAbsentKeyReturnsNull() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - assertNull(table.remove("a", 99)); - assertNull(table.remove("z", 1)); - assertEquals(1, table.size()); - } - - @Test - void removeMiddleOfSameBucketChainKeepsOthersReachable() { - // key2 = -31 * key1.hashCode() zeroes the combined hash, so all three land in one bucket - // chain regardless of table size. - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); - PairEntry mid = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); - table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); - - assertSame(mid, table.remove("b", -31 * "b".hashCode())); - assertNull(table.get("b", -31 * "b".hashCode())); - assertNotNull(table.get("a", -31 * "a".hashCode())); - assertNotNull(table.get("c", -31 * "c".hashCode())); - assertEquals(2, table.size()); - } - - @Test - void removeIfRemovesMatchingEntries() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 16); - for (int i = 0; i < 10; i++) { - table.tryGetOrCreateOrNull("k", i, PairEntry::new); + + @Test + void removeMiddleOfSameBucketChainKeepsOthersReachable() { + // key2 = -31 * key1.hashCode() zeroes the combined hash, so all three land in one bucket + // chain regardless of table size. + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", -31 * "a".hashCode(), PairEntry::new); + PairEntry mid = table.tryGetOrCreateOrNull("b", -31 * "b".hashCode(), PairEntry::new); + table.tryGetOrCreateOrNull("c", -31 * "c".hashCode(), PairEntry::new); + + assertSame(mid, table.remove("b", -31 * "b".hashCode())); + assertNull(table.get("b", -31 * "b".hashCode())); + assertNotNull(table.get("a", -31 * "a".hashCode())); + assertNotNull(table.get("c", -31 * "c".hashCode())); + assertEquals(2, table.size()); } - boolean removed = table.removeIf(e -> e.key2() % 2 == 0); // removes key2 0,2,4,6,8 - assertTrue(removed); - assertEquals(5, table.size()); - Set seen = new HashSet<>(); - table.forEach(e -> seen.add(e.key1() + ":" + e.key2())); - assertEquals(5, seen.size()); - } - - @Test - void removeIfReturnsFalseWhenNothingMatches() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - assertFalse(table.removeIf(e -> false)); - assertEquals(1, table.size()); - } - - @Test - void clearEmptiesTableAndLeavesItUsable() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - table.tryGetOrCreateOrNull("b", 2, PairEntry::new); - table.clear(); - assertEquals(0, table.size()); - assertNull(table.get("a", 1)); - PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); - assertSame(c, table.get("c", 3)); - assertEquals(1, table.size()); - } - - @Test - void drainRemovesEveryEntryAndFeedsSink() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - table.tryGetOrCreateOrNull("a", 2, PairEntry::new); - table.tryGetOrCreateOrNull("b", 1, PairEntry::new); - - Set drained = new HashSet<>(); - table.drain(e -> drained.add(e.key1() + ":" + e.key2())); - - assertEquals(new HashSet<>(Arrays.asList("a:1", "a:2", "b:1")), drained); - assertEquals(0, table.size()); - assertNull(table.get("a", 1)); - PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); - assertSame(c, table.get("c", 3)); - assertEquals(1, table.size()); - } - - @Test - void drainWithContextFeedsSink() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - table.tryGetOrCreateOrNull("b", 2, PairEntry::new); - - Set drained = new HashSet<>(); - table.drain(drained, (ctx, e) -> ctx.add(e.key1() + ":" + e.key2())); - - assertEquals(new HashSet<>(Arrays.asList("a:1", "b:2")), drained); - assertEquals(0, table.size()); - } - - @Test - void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); - Maybe created = table.tryGetOrCreateOrEvict("a", 1, PairEntry::new, e -> true); - assertTrue(created.isPresent()); - assertEquals(1, table.size()); - assertSame(created.getOrNull(), table.get("a", 1)); - } - - @Test - void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); - PairEntry a = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); - Maybe got = - table.tryGetOrCreateOrEvict( - "a", - 1, - (k1, k2) -> { - throw new AssertionError("creator must not run on a hit"); - }, - e -> { - throw new AssertionError("evictable must not run on a hit"); - }); - assertSame(a, got.getOrNull()); - assertEquals(1, table.size()); - } - - @Test - void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); - table.tryGetOrCreateOrNull("old", 1, PairEntry::new); - assertTrue(table.isFull()); - - Maybe created = table.tryGetOrCreateOrEvict("new", 2, PairEntry::new, e -> true); - assertTrue(created.isPresent()); - assertEquals("new", created.getOrNull().key1()); - assertEquals(1, table.size()); - assertNull(table.get("old", 1)); - assertSame(created.getOrNull(), table.get("new", 2)); - } - - @Test - void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); - table.tryGetOrCreateOrNull("old", 1, PairEntry::new); - - PairEntry result = table.tryGetOrCreateOrEvictOrNull("new", 2, PairEntry::new, e -> false); - assertNull(result); - assertEquals(1, table.size()); - assertNotNull(table.get("old", 1)); - assertNull(table.get("new", 2)); - } - - @Test - void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { - ConcurrentHashtable.D2 table = - ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); - table.tryGetOrCreateOrNull("old", 1, PairEntry::new); - - assertThrows( - RuntimeException.class, - () -> - table.tryGetOrCreateOrEvictOrNull( - "new", - 2, + + @Test + void removeIfRemovesMatchingEntries() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 16); + for (int i = 0; i < 10; i++) { + table.tryGetOrCreateOrNull("k", i, PairEntry::new); + } + boolean removed = table.removeIf(e -> e.key2() % 2 == 0); // removes key2 0,2,4,6,8 + assertTrue(removed); + assertEquals(5, table.size()); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key1() + ":" + e.key2())); + assertEquals(5, seen.size()); + } + + @Test + void removeIfReturnsFalseWhenNothingMatches() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + assertFalse(table.removeIf(e -> false)); + assertEquals(1, table.size()); + } + + @Test + void clearEmptiesTableAndLeavesItUsable() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); + assertSame(c, table.get("c", 3)); + assertEquals(1, table.size()); + } + + @Test + void drainRemovesEveryEntryAndFeedsSink() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("a", 2, PairEntry::new); + table.tryGetOrCreateOrNull("b", 1, PairEntry::new); + + Set drained = new HashSet<>(); + table.drain(e -> drained.add(e.key1() + ":" + e.key2())); + + assertEquals(new HashSet<>(Arrays.asList("a:1", "a:2", "b:1")), drained); + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + PairEntry c = table.tryGetOrCreateOrNull("c", 3, PairEntry::new); + assertSame(c, table.get("c", 3)); + assertEquals(1, table.size()); + } + + @Test + void drainWithContextFeedsSink() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + table.tryGetOrCreateOrNull("b", 2, PairEntry::new); + + Set drained = new HashSet<>(); + table.drain(drained, (ctx, e) -> ctx.add(e.key1() + ":" + e.key2())); + + assertEquals(new HashSet<>(Arrays.asList("a:1", "b:2")), drained); + assertEquals(0, table.size()); + } + + @Test + void tryGetOrCreateOrEvictInsertsWithoutEvictingWhenUnderCapacity() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 8); + Maybe created = table.tryGetOrCreateOrEvict("a", 1, PairEntry::new, e -> true); + assertTrue(created.isPresent()); + assertEquals(1, table.size()); + assertSame(created.getOrNull(), table.get("a", 1)); + } + + @Test + void tryGetOrCreateOrEvictReturnsExistingEntryOnHitWithoutEvicting() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + PairEntry a = table.tryGetOrCreateOrNull("a", 1, PairEntry::new); + Maybe got = table.tryGetOrCreateOrEvict( + "a", + 1, (k1, k2) -> { - throw new RuntimeException("boom"); + throw new AssertionError("creator must not run on a hit"); }, - e -> true)); - - // Eviction already happened before the creator threw: the table is left one entry smaller, - // not corrupted or double-booked. - assertEquals(0, table.size()); - assertNull(table.get("old", 1)); - assertNull(table.get("new", 2)); - } - - /** Entry with no payload beyond its two key parts, used to exercise the D2 identity/API. */ - private static final class PairEntry extends ConcurrentHashtable.D2.Entry { - PairEntry(String key1, Integer key2) { - super(key1, key2); + e -> { + throw new AssertionError("evictable must not run on a hit"); + }); + assertSame(a, got.getOrNull()); + assertEquals(1, table.size()); + } + + @Test + void tryGetOrCreateOrEvictEvictsWhenFullAndInsertsNewEntry() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, PairEntry::new); + assertTrue(table.isFull()); + + Maybe created = table.tryGetOrCreateOrEvict("new", 2, PairEntry::new, e -> true); + assertTrue(created.isPresent()); + assertEquals("new", created.getOrNull().key1()); + assertEquals(1, table.size()); + assertNull(table.get("old", 1)); + assertSame(created.getOrNull(), table.get("new", 2)); + } + + @Test + void tryGetOrCreateOrEvictOrNullRefusesWhenFullAndNothingEvictable() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, PairEntry::new); + + PairEntry result = table.tryGetOrCreateOrEvictOrNull("new", 2, PairEntry::new, e -> false); + assertNull(result); + assertEquals(1, table.size()); + assertNotNull(table.get("old", 1)); + assertNull(table.get("new", 2)); + } + + @Test + void tryGetOrCreateOrEvictOrNullEvictionRunsBeforeThrowingCreator() { + ConcurrentHashtable.D2 table = + ConcurrentHashtable.D2.createBounded(PairEntry.class, 1); + table.tryGetOrCreateOrNull("old", 1, PairEntry::new); + + assertThrows( + RuntimeException.class, + () -> table.tryGetOrCreateOrEvictOrNull( + "new", + 2, + (k1, k2) -> { + throw new RuntimeException("boom"); + }, + e -> true)); + + // Eviction already happened before the creator threw: the table is left one entry smaller, + // not corrupted or double-booked. + assertEquals(0, table.size()); + assertNull(table.get("old", 1)); + assertNull(table.get("new", 2)); + } + + /** Entry with no payload beyond its two key parts, used to exercise the D2 identity/API. */ + private static final class PairEntry extends ConcurrentHashtable.D2.Entry { + PairEntry(String key1, Integer key2) { + super(key1, key2); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableReservationTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableReservationTest.java index 2e8f2af118f..a43d9ac227d 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableReservationTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableReservationTest.java @@ -19,410 +19,380 @@ /** Exercises {@link ConcurrentHashtable#tryReserve} and {@link ConcurrentHashtable.Reservation}. */ class ConcurrentHashtableReservationTest { - private static final class TestEntry extends ConcurrentHashtable.Entry { - final int value; - - TestEntry(int value) { - super(value); - this.value = value; - } - - @Override - public boolean matches(@Nonnull TestEntry other) { - return value == other.value; - } - } - - @Test - void tryGetOrInsertOrNullInsertsOnMissAndFindsOnHit() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 4); - - TestEntry first; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - assertTrue(r.isReserved()); - first = r.tryGetOrInsertOrNull(1, TestEntry::new); - } - assertEquals(1, first.value); - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - - // Reserving again for a key that already exists should discard the reservation and return the - // existing entry, not double-insert or leak the claimed slot. - TestEntry second; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - second = r.tryGetOrInsertOrNull(1, TestEntry::new); - } - assertSame(first, second); - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - } - - @Test - void reserveOnFullTableIsAbsentAndSkipsTheFactory() { - // capacity 3 rounds up to 4 buckets, so one bucket stays empty once the table is full -- - // that's what lets tryReserve rule out keyHash 4 without ever taking the write lock. - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 3); - for (int keyHash = 1; keyHash <= 3; keyHash++) { - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state, keyHash)) { - r.tryGetOrInsertOrNull(keyHash, TestEntry::new); - } - } - assertTrue(ConcurrentHashtable.isFull(state)); - - AtomicInteger factoryCalls = new AtomicInteger(); - TestEntry result; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 4)) { - assertFalse(r.isReserved()); - result = - r.tryGetOrInsertOrNull( - 4, - v -> { + private static final class TestEntry extends ConcurrentHashtable.Entry { + final int value; + + TestEntry(int value) { + super(value); + this.value = value; + } + + @Override + public boolean matches(@Nonnull TestEntry other) { + return value == other.value; + } + } + + @Test + void tryGetOrInsertOrNullInsertsOnMissAndFindsOnHit() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 4); + + TestEntry first; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + assertTrue(r.isReserved()); + first = r.tryGetOrInsertOrNull(1, TestEntry::new); + } + assertEquals(1, first.value); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + + // Reserving again for a key that already exists should discard the reservation and return the + // existing entry, not double-insert or leak the claimed slot. + TestEntry second; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + second = r.tryGetOrInsertOrNull(1, TestEntry::new); + } + assertSame(first, second); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + } + + @Test + void reserveOnFullTableIsAbsentAndSkipsTheFactory() { + // capacity 3 rounds up to 4 buckets, so one bucket stays empty once the table is full -- + // that's what lets tryReserve rule out keyHash 4 without ever taking the write lock. + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 3); + for (int keyHash = 1; keyHash <= 3; keyHash++) { + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, keyHash)) { + r.tryGetOrInsertOrNull(keyHash, TestEntry::new); + } + } + assertTrue(ConcurrentHashtable.isFull(state)); + + AtomicInteger factoryCalls = new AtomicInteger(); + TestEntry result; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 4)) { + assertFalse(r.isReserved()); + result = r.tryGetOrInsertOrNull(4, v -> { factoryCalls.incrementAndGet(); return new TestEntry(v); - }); - } - assertNull(result); - assertEquals(0, factoryCalls.get()); - assertEquals(3, ConcurrentHashtable.estimateSize(state)); - } - - @Test - void closeCancelsAnUnconsumedReservation() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - assertTrue(r.isReserved()); - // Deliberately not consuming the reservation. - } - assertEquals(0, ConcurrentHashtable.estimateSize(state)); - assertFalse(ConcurrentHashtable.isFull(state)); - } - - @Test - void tryGetOrInsertWrapsResultInMaybe() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - - Maybe present; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - present = r.tryGetOrInsert(1, TestEntry::new); - } - assertTrue(present.isPresent()); - assertEquals(1, present.getOrNull().value); - - Maybe absent; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 2)) { - absent = r.tryGetOrInsert(2, TestEntry::new); - } - assertFalse(absent.isPresent()); - assertNull(absent.getOrNull()); - } - - @Test - void closeOnAnAbsentReservationIsANoOp() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 0); - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - assertFalse(r.isReserved()); - } - assertEquals(0, ConcurrentHashtable.estimateSize(state)); - } - - private static final class TwoPartEntry extends ConcurrentHashtable.Entry { - final String a; - final String b; - - TwoPartEntry(String a, String b) { - super(HashingUtils.hash(a, b)); - this.a = a; - this.b = b; - } - - @Override - public boolean matches(@Nonnull TwoPartEntry other) { - return a.equals(other.a) && b.equals(other.b); - } - } - - private static final class ThreePartEntry extends ConcurrentHashtable.Entry { - final String a; - final String b; - final String c; - - ThreePartEntry(String a, String b, String c) { - super(HashingUtils.hash(a, b, c)); - this.a = a; - this.b = b; - this.c = c; - } - - @Override - public boolean matches(@Nonnull ThreePartEntry other) { - return a.equals(other.a) && b.equals(other.b) && c.equals(other.c); - } - } - - private static final class FourPartEntry extends ConcurrentHashtable.Entry { - final String a; - final String b; - final String c; - final String d; - - FourPartEntry(String a, String b, String c, String d) { - super(HashingUtils.hash(a, b, c, d)); - this.a = a; - this.b = b; - this.c = c; - this.d = d; - } - - @Override - public boolean matches(@Nonnull FourPartEntry other) { - return a.equals(other.a) && b.equals(other.b) && c.equals(other.c) && d.equals(other.d); - } - } - - @Test - void tryGetOrInsertOrNullSupportsUpToFourComponents() { - ConcurrentHashtable.State state3 = - ConcurrentHashtable.createBounded(ThreePartEntry.class, 2); - ThreePartEntry three; - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state3, HashingUtils.hash("x", "y", "z"))) { - three = r.tryGetOrInsertOrNull("x", "y", "z", ThreePartEntry::new); - } - assertEquals("x", three.a); - assertEquals("y", three.b); - assertEquals("z", three.c); - - ConcurrentHashtable.State state4 = - ConcurrentHashtable.createBounded(FourPartEntry.class, 2); - FourPartEntry four; - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state4, HashingUtils.hash("w", "x", "y", "z"))) { - four = r.tryGetOrInsertOrNull("w", "x", "y", "z", FourPartEntry::new); - } - assertEquals("w", four.a); - assertEquals("x", four.b); - assertEquals("y", four.c); - assertEquals("z", four.d); - } - - @Test - void tryGetOrInsertOrNullSupportsTwoComponents() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TwoPartEntry.class, 2); - TwoPartEntry two; - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state, HashingUtils.hash("x", "y"))) { - two = r.tryGetOrInsertOrNull("x", "y", TwoPartEntry::new); - } - assertEquals("x", two.a); - assertEquals("y", two.b); - } - - @Test - void tryGetOrInsertOrNullOnPrebuiltEntry() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - TestEntry inserted; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - inserted = r.tryGetOrInsertOrNull(new TestEntry(1)); - } - assertEquals(1, inserted.value); - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - } - - @Test - void tryGetOrInsertOnPrebuiltEntryWrapsResultInMaybe() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - - Maybe present; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - present = r.tryGetOrInsert(new TestEntry(1)); - } - assertTrue(present.isPresent()); - assertEquals(1, present.getOrNull().value); - - Maybe absent; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 2)) { - absent = r.tryGetOrInsert(new TestEntry(2)); - } - assertFalse(absent.isPresent()); - assertNull(absent.getOrNull()); - } - - @Test - void tryGetOrInsertWrapsResultInMaybeForTwoThreeAndFourComponents() { - ConcurrentHashtable.State state2 = - ConcurrentHashtable.createBounded(TwoPartEntry.class, 2); - Maybe two; - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state2, HashingUtils.hash("x", "y"))) { - two = r.tryGetOrInsert("x", "y", TwoPartEntry::new); - } - assertTrue(two.isPresent()); - assertEquals("x", two.getOrNull().a); - assertEquals("y", two.getOrNull().b); - - ConcurrentHashtable.State state3 = - ConcurrentHashtable.createBounded(ThreePartEntry.class, 2); - Maybe three; - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state3, HashingUtils.hash("x", "y", "z"))) { - three = r.tryGetOrInsert("x", "y", "z", ThreePartEntry::new); - } - assertTrue(three.isPresent()); - assertEquals("x", three.getOrNull().a); - assertEquals("y", three.getOrNull().b); - assertEquals("z", three.getOrNull().c); - - ConcurrentHashtable.State state4 = - ConcurrentHashtable.createBounded(FourPartEntry.class, 2); - Maybe four; - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state4, HashingUtils.hash("w", "x", "y", "z"))) { - four = r.tryGetOrInsert("w", "x", "y", "z", FourPartEntry::new); - } - assertTrue(four.isPresent()); - assertEquals("w", four.getOrNull().a); - assertEquals("x", four.getOrNull().b); - assertEquals("y", four.getOrNull().c); - assertEquals("z", four.getOrNull().d); - } - - @Test - void tryReserveSkipsTheLockWhenTheTargetBucketIsDefinitelyEmpty() { - // capacity 3 rounds up to 4 buckets, so one bucket stays empty once the table is full. - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 3); - for (int keyHash = 1; keyHash <= 3; keyHash++) { - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state, keyHash)) { - r.tryGetOrInsertOrNull(keyHash, TestEntry::new); - } - } - assertTrue(ConcurrentHashtable.isFull(state)); - - AtomicInteger factoryCalls = new AtomicInteger(); - TestEntry result; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 4)) { - assertFalse(r.isReserved()); - result = - r.tryGetOrInsertOrNull( - 4, - v -> { + }); + } + assertNull(result); + assertEquals(0, factoryCalls.get()); + assertEquals(3, ConcurrentHashtable.estimateSize(state)); + } + + @Test + void closeCancelsAnUnconsumedReservation() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + assertTrue(r.isReserved()); + // Deliberately not consuming the reservation. + } + assertEquals(0, ConcurrentHashtable.estimateSize(state)); + assertFalse(ConcurrentHashtable.isFull(state)); + } + + @Test + void tryGetOrInsertWrapsResultInMaybe() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + + Maybe present; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + present = r.tryGetOrInsert(1, TestEntry::new); + } + assertTrue(present.isPresent()); + assertEquals(1, present.getOrNull().value); + + Maybe absent; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 2)) { + absent = r.tryGetOrInsert(2, TestEntry::new); + } + assertFalse(absent.isPresent()); + assertNull(absent.getOrNull()); + } + + @Test + void closeOnAnAbsentReservationIsANoOp() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 0); + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + assertFalse(r.isReserved()); + } + assertEquals(0, ConcurrentHashtable.estimateSize(state)); + } + + private static final class TwoPartEntry extends ConcurrentHashtable.Entry { + final String a; + final String b; + + TwoPartEntry(String a, String b) { + super(HashingUtils.hash(a, b)); + this.a = a; + this.b = b; + } + + @Override + public boolean matches(@Nonnull TwoPartEntry other) { + return a.equals(other.a) && b.equals(other.b); + } + } + + private static final class ThreePartEntry extends ConcurrentHashtable.Entry { + final String a; + final String b; + final String c; + + ThreePartEntry(String a, String b, String c) { + super(HashingUtils.hash(a, b, c)); + this.a = a; + this.b = b; + this.c = c; + } + + @Override + public boolean matches(@Nonnull ThreePartEntry other) { + return a.equals(other.a) && b.equals(other.b) && c.equals(other.c); + } + } + + private static final class FourPartEntry extends ConcurrentHashtable.Entry { + final String a; + final String b; + final String c; + final String d; + + FourPartEntry(String a, String b, String c, String d) { + super(HashingUtils.hash(a, b, c, d)); + this.a = a; + this.b = b; + this.c = c; + this.d = d; + } + + @Override + public boolean matches(@Nonnull FourPartEntry other) { + return a.equals(other.a) && b.equals(other.b) && c.equals(other.c) && d.equals(other.d); + } + } + + @Test + void tryGetOrInsertOrNullSupportsUpToFourComponents() { + ConcurrentHashtable.State state3 = ConcurrentHashtable.createBounded(ThreePartEntry.class, 2); + ThreePartEntry three; + try (ConcurrentHashtable.Reservation r = + ConcurrentHashtable.tryReserve(state3, HashingUtils.hash("x", "y", "z"))) { + three = r.tryGetOrInsertOrNull("x", "y", "z", ThreePartEntry::new); + } + assertEquals("x", three.a); + assertEquals("y", three.b); + assertEquals("z", three.c); + + ConcurrentHashtable.State state4 = ConcurrentHashtable.createBounded(FourPartEntry.class, 2); + FourPartEntry four; + try (ConcurrentHashtable.Reservation r = + ConcurrentHashtable.tryReserve(state4, HashingUtils.hash("w", "x", "y", "z"))) { + four = r.tryGetOrInsertOrNull("w", "x", "y", "z", FourPartEntry::new); + } + assertEquals("w", four.a); + assertEquals("x", four.b); + assertEquals("y", four.c); + assertEquals("z", four.d); + } + + @Test + void tryGetOrInsertOrNullSupportsTwoComponents() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TwoPartEntry.class, 2); + TwoPartEntry two; + try (ConcurrentHashtable.Reservation r = + ConcurrentHashtable.tryReserve(state, HashingUtils.hash("x", "y"))) { + two = r.tryGetOrInsertOrNull("x", "y", TwoPartEntry::new); + } + assertEquals("x", two.a); + assertEquals("y", two.b); + } + + @Test + void tryGetOrInsertOrNullOnPrebuiltEntry() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + TestEntry inserted; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + inserted = r.tryGetOrInsertOrNull(new TestEntry(1)); + } + assertEquals(1, inserted.value); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + } + + @Test + void tryGetOrInsertOnPrebuiltEntryWrapsResultInMaybe() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + + Maybe present; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + present = r.tryGetOrInsert(new TestEntry(1)); + } + assertTrue(present.isPresent()); + assertEquals(1, present.getOrNull().value); + + Maybe absent; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 2)) { + absent = r.tryGetOrInsert(new TestEntry(2)); + } + assertFalse(absent.isPresent()); + assertNull(absent.getOrNull()); + } + + @Test + void tryGetOrInsertWrapsResultInMaybeForTwoThreeAndFourComponents() { + ConcurrentHashtable.State state2 = ConcurrentHashtable.createBounded(TwoPartEntry.class, 2); + Maybe two; + try (ConcurrentHashtable.Reservation r = + ConcurrentHashtable.tryReserve(state2, HashingUtils.hash("x", "y"))) { + two = r.tryGetOrInsert("x", "y", TwoPartEntry::new); + } + assertTrue(two.isPresent()); + assertEquals("x", two.getOrNull().a); + assertEquals("y", two.getOrNull().b); + + ConcurrentHashtable.State state3 = ConcurrentHashtable.createBounded(ThreePartEntry.class, 2); + Maybe three; + try (ConcurrentHashtable.Reservation r = + ConcurrentHashtable.tryReserve(state3, HashingUtils.hash("x", "y", "z"))) { + three = r.tryGetOrInsert("x", "y", "z", ThreePartEntry::new); + } + assertTrue(three.isPresent()); + assertEquals("x", three.getOrNull().a); + assertEquals("y", three.getOrNull().b); + assertEquals("z", three.getOrNull().c); + + ConcurrentHashtable.State state4 = ConcurrentHashtable.createBounded(FourPartEntry.class, 2); + Maybe four; + try (ConcurrentHashtable.Reservation r = + ConcurrentHashtable.tryReserve(state4, HashingUtils.hash("w", "x", "y", "z"))) { + four = r.tryGetOrInsert("w", "x", "y", "z", FourPartEntry::new); + } + assertTrue(four.isPresent()); + assertEquals("w", four.getOrNull().a); + assertEquals("x", four.getOrNull().b); + assertEquals("y", four.getOrNull().c); + assertEquals("z", four.getOrNull().d); + } + + @Test + void tryReserveSkipsTheLockWhenTheTargetBucketIsDefinitelyEmpty() { + // capacity 3 rounds up to 4 buckets, so one bucket stays empty once the table is full. + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 3); + for (int keyHash = 1; keyHash <= 3; keyHash++) { + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, keyHash)) { + r.tryGetOrInsertOrNull(keyHash, TestEntry::new); + } + } + assertTrue(ConcurrentHashtable.isFull(state)); + + AtomicInteger factoryCalls = new AtomicInteger(); + TestEntry result; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 4)) { + assertFalse(r.isReserved()); + result = r.tryGetOrInsertOrNull(4, v -> { factoryCalls.incrementAndGet(); return new TestEntry(v); - }); - } - assertNull(result); - assertEquals(0, factoryCalls.get()); - assertEquals(3, ConcurrentHashtable.estimateSize(state)); - } - - @Test - void tryReserveFindsAConcurrentDuplicateEvenWhenTheTableLooksFull() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 3); - TestEntry existing; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - existing = r.tryGetOrInsertOrNull(1, TestEntry::new); - } - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 2)) { - r.tryGetOrInsertOrNull(2, TestEntry::new); - } - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 3)) { - r.tryGetOrInsertOrNull(3, TestEntry::new); - } - assertTrue(ConcurrentHashtable.isFull(state)); - - // Reserving for the same keyHash again simulates a concurrent duplicate insert landing just - // before the caller's own reservation attempt. - TestEntry match; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - assertTrue(r.isReserved()); - match = r.tryGetOrInsertOrNull(new TestEntry(1)); - } - assertSame(existing, match); - assertEquals(3, ConcurrentHashtable.estimateSize(state)); - } - - @Test - void tryReserveReturnsNullWithoutOvercountingWhenGenuinelyFull() { - // capacity 3 rounds up to 4 buckets; keyHash 1 and 5 collide on the same bucket (index 1) but - // are logically distinct keys (TestEntry.matches compares by value). - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 3); - for (int keyHash = 1; keyHash <= 3; keyHash++) { - try (ConcurrentHashtable.Reservation r = - ConcurrentHashtable.tryReserve(state, keyHash)) { - r.tryGetOrInsertOrNull(keyHash, TestEntry::new); - } - } - assertTrue(ConcurrentHashtable.isFull(state)); - - TestEntry result; - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 5)) { - assertTrue(r.isReserved()); - result = r.tryGetOrInsertOrNull(new TestEntry(5)); - } - assertNull(result); - assertEquals(3, ConcurrentHashtable.estimateSize(state)); - } - - @Test - void tryReserveWaitsForAnInFlightReservationOnTheSameKeyBeforeRejecting() throws Exception { - // Capacity 1: the first reservation claims the table's only slot (so isFull() flips true) - // without yet linking its entry into the bucket -- exactly the window a lock-free "full + - // empty bucket" peek must not trust, since a concurrent duplicate could land there. - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - - ConcurrentHashtable.Reservation first = ConcurrentHashtable.tryReserve(state, 1); - assertTrue(first.isReserved()); - assertTrue(ConcurrentHashtable.isFull(state)); - - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - Future secondFuture = - executor.submit( - () -> { - try (ConcurrentHashtable.Reservation second = - ConcurrentHashtable.tryReserve(state, 1)) { - return second.tryGetOrInsertOrNull(new TestEntry(1)); + }); + } + assertNull(result); + assertEquals(0, factoryCalls.get()); + assertEquals(3, ConcurrentHashtable.estimateSize(state)); + } + + @Test + void tryReserveFindsAConcurrentDuplicateEvenWhenTheTableLooksFull() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 3); + TestEntry existing; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + existing = r.tryGetOrInsertOrNull(1, TestEntry::new); + } + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 2)) { + r.tryGetOrInsertOrNull(2, TestEntry::new); + } + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 3)) { + r.tryGetOrInsertOrNull(3, TestEntry::new); + } + assertTrue(ConcurrentHashtable.isFull(state)); + + // Reserving for the same keyHash again simulates a concurrent duplicate insert landing just + // before the caller's own reservation attempt. + TestEntry match; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + assertTrue(r.isReserved()); + match = r.tryGetOrInsertOrNull(new TestEntry(1)); + } + assertSame(existing, match); + assertEquals(3, ConcurrentHashtable.estimateSize(state)); + } + + @Test + void tryReserveReturnsNullWithoutOvercountingWhenGenuinelyFull() { + // capacity 3 rounds up to 4 buckets; keyHash 1 and 5 collide on the same bucket (index 1) but + // are logically distinct keys (TestEntry.matches compares by value). + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 3); + for (int keyHash = 1; keyHash <= 3; keyHash++) { + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, keyHash)) { + r.tryGetOrInsertOrNull(keyHash, TestEntry::new); + } + } + assertTrue(ConcurrentHashtable.isFull(state)); + + TestEntry result; + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 5)) { + assertTrue(r.isReserved()); + result = r.tryGetOrInsertOrNull(new TestEntry(5)); + } + assertNull(result); + assertEquals(3, ConcurrentHashtable.estimateSize(state)); + } + + @Test + void tryReserveWaitsForAnInFlightReservationOnTheSameKeyBeforeRejecting() throws Exception { + // Capacity 1: the first reservation claims the table's only slot (so isFull() flips true) + // without yet linking its entry into the bucket -- exactly the window a lock-free "full + + // empty bucket" peek must not trust, since a concurrent duplicate could land there. + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + + ConcurrentHashtable.Reservation first = ConcurrentHashtable.tryReserve(state, 1); + assertTrue(first.isReserved()); + assertTrue(ConcurrentHashtable.isFull(state)); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future secondFuture = executor.submit(() -> { + try (ConcurrentHashtable.Reservation second = ConcurrentHashtable.tryReserve(state, 1)) { + return second.tryGetOrInsertOrNull(new TestEntry(1)); } - }); - - // A buggy fast-reject would complete this immediately (with a null result) instead of - // blocking on the still-held write lock. - assertThrows(TimeoutException.class, () -> secondFuture.get(200, TimeUnit.MILLISECONDS)); - - TestEntry inserted; - try { - inserted = first.tryGetOrInsertOrNull(new TestEntry(1)); - } finally { - first.close(); - } - - TestEntry secondResult = secondFuture.get(5, TimeUnit.SECONDS); - assertSame(inserted, secondResult); - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - } finally { - executor.shutdown(); - } - } - - @Test - void reusingAReservationForASecondCompletionThrows() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 2); - try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { - r.tryGetOrInsertOrNull(1, TestEntry::new); - assertThrows(IllegalStateException.class, () -> r.tryGetOrInsertOrNull(2, TestEntry::new)); + }); + + // A buggy fast-reject would complete this immediately (with a null result) instead of + // blocking on the still-held write lock. + assertThrows(TimeoutException.class, () -> secondFuture.get(200, TimeUnit.MILLISECONDS)); + + TestEntry inserted; + try { + inserted = first.tryGetOrInsertOrNull(new TestEntry(1)); + } finally { + first.close(); + } + + TestEntry secondResult = secondFuture.get(5, TimeUnit.SECONDS); + assertSame(inserted, secondResult); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + } finally { + executor.shutdown(); + } + } + + @Test + void reusingAReservationForASecondCompletionThrows() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 2); + try (ConcurrentHashtable.Reservation r = ConcurrentHashtable.tryReserve(state, 1)) { + r.tryGetOrInsertOrNull(1, TestEntry::new); + assertThrows(IllegalStateException.class, () -> r.tryGetOrInsertOrNull(2, TestEntry::new)); + } + assertEquals(1, ConcurrentHashtable.estimateSize(state)); } - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - } } diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java index cd6a6aafc1e..54f6a4f3039 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableSizeManagerTest.java @@ -20,457 +20,436 @@ */ class ConcurrentHashtableSizeManagerTest { - @Test - void tryReserveSucceedsUnderCapacityAndFailsWhenFull() { - ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(2); - assertEquals(0, sizeManager.estimateSize()); - assertFalse(sizeManager.isFull()); - - assertTrue(sizeManager.tryReserve()); - assertEquals(1, sizeManager.estimateSize()); - assertFalse(sizeManager.isFull()); - - assertTrue(sizeManager.tryReserve()); - assertEquals(2, sizeManager.estimateSize()); - assertTrue(sizeManager.isFull()); - - assertFalse(sizeManager.tryReserve()); - assertEquals(2, sizeManager.estimateSize()); - } - - @Test - void tryReserveOrEvictReservesDirectlyWhenUnderCapacity() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 2); - - boolean reserved = tryReserveOrEvict(state, e -> true); - assertTrue(reserved); - assertEquals(1, state.sizeManager.estimateSize()); - assertNull(state.buckets.get(0)); // nothing was evicted - } - - @Test - void tryReserveOrEvictEvictsWhenFullAndSomethingMatches() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - TestEntry existing = insertAt(state, 0, "existing"); - assertTrue(state.sizeManager.tryReserve()); - assertTrue(state.sizeManager.isFull()); - - boolean reserved = tryReserveOrEvict(state, e -> true); - assertTrue(reserved); - assertEquals(1, state.sizeManager.estimateSize()); // one evicted, one reserved: net unchanged - assertNull(state.buckets.get(0)); // existing was unlinked - } - - @Test - void tryReserveOrEvictFailsAndLeavesTableUntouchedWhenNothingEvictable() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - TestEntry existing = insertAt(state, 0, "existing"); - assertTrue(state.sizeManager.tryReserve()); - - boolean reserved = tryReserveOrEvict(state, e -> false); - assertFalse(reserved); - assertEquals(1, state.sizeManager.estimateSize()); - assertSame(existing, state.buckets.get(0)); - } - - @Test - void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 2); - assertTrue(state.sizeManager.tryReserve()); - assertEquals(1, state.sizeManager.estimateSize()); - - TestEntry entry = new TestEntry(0, "reserved"); - ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); - lock.lock(); - try { - ConcurrentHashtable.insertReserved(state, entry.keyHash, entry); - } finally { - lock.unlock(); + @Test + void tryReserveSucceedsUnderCapacityAndFailsWhenFull() { + ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(2); + assertEquals(0, sizeManager.estimateSize()); + assertFalse(sizeManager.isFull()); + + assertTrue(sizeManager.tryReserve()); + assertEquals(1, sizeManager.estimateSize()); + assertFalse(sizeManager.isFull()); + + assertTrue(sizeManager.tryReserve()); + assertEquals(2, sizeManager.estimateSize()); + assertTrue(sizeManager.isFull()); + + assertFalse(sizeManager.tryReserve()); + assertEquals(2, sizeManager.estimateSize()); } - assertSame(entry, state.buckets.get(0)); - // Count reflects only the earlier tryReserve() -- insertReserved must not increment again. - assertEquals(1, state.sizeManager.estimateSize()); - } - - @Test - void evictOneReturnsNullAndLeavesCountUnchangedWhenNothingMatches() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 4); - insertAt(state, 0, "a"); - state.sizeManager.increment(); - - TestEntry evicted = evictOne(state, e -> false); - assertNull(evicted); - assertEquals(1, state.sizeManager.estimateSize()); - } - - @Test - void evictOneUnlinksMatchAndDecrementsCount() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 4); - TestEntry a = insertAt(state, 0, "a"); - TestEntry b = insertAt(state, 1, "b"); - state.sizeManager.increment(); - state.sizeManager.increment(); - - TestEntry evicted = evictOne(state, e -> e.label.equals("a")); - assertSame(a, evicted); - assertNull(state.buckets.get(0)); - assertSame(b, state.buckets.get(1)); // untouched - assertEquals(1, state.sizeManager.estimateSize()); - } - - /** - * Verifies the cursor-resume contract from {@link ConcurrentHashtable.SizeManager#evictOne}: each - * scan resumes where the previous eviction left off, so among several equally-matching candidates - * the one nearest (forward from the cursor, wrapping) is picked first -- not always the lowest - * bucket index. - */ - @Test - void evictOneResumesFromLastEvictedBucketAndWrapsAround() { - // Bucket-array length 4: keyHash i lands in bucket i. - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 4); - TestEntry e0 = insertAt(state, 0, "e0"); - insertAt(state, 2, "e2"); - TestEntry e3 = insertAt(state, 3, "e3"); - state.sizeManager.increment(); - state.sizeManager.increment(); - state.sizeManager.increment(); - - // First eviction: scan starts at cursor 0, finds bucket 2 first among evictable entries - // (only e2 matches here) -- sets the cursor to 2. - TestEntry firstEvicted = evictOne(state, e -> e.label.equals("e2")); - assertEquals("e2", firstEvicted.label); - - // Second eviction: both e0 (bucket 0) and e3 (bucket 3) match. Scanning resumes at the - // cursor (2) and goes forward before wrapping, so bucket 3 (e3) is found before bucket 0. - TestEntry secondEvicted = evictOne(state, e -> true); - assertSame(e3, secondEvicted); - assertSame(e0, state.buckets.get(0)); // e0 not yet touched - - // Third eviction: only e0 remains. The cursor is now past bucket 3, so the scan must wrap - // around to bucket 0 to find it. - TestEntry thirdEvicted = evictOne(state, e -> true); - assertSame(e0, thirdEvicted); - assertEquals(0, state.sizeManager.estimateSize()); - } - - @Test - void evictAllRemovesEveryMatchAndReturnsCount() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 8); - for (int i = 0; i < 6; i++) { - insertAt(state, i, "e" + i); - state.sizeManager.increment(); + @Test + void tryReserveOrEvictReservesDirectlyWhenUnderCapacity() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 2); + + boolean reserved = tryReserveOrEvict(state, e -> true); + assertTrue(reserved); + assertEquals(1, state.sizeManager.estimateSize()); + assertNull(state.buckets.get(0)); // nothing was evicted } - // Evict everything except bucket 1 and bucket 4. - int count = evictAll(state, e -> !e.label.equals("e1") && !e.label.equals("e4")); - - assertEquals(4, count); - assertEquals(2, state.sizeManager.estimateSize()); - assertNotNullLabel(state, 1, "e1"); - assertNotNullLabel(state, 4, "e4"); - for (int i : new int[] {0, 2, 3, 5}) { - assertNull(state.buckets.get(i)); + + @Test + void tryReserveOrEvictEvictsWhenFullAndSomethingMatches() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + TestEntry existing = insertAt(state, 0, "existing"); + assertTrue(state.sizeManager.tryReserve()); + assertTrue(state.sizeManager.isFull()); + + boolean reserved = tryReserveOrEvict(state, e -> true); + assertTrue(reserved); + assertEquals(1, state.sizeManager.estimateSize()); // one evicted, one reserved: net unchanged + assertNull(state.buckets.get(0)); // existing was unlinked } - } - - @Test - void evictAllResetsCursorSoSubsequentEvictOneScansFromBucketZero() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 4); - insertAt(state, 2, "a"); - state.sizeManager.increment(); - // Advance the cursor away from 0 via a successful eviction at bucket 2. - evictOne(state, e -> true); - - // A full pass that removes nothing still resets the scan position (per evictAll's contract). - int count = evictAll(state, e -> false); - assertEquals(0, count); - - TestEntry e0 = insertAt(state, 0, "e0"); - TestEntry e3 = insertAt(state, 3, "e3"); - state.sizeManager.increment(); - state.sizeManager.increment(); - - // With the cursor reset to 0, the forward scan reaches bucket 0 before bucket 3. - TestEntry evicted = evictOne(state, e -> true); - assertSame(e0, evicted); - assertSame(e3, state.buckets.get(3)); - } - - @Test - void releaseGivesBackRemovedSlotsAndRestartsScan() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 4); - insertAt(state, 2, "a"); - state.sizeManager.increment(); - evictOne(state, e -> true); // advances the cursor to 2, count back to 0 - state.sizeManager.increment(); // pretend a fresh entry was inserted - - // One entry is live and counted; releasing that one slot brings the count back to zero and - // restarts the scan -- unlike a blanket zeroing, this only gives back what was removed. - state.sizeManager.release(1); - assertEquals(0, state.sizeManager.estimateSize()); - - TestEntry e0 = insertAt(state, 0, "e0"); - TestEntry e3 = insertAt(state, 3, "e3"); - state.sizeManager.increment(); - state.sizeManager.increment(); - TestEntry evicted = evictOne(state, e -> true); - assertSame(e0, evicted); // scan restarted from bucket 0, per release() - assertSame(e3, state.buckets.get(3)); - } - - @Test - void stateCreateCappedBundlesBucketsAndSizeManager() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 3); - assertEquals(0, state.sizeManager.estimateSize()); - assertEquals(3, state.sizeManager.capacity()); - assertTrue(state.buckets.length() >= 3); - } - - @Test - void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - ReentrantLock lockAt0 = ConcurrentHashtable.getWriteLockAt(state, 0); - lockAt0.lock(); - try { - ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "a")); - } finally { - lockAt0.unlock(); + + @Test + void tryReserveOrEvictFailsAndLeavesTableUntouchedWhenNothingEvictable() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + TestEntry existing = insertAt(state, 0, "existing"); + assertTrue(state.sizeManager.tryReserve()); + + boolean reserved = tryReserveOrEvict(state, e -> false); + assertFalse(reserved); + assertEquals(1, state.sizeManager.estimateSize()); + assertSame(existing, state.buckets.get(0)); } - state.sizeManager.increment(); - assertTrue(ConcurrentHashtable.isFull(state)); - - // Table is full: tryReserveOrEvict evicts "a" and reserves the freed slot for the caller, who - // is now responsible for splicing in the entry that occupies it -- via insertReserved, since - // the reservation already happened and a plain insertHeadEntryAt/increment would double-count. - // Both steps go in ONE critical section: tryReserveOrEvict is self-locking, so on its own it - // leaves a window where a drain/clear could reset the count out from under the reservation. - ReentrantLock tableLock = ConcurrentHashtable.getTableWriteLock(state); - tableLock.lock(); - try { - boolean reserved = ConcurrentHashtable.tryReserveOrEvict(state, e -> true); - assertTrue(reserved); - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - assertNull(state.buckets.get(0)); // "a" was evicted; the reserved slot has no entry yet - ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); - } finally { - tableLock.unlock(); + + @Test + void insertReservedSplicesWithoutTouchingTheCountAfterATryReserve() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 2); + assertTrue(state.sizeManager.tryReserve()); + assertEquals(1, state.sizeManager.estimateSize()); + + TestEntry entry = new TestEntry(0, "reserved"); + ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); + lock.lock(); + try { + ConcurrentHashtable.insertReserved(state, entry.keyHash, entry); + } finally { + lock.unlock(); + } + + assertSame(entry, state.buckets.get(0)); + // Count reflects only the earlier tryReserve() -- insertReserved must not increment again. + assertEquals(1, state.sizeManager.estimateSize()); } - int evicted = ConcurrentHashtable.evictAll(state, e -> true); - assertEquals(1, evicted); - assertEquals(0, ConcurrentHashtable.estimateSize(state)); - assertFalse(ConcurrentHashtable.isFull(state)); + @Test + void evictOneReturnsNullAndLeavesCountUnchangedWhenNothingMatches() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 4); + insertAt(state, 0, "a"); + state.sizeManager.increment(); - lockAt0.lock(); - try { - ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "b")); - } finally { - lockAt0.unlock(); + TestEntry evicted = evictOne(state, e -> false); + assertNull(evicted); + assertEquals(1, state.sizeManager.estimateSize()); } - state.sizeManager.increment(); - TestEntry viaEvictOne = ConcurrentHashtable.evictOne(state, e -> e.label.equals("b")); - assertNotNull(viaEvictOne); - assertEquals("b", viaEvictOne.label); - assertEquals(0, ConcurrentHashtable.estimateSize(state)); - } - - /** - * Holding the table lock across {@code tryReserveOrEvict} + {@code insertReserved} still keeps a - * concurrent {@link ConcurrentHashtable#clear(ConcurrentHashtable.State)} out of the gap -- the - * belt-and-braces version of the protocol. {@link - * #reservationSurvivesAClearLandingBetweenReserveAndInsert()} covers the case that matters more - * now: the pair does not actually need one critical section. - */ - @Test - void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedException { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 1); - insertAt(state, 0, "a"); - state.sizeManager.increment(); - assertTrue(ConcurrentHashtable.isFull(state)); - - Thread clearer = new Thread(() -> ConcurrentHashtable.clear(state), "clearer"); - ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); - lock.lock(); - try { - clearer.start(); - // Wait until the clear is definitely queued on the lock we hold, so the interleaving under - // test is the one actually attempted rather than one the scheduler happened to avoid. - new PollingConditions() - .eventually(() -> assertEquals(Thread.State.WAITING, clearer.getState())); - - assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> true)); - ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - } finally { - lock.unlock(); + + @Test + void evictOneUnlinksMatchAndDecrementsCount() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 4); + TestEntry a = insertAt(state, 0, "a"); + TestEntry b = insertAt(state, 1, "b"); + state.sizeManager.increment(); + state.sizeManager.increment(); + + TestEntry evicted = evictOne(state, e -> e.label.equals("a")); + assertSame(a, evicted); + assertNull(state.buckets.get(0)); + assertSame(b, state.buckets.get(1)); // untouched + assertEquals(1, state.sizeManager.estimateSize()); } - clearer.join(); - - // The clear ran strictly after the pair, so the table is exactly post-clear: no entry, no - // count, and -- the point -- the two agree. - assertEquals(0, ConcurrentHashtable.estimateSize(state)); - assertNull(state.buckets.get(0)); - assertFalse(ConcurrentHashtable.isFull(state)); - } - - /** - * A clear landing squarely between the reservation and the insert must not void the reservation. - * It cannot, because {@link ConcurrentHashtable.SizeManager#release(int)} subtracts what the - * sweep removed instead of zeroing the count -- so the claim taken before the clear is still a - * claim after it, and the entry the caller then links is accounted for. - * - *

Zeroing would leave the count one below reality here, permanently: eviction decrements too, - * so nothing later repairs it, and a capped table admits one extra entry from then on. - */ - @Test - void reservationSurvivesAClearLandingBetweenReserveAndInsert() { - ConcurrentHashtable.State state = - ConcurrentHashtable.createBounded(TestEntry.class, 2); - insertAt(state, 0, "a"); - state.sizeManager.increment(); - - // Reserve, with no lock held across what follows. - assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> false)); - assertEquals(2, ConcurrentHashtable.estimateSize(state)); // "a" plus our reservation - - // The sweep lands in the gap, removing the one entry that exists. Our slot is not its to give - // back, so the count drops by exactly one. - ConcurrentHashtable.clear(state); - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - - // The reservation is still good, and filling it leaves the count matching the entries present. - ReentrantLock lockAt0 = ConcurrentHashtable.getWriteLockAt(state, 0); - lockAt0.lock(); - try { - ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); - } finally { - lockAt0.unlock(); + + /** + * Verifies the cursor-resume contract from {@link ConcurrentHashtable.SizeManager#evictOne}: each + * scan resumes where the previous eviction left off, so among several equally-matching candidates + * the one nearest (forward from the cursor, wrapping) is picked first -- not always the lowest + * bucket index. + */ + @Test + void evictOneResumesFromLastEvictedBucketAndWrapsAround() { + // Bucket-array length 4: keyHash i lands in bucket i. + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 4); + TestEntry e0 = insertAt(state, 0, "e0"); + insertAt(state, 2, "e2"); + TestEntry e3 = insertAt(state, 3, "e3"); + state.sizeManager.increment(); + state.sizeManager.increment(); + state.sizeManager.increment(); + + // First eviction: scan starts at cursor 0, finds bucket 2 first among evictable entries + // (only e2 matches here) -- sets the cursor to 2. + TestEntry firstEvicted = evictOne(state, e -> e.label.equals("e2")); + assertEquals("e2", firstEvicted.label); + + // Second eviction: both e0 (bucket 0) and e3 (bucket 3) match. Scanning resumes at the + // cursor (2) and goes forward before wrapping, so bucket 3 (e3) is found before bucket 0. + TestEntry secondEvicted = evictOne(state, e -> true); + assertSame(e3, secondEvicted); + assertSame(e0, state.buckets.get(0)); // e0 not yet touched + + // Third eviction: only e0 remains. The cursor is now past bucket 3, so the scan must wrap + // around to bucket 0 to find it. + TestEntry thirdEvicted = evictOne(state, e -> true); + assertSame(e0, thirdEvicted); + assertEquals(0, state.sizeManager.estimateSize()); } - assertEquals(1, ConcurrentHashtable.estimateSize(state)); - assertNotNullLabel(state, 0, "reserved"); - assertFalse(ConcurrentHashtable.isFull(state)); - } - - /** - * Two reservers racing at the cap cannot both get through: {@code tryReserve} claims first and - * refunds on overshoot, so the count is never left above {@link - * ConcurrentHashtable.SizeManager#capacity()} once both have finished -- and it achieves that - * without a lock. - */ - @Test - void concurrentReserversCannotBothPassTheCap() throws InterruptedException { - for (int attempt = 0; attempt < 200; attempt++) { - ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(1); - java.util.concurrent.atomic.AtomicInteger granted = - new java.util.concurrent.atomic.AtomicInteger(); - java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); - Runnable reserve = - () -> { - try { - start.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - if (sizeManager.tryReserve()) { - granted.incrementAndGet(); - } - }; - Thread t1 = new Thread(reserve, "reserve-1"); - Thread t2 = new Thread(reserve, "reserve-2"); - t1.start(); - t2.start(); - start.countDown(); - t1.join(); - t2.join(); - - assertEquals(1, granted.get(), "exactly one reserver should win a capacity of 1"); - assertEquals(1, sizeManager.estimateSize()); + + @Test + void evictAllRemovesEveryMatchAndReturnsCount() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 8); + for (int i = 0; i < 6; i++) { + insertAt(state, i, "e" + i); + state.sizeManager.increment(); + } + // Evict everything except bucket 1 and bucket 4. + int count = evictAll(state, e -> !e.label.equals("e1") && !e.label.equals("e4")); + + assertEquals(4, count); + assertEquals(2, state.sizeManager.estimateSize()); + assertNotNullLabel(state, 1, "e1"); + assertNotNullLabel(state, 4, "e4"); + for (int i : new int[] {0, 2, 3, 5}) { + assertNull(state.buckets.get(i)); + } } - } - - private static void assertNotNullLabel( - ConcurrentHashtable.State state, int index, String label) { - TestEntry e = state.buckets.get(index); - assertNotNull(e); - assertEquals(label, e.label); - } - - /** Inserts a fresh entry at the given bucket index. Bucket-array length must exceed index. */ - private static TestEntry insertAt( - ConcurrentHashtable.State state, int index, String label) { - TestEntry entry = new TestEntry(index, label); - ReentrantLock lock = ConcurrentHashtable.getWriteLockAt(state, index); - lock.lock(); - try { - ConcurrentHashtable.insertHeadEntryAt(state, index, entry); - } finally { - lock.unlock(); + + @Test + void evictAllResetsCursorSoSubsequentEvictOneScansFromBucketZero() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 4); + insertAt(state, 2, "a"); + state.sizeManager.increment(); + // Advance the cursor away from 0 via a successful eviction at bucket 2. + evictOne(state, e -> true); + + // A full pass that removes nothing still resets the scan position (per evictAll's contract). + int count = evictAll(state, e -> false); + assertEquals(0, count); + + TestEntry e0 = insertAt(state, 0, "e0"); + TestEntry e3 = insertAt(state, 3, "e3"); + state.sizeManager.increment(); + state.sizeManager.increment(); + + // With the cursor reset to 0, the forward scan reaches bucket 0 before bucket 3. + TestEntry evicted = evictOne(state, e -> true); + assertSame(e0, evicted); + assertSame(e3, state.buckets.get(3)); } - return entry; - } - - /** {@code sizeManager.tryReserveOrEvict}, taking the write lock {@code @GuardedBy} requires. */ - private static boolean tryReserveOrEvict( - ConcurrentHashtable.State state, Predicate evictable) { - ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); - lock.lock(); - try { - return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); - } finally { - lock.unlock(); + + @Test + void releaseGivesBackRemovedSlotsAndRestartsScan() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 4); + insertAt(state, 2, "a"); + state.sizeManager.increment(); + evictOne(state, e -> true); // advances the cursor to 2, count back to 0 + state.sizeManager.increment(); // pretend a fresh entry was inserted + + // One entry is live and counted; releasing that one slot brings the count back to zero and + // restarts the scan -- unlike a blanket zeroing, this only gives back what was removed. + state.sizeManager.release(1); + assertEquals(0, state.sizeManager.estimateSize()); + + TestEntry e0 = insertAt(state, 0, "e0"); + TestEntry e3 = insertAt(state, 3, "e3"); + state.sizeManager.increment(); + state.sizeManager.increment(); + TestEntry evicted = evictOne(state, e -> true); + assertSame(e0, evicted); // scan restarted from bucket 0, per release() + assertSame(e3, state.buckets.get(3)); } - } - - /** {@code sizeManager.evictOne}, taking the write lock {@code @GuardedBy} requires. */ - private static TestEntry evictOne( - ConcurrentHashtable.State state, Predicate evictable) { - ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); - lock.lock(); - try { - return state.sizeManager.evictOne(state.buckets, evictable); - } finally { - lock.unlock(); + + @Test + void stateCreateCappedBundlesBucketsAndSizeManager() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 3); + assertEquals(0, state.sizeManager.estimateSize()); + assertEquals(3, state.sizeManager.capacity()); + assertTrue(state.buckets.length() >= 3); } - } - - /** {@code sizeManager.evictAll}, taking the write lock {@code @GuardedBy} requires. */ - private static int evictAll( - ConcurrentHashtable.State state, Predicate evictable) { - ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); - lock.lock(); - try { - return state.sizeManager.evictAll(state.buckets, evictable); - } finally { - lock.unlock(); + + @Test + void stateLevelTryReserveOrEvictAndEvictOneAndEvictAllDelegateToSizeManager() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + ReentrantLock lockAt0 = ConcurrentHashtable.getWriteLockAt(state, 0); + lockAt0.lock(); + try { + ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "a")); + } finally { + lockAt0.unlock(); + } + state.sizeManager.increment(); + assertTrue(ConcurrentHashtable.isFull(state)); + + // Table is full: tryReserveOrEvict evicts "a" and reserves the freed slot for the caller, who + // is now responsible for splicing in the entry that occupies it -- via insertReserved, since + // the reservation already happened and a plain insertHeadEntryAt/increment would double-count. + // Both steps go in ONE critical section: tryReserveOrEvict is self-locking, so on its own it + // leaves a window where a drain/clear could reset the count out from under the reservation. + ReentrantLock tableLock = ConcurrentHashtable.getTableWriteLock(state); + tableLock.lock(); + try { + boolean reserved = ConcurrentHashtable.tryReserveOrEvict(state, e -> true); + assertTrue(reserved); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + assertNull(state.buckets.get(0)); // "a" was evicted; the reserved slot has no entry yet + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); + } finally { + tableLock.unlock(); + } + + int evicted = ConcurrentHashtable.evictAll(state, e -> true); + assertEquals(1, evicted); + assertEquals(0, ConcurrentHashtable.estimateSize(state)); + assertFalse(ConcurrentHashtable.isFull(state)); + + lockAt0.lock(); + try { + ConcurrentHashtable.insertHeadEntryAt(state, 0, new TestEntry(0, "b")); + } finally { + lockAt0.unlock(); + } + state.sizeManager.increment(); + TestEntry viaEvictOne = ConcurrentHashtable.evictOne(state, e -> e.label.equals("b")); + assertNotNull(viaEvictOne); + assertEquals("b", viaEvictOne.label); + assertEquals(0, ConcurrentHashtable.estimateSize(state)); } - } - /** Entry with a caller-controlled {@code keyHash} so tests can place it in an exact bucket. */ - private static final class TestEntry extends ConcurrentHashtable.Entry { - final String label; + /** + * Holding the table lock across {@code tryReserveOrEvict} + {@code insertReserved} still keeps a + * concurrent {@link ConcurrentHashtable#clear(ConcurrentHashtable.State)} out of the gap -- the + * belt-and-braces version of the protocol. {@link + * #reservationSurvivesAClearLandingBetweenReserveAndInsert()} covers the case that matters more + * now: the pair does not actually need one critical section. + */ + @Test + void clearCannotInterleaveBetweenReservationAndInsert() throws InterruptedException { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 1); + insertAt(state, 0, "a"); + state.sizeManager.increment(); + assertTrue(ConcurrentHashtable.isFull(state)); + + Thread clearer = new Thread(() -> ConcurrentHashtable.clear(state), "clearer"); + ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); + lock.lock(); + try { + clearer.start(); + // Wait until the clear is definitely queued on the lock we hold, so the interleaving under + // test is the one actually attempted rather than one the scheduler happened to avoid. + new PollingConditions().eventually(() -> assertEquals(Thread.State.WAITING, clearer.getState())); + + assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> true)); + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + } finally { + lock.unlock(); + } + clearer.join(); + + // The clear ran strictly after the pair, so the table is exactly post-clear: no entry, no + // count, and -- the point -- the two agree. + assertEquals(0, ConcurrentHashtable.estimateSize(state)); + assertNull(state.buckets.get(0)); + assertFalse(ConcurrentHashtable.isFull(state)); + } - TestEntry(long keyHash, String label) { - super(keyHash); - this.label = label; + /** + * A clear landing squarely between the reservation and the insert must not void the reservation. + * It cannot, because {@link ConcurrentHashtable.SizeManager#release(int)} subtracts what the + * sweep removed instead of zeroing the count -- so the claim taken before the clear is still a + * claim after it, and the entry the caller then links is accounted for. + * + *

Zeroing would leave the count one below reality here, permanently: eviction decrements too, + * so nothing later repairs it, and a capped table admits one extra entry from then on. + */ + @Test + void reservationSurvivesAClearLandingBetweenReserveAndInsert() { + ConcurrentHashtable.State state = ConcurrentHashtable.createBounded(TestEntry.class, 2); + insertAt(state, 0, "a"); + state.sizeManager.increment(); + + // Reserve, with no lock held across what follows. + assertTrue(ConcurrentHashtable.tryReserveOrEvict(state, e -> false)); + assertEquals(2, ConcurrentHashtable.estimateSize(state)); // "a" plus our reservation + + // The sweep lands in the gap, removing the one entry that exists. Our slot is not its to give + // back, so the count drops by exactly one. + ConcurrentHashtable.clear(state); + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + + // The reservation is still good, and filling it leaves the count matching the entries present. + ReentrantLock lockAt0 = ConcurrentHashtable.getWriteLockAt(state, 0); + lockAt0.lock(); + try { + ConcurrentHashtable.insertReserved(state, 0, new TestEntry(0, "reserved")); + } finally { + lockAt0.unlock(); + } + assertEquals(1, ConcurrentHashtable.estimateSize(state)); + assertNotNullLabel(state, 0, "reserved"); + assertFalse(ConcurrentHashtable.isFull(state)); } - @Override - public boolean matches(TestEntry other) { - return label.equals(other.label); + /** + * Two reservers racing at the cap cannot both get through: {@code tryReserve} claims first and + * refunds on overshoot, so the count is never left above {@link + * ConcurrentHashtable.SizeManager#capacity()} once both have finished -- and it achieves that + * without a lock. + */ + @Test + void concurrentReserversCannotBothPassTheCap() throws InterruptedException { + for (int attempt = 0; attempt < 200; attempt++) { + ConcurrentHashtable.SizeManager sizeManager = new ConcurrentHashtable.SizeManager(1); + java.util.concurrent.atomic.AtomicInteger granted = new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + Runnable reserve = () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (sizeManager.tryReserve()) { + granted.incrementAndGet(); + } + }; + Thread t1 = new Thread(reserve, "reserve-1"); + Thread t2 = new Thread(reserve, "reserve-2"); + t1.start(); + t2.start(); + start.countDown(); + t1.join(); + t2.join(); + + assertEquals(1, granted.get(), "exactly one reserver should win a capacity of 1"); + assertEquals(1, sizeManager.estimateSize()); + } + } + + private static void assertNotNullLabel(ConcurrentHashtable.State state, int index, String label) { + TestEntry e = state.buckets.get(index); + assertNotNull(e); + assertEquals(label, e.label); + } + + /** Inserts a fresh entry at the given bucket index. Bucket-array length must exceed index. */ + private static TestEntry insertAt(ConcurrentHashtable.State state, int index, String label) { + TestEntry entry = new TestEntry(index, label); + ReentrantLock lock = ConcurrentHashtable.getWriteLockAt(state, index); + lock.lock(); + try { + ConcurrentHashtable.insertHeadEntryAt(state, index, entry); + } finally { + lock.unlock(); + } + return entry; + } + + /** {@code sizeManager.tryReserveOrEvict}, taking the write lock {@code @GuardedBy} requires. */ + private static boolean tryReserveOrEvict( + ConcurrentHashtable.State state, Predicate evictable) { + ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); + lock.lock(); + try { + return state.sizeManager.tryReserveOrEvict(state.buckets, evictable); + } finally { + lock.unlock(); + } + } + + /** {@code sizeManager.evictOne}, taking the write lock {@code @GuardedBy} requires. */ + private static TestEntry evictOne(ConcurrentHashtable.State state, Predicate evictable) { + ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); + lock.lock(); + try { + return state.sizeManager.evictOne(state.buckets, evictable); + } finally { + lock.unlock(); + } + } + + /** {@code sizeManager.evictAll}, taking the write lock {@code @GuardedBy} requires. */ + private static int evictAll(ConcurrentHashtable.State state, Predicate evictable) { + ReentrantLock lock = ConcurrentHashtable.getTableWriteLock(state); + lock.lock(); + try { + return state.sizeManager.evictAll(state.buckets, evictable); + } finally { + lock.unlock(); + } + } + + /** Entry with a caller-controlled {@code keyHash} so tests can place it in an exact bucket. */ + private static final class TestEntry extends ConcurrentHashtable.Entry { + final String label; + + TestEntry(long keyHash, String label) { + super(keyHash); + this.label = label; + } + + @Override + public boolean matches(TestEntry other) { + return label.equals(other.label); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java index 8be6e791b9e..ee5074a8829 100644 --- a/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/ConcurrentHashtableStaticsTest.java @@ -26,409 +26,389 @@ */ class ConcurrentHashtableStaticsTest { - @Test - void sizeForRoundsUpToPowerOfTwo() { - assertEquals(1, ConcurrentHashtable.sizeFor(1)); - assertEquals(8, ConcurrentHashtable.sizeFor(5)); - assertEquals(8, ConcurrentHashtable.sizeFor(8)); - assertEquals(16, ConcurrentHashtable.sizeFor(9)); - } - - @Test - void createFixedBucketsAllocatesPowerOfTwoSpine() { - AtomicReferenceArray buckets = - ConcurrentHashtable.createFixedBuckets(IntEntry.class, 10); - assertEquals(16, buckets.length()); - assertNull(buckets.get(0)); - } - - @Test - void getWriteLockIsStableAndNonNull() { - AtomicReferenceArray buckets = - ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); - Object lock = ConcurrentHashtable.getWriteLock(buckets, 3L); - assertNotNull(lock); - assertSame(lock, ConcurrentHashtable.getWriteLock(buckets, 3L)); - // The keyHash form is defined as the index form under bucketIndex. - assertSame( - lock, - ConcurrentHashtable.getWriteLockAt(buckets, ConcurrentHashtable.bucketIndex(buckets, 3L))); - } - - /** - * The three accessors name three scopes, but a single-lock table answers all of them with one - * monitor. Asserting that pins today's granularity as a deliberate choice rather than an - * accident: if it ever changes, this is the test that says so, and callers that asked for the - * scope they actually mutate keep working. - */ - @Test - void allWriteLockScopesResolveToOneMonitorToday() { - AtomicReferenceArray buckets = - ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); - Object table = ConcurrentHashtable.getTableWriteLock(buckets); - assertNotNull(table); - for (int index = 0; index < buckets.length(); index++) { - assertSame(table, ConcurrentHashtable.getWriteLockAt(buckets, index)); - assertSame(table, ConcurrentHashtable.getWriteLock(buckets, index)); + @Test + void sizeForRoundsUpToPowerOfTwo() { + assertEquals(1, ConcurrentHashtable.sizeFor(1)); + assertEquals(8, ConcurrentHashtable.sizeFor(5)); + assertEquals(8, ConcurrentHashtable.sizeFor(8)); + assertEquals(16, ConcurrentHashtable.sizeFor(9)); } - } - - @Test - void bucketIndexMasksToArrayLength() { - AtomicReferenceArray buckets = - ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // length 8, mask 7 - assertEquals(0, ConcurrentHashtable.bucketIndex(buckets, 8L)); - assertEquals(1, ConcurrentHashtable.bucketIndex(buckets, 9L)); - assertEquals(7, ConcurrentHashtable.bucketIndex(buckets, 7L)); - } - - @Test - void insertGetAndRemoveViaStatics() { - IntTable table = new IntTable(8); - IntEntry a = table.getOrCreate(1, 100); - IntEntry b = table.getOrCreate(2, 200); - assertEquals(2, table.size.get()); - assertSame(a, table.get(1)); - assertSame(b, table.get(2)); - assertNull(table.get(3)); - - // getOrCreate on a hit returns the existing entry, no new insert. - assertSame(a, table.getOrCreate(1, 999)); - assertEquals(2, table.size.get()); - - assertSame(a, table.remove(1)); - assertNull(table.get(1)); - assertNull(table.remove(1)); // already gone - assertEquals(1, table.size.get()); - } - - @Test - void insertHeadEntryForPlacesInBucketMaskedFromKeyHash() { - AtomicReferenceArray buckets = - ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // mask 7 - IntEntry e = new IntEntry(9, 1); // keyHash 9 → bucket 1 - synchronized (ConcurrentHashtable.getWriteLock(buckets, e.keyHash)) { - ConcurrentHashtable.insertHeadEntryFor(buckets, e.keyHash, e); + + @Test + void createFixedBucketsAllocatesPowerOfTwoSpine() { + AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 10); + assertEquals(16, buckets.length()); + assertNull(buckets.get(0)); + } + + @Test + void getWriteLockIsStableAndNonNull() { + AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + Object lock = ConcurrentHashtable.getWriteLock(buckets, 3L); + assertNotNull(lock); + assertSame(lock, ConcurrentHashtable.getWriteLock(buckets, 3L)); + // The keyHash form is defined as the index form under bucketIndex. + assertSame(lock, ConcurrentHashtable.getWriteLockAt(buckets, ConcurrentHashtable.bucketIndex(buckets, 3L))); } - assertSame(e, ConcurrentHashtable.bucketFor(buckets, 9L)); // masks keyHash to the bucket index - assertSame( - e, ConcurrentHashtable.bucketAt(buckets, 1)); // same slot, addressed directly by index - assertNull(buckets.get(0)); - } - - @Test - void unlinkRemovesHeadMiddleAndTailOfChain() { - // Capacity 1 → mask 0 → every key lands in bucket 0, forming one chain. - IntTable table = new IntTable(1); - IntEntry a = table.getOrCreate(1, 1); - IntEntry b = table.getOrCreate(2, 2); - IntEntry c = table.getOrCreate(3, 3); - assertEquals(3, table.size.get()); - - // Remove the middle: head and tail stay reachable. - assertSame(b, table.remove(2)); - assertNull(table.get(2)); - assertSame(a, table.get(1)); - assertSame(c, table.get(3)); - assertEquals(2, table.size.get()); - - // Remove the head, then the last remaining. - assertSame(c, table.remove(3)); - assertSame(a, table.remove(1)); - assertEquals(0, table.size.get()); - assertNull(table.get(1)); - } - - @Test - void staticRemoveIfRemovesMatchingAndDecrementsSize() { - IntTable table = new IntTable(16); - for (int i = 0; i < 10; i++) { - table.getOrCreate(i, i); + + /** + * The three accessors name three scopes, but a single-lock table answers all of them with one + * monitor. Asserting that pins today's granularity as a deliberate choice rather than an + * accident: if it ever changes, this is the test that says so, and callers that asked for the + * scope they actually mutate keep working. + */ + @Test + void allWriteLockScopesResolveToOneMonitorToday() { + AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + Object table = ConcurrentHashtable.getTableWriteLock(buckets); + assertNotNull(table); + for (int index = 0; index < buckets.length(); index++) { + assertSame(table, ConcurrentHashtable.getWriteLockAt(buckets, index)); + assertSame(table, ConcurrentHashtable.getWriteLock(buckets, index)); + } + } + + @Test + void bucketIndexMasksToArrayLength() { + AtomicReferenceArray buckets = + ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // length 8, mask 7 + assertEquals(0, ConcurrentHashtable.bucketIndex(buckets, 8L)); + assertEquals(1, ConcurrentHashtable.bucketIndex(buckets, 9L)); + assertEquals(7, ConcurrentHashtable.bucketIndex(buckets, 7L)); + } + + @Test + void insertGetAndRemoveViaStatics() { + IntTable table = new IntTable(8); + IntEntry a = table.getOrCreate(1, 100); + IntEntry b = table.getOrCreate(2, 200); + assertEquals(2, table.size.get()); + assertSame(a, table.get(1)); + assertSame(b, table.get(2)); + assertNull(table.get(3)); + + // getOrCreate on a hit returns the existing entry, no new insert. + assertSame(a, table.getOrCreate(1, 999)); + assertEquals(2, table.size.get()); + + assertSame(a, table.remove(1)); + assertNull(table.get(1)); + assertNull(table.remove(1)); // already gone + assertEquals(1, table.size.get()); } - boolean removed = - ConcurrentHashtable.removeIf(table.buckets, table.size, e -> e.value % 2 == 0); - assertTrue(removed); - assertEquals(5, table.size.get()); - for (int i = 0; i < 10; i++) { - if (i % 2 == 0) { - assertNull(table.get(i)); - } else { - assertNotNull(table.get(i)); - } + + @Test + void insertHeadEntryForPlacesInBucketMaskedFromKeyHash() { + AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); // mask 7 + IntEntry e = new IntEntry(9, 1); // keyHash 9 → bucket 1 + synchronized (ConcurrentHashtable.getWriteLock(buckets, e.keyHash)) { + ConcurrentHashtable.insertHeadEntryFor(buckets, e.keyHash, e); + } + assertSame(e, ConcurrentHashtable.bucketFor(buckets, 9L)); // masks keyHash to the bucket index + assertSame(e, ConcurrentHashtable.bucketAt(buckets, 1)); // same slot, addressed directly by index + assertNull(buckets.get(0)); + } + + @Test + void unlinkRemovesHeadMiddleAndTailOfChain() { + // Capacity 1 → mask 0 → every key lands in bucket 0, forming one chain. + IntTable table = new IntTable(1); + IntEntry a = table.getOrCreate(1, 1); + IntEntry b = table.getOrCreate(2, 2); + IntEntry c = table.getOrCreate(3, 3); + assertEquals(3, table.size.get()); + + // Remove the middle: head and tail stay reachable. + assertSame(b, table.remove(2)); + assertNull(table.get(2)); + assertSame(a, table.get(1)); + assertSame(c, table.get(3)); + assertEquals(2, table.size.get()); + + // Remove the head, then the last remaining. + assertSame(c, table.remove(3)); + assertSame(a, table.remove(1)); + assertEquals(0, table.size.get()); + assertNull(table.get(1)); + } + + @Test + void staticRemoveIfRemovesMatchingAndDecrementsSize() { + IntTable table = new IntTable(16); + for (int i = 0; i < 10; i++) { + table.getOrCreate(i, i); + } + boolean removed = ConcurrentHashtable.removeIf(table.buckets, table.size, e -> e.value % 2 == 0); + assertTrue(removed); + assertEquals(5, table.size.get()); + for (int i = 0; i < 10; i++) { + if (i % 2 == 0) { + assertNull(table.get(i)); + } else { + assertNotNull(table.get(i)); + } + } } - } - - @Test - void staticRemoveIfReturnsFalseWhenNothingMatches() { - IntTable table = new IntTable(8); - table.getOrCreate(1, 1); - assertFalse(ConcurrentHashtable.removeIf(table.buckets, table.size, e -> false)); - assertEquals(1, table.size.get()); - } - - @Test - void staticDrainRemovesEveryEntryAndFeedsSink() { - IntTable table = new IntTable(8); - table.getOrCreate(1, 10); - table.getOrCreate(2, 20); - table.getOrCreate(3, 30); - - Set keys = new HashSet<>(); - int[] sum = {0}; - ConcurrentHashtable.drain( - table.buckets, - e -> { - keys.add(e.key); - sum[0] += e.value; + + @Test + void staticRemoveIfReturnsFalseWhenNothingMatches() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + assertFalse(ConcurrentHashtable.removeIf(table.buckets, table.size, e -> false)); + assertEquals(1, table.size.get()); + } + + @Test + void staticDrainRemovesEveryEntryAndFeedsSink() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 10); + table.getOrCreate(2, 20); + table.getOrCreate(3, 30); + + Set keys = new HashSet<>(); + int[] sum = {0}; + ConcurrentHashtable.drain(table.buckets, e -> { + keys.add(e.key); + sum[0] += e.value; }); - assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), keys); - assertEquals(60, sum[0]); - // drain does not touch size accounting on the static path — the caller resets it. - for (int i = 1; i <= 3; i++) { - assertNull(table.get(i)); + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), keys); + assertEquals(60, sum[0]); + // drain does not touch size accounting on the static path — the caller resets it. + for (int i = 1; i <= 3; i++) { + assertNull(table.get(i)); + } + } + + @Test + void staticDrainWithContextFeedsSink() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 10); + table.getOrCreate(2, 20); + + Set keys = new HashSet<>(); + ConcurrentHashtable.drain(table.buckets, keys, (ctx, e) -> ctx.add(e.key)); + + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2)), keys); + assertNull(table.get(1)); } - } - - @Test - void staticDrainWithContextFeedsSink() { - IntTable table = new IntTable(8); - table.getOrCreate(1, 10); - table.getOrCreate(2, 20); - - Set keys = new HashSet<>(); - ConcurrentHashtable.drain(table.buckets, keys, (ctx, e) -> ctx.add(e.key)); - - assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2)), keys); - assertNull(table.get(1)); - } - - @Test - void staticClearEmptiesEveryBucket() { - IntTable table = new IntTable(8); - table.getOrCreate(1, 1); - table.getOrCreate(2, 2); - ConcurrentHashtable.clear(table.buckets); - assertNull(table.get(1)); - assertNull(table.get(2)); - for (int i = 0; i < table.buckets.length(); i++) { - assertNull(table.buckets.get(i)); + + @Test + void staticClearEmptiesEveryBucket() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + table.getOrCreate(2, 2); + ConcurrentHashtable.clear(table.buckets); + assertNull(table.get(1)); + assertNull(table.get(2)); + for (int i = 0; i < table.buckets.length(); i++) { + assertNull(table.buckets.get(i)); + } + } + + @Test + void staticForEachVisitsEveryEntry() { + IntTable table = new IntTable(8); + table.getOrCreate(1, 1); + table.getOrCreate(2, 2); + table.getOrCreate(3, 3); + + Set seen = new HashSet<>(); + ConcurrentHashtable.forEach(table.buckets, e -> seen.add(e.key)); + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seen); + + Set seenCtx = new HashSet<>(); + ConcurrentHashtable.forEach(table.buckets, seenCtx, (ctx, e) -> ctx.add(e.key)); + assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seenCtx); } - } - - @Test - void staticForEachVisitsEveryEntry() { - IntTable table = new IntTable(8); - table.getOrCreate(1, 1); - table.getOrCreate(2, 2); - table.getOrCreate(3, 3); - - Set seen = new HashSet<>(); - ConcurrentHashtable.forEach(table.buckets, e -> seen.add(e.key)); - assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seen); - - Set seenCtx = new HashSet<>(); - ConcurrentHashtable.forEach(table.buckets, seenCtx, (ctx, e) -> ctx.add(e.key)); - assertEquals(new HashSet<>(java.util.Arrays.asList(1, 2, 3)), seenCtx); - } - - @Test - void insertHeadEntryWithoutLockTripsAssertion() { - assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); - AtomicReferenceArray buckets = - ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); - assertThrows( - AssertionError.class, - () -> ConcurrentHashtable.insertHeadEntryAt(buckets, 0, new IntEntry(1, 1))); - } - - @Test - void unlinkWithoutLockTripsAssertion() { - assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); - AtomicReferenceArray buckets = - ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); - IntEntry e = new IntEntry(1, 1); - synchronized (ConcurrentHashtable.getWriteLockAt(buckets, 0)) { - ConcurrentHashtable.insertHeadEntryAt(buckets, 0, e); + + @Test + void insertHeadEntryWithoutLockTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + assertThrows(AssertionError.class, () -> ConcurrentHashtable.insertHeadEntryAt(buckets, 0, new IntEntry(1, 1))); } - assertThrows(AssertionError.class, () -> ConcurrentHashtable.unlink(buckets, 0, null, e)); - } - - @Test - void concurrentGetOrCreateViaStaticsProducesExactlyOneEntry() throws InterruptedException { - IntTable table = new IntTable(8); - int threads = 16; - CountDownLatch ready = new CountDownLatch(threads); - CountDownLatch go = new CountDownLatch(1); - AtomicInteger createCount = new AtomicInteger(); - - Thread[] workers = new Thread[threads]; - for (int i = 0; i < threads; i++) { - workers[i] = - new Thread( - () -> { + + @Test + void unlinkWithoutLockTripsAssertion() { + assumeTrue(assertionsEnabled(), "assert-guard test requires -ea"); + AtomicReferenceArray buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, 8); + IntEntry e = new IntEntry(1, 1); + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, 0)) { + ConcurrentHashtable.insertHeadEntryAt(buckets, 0, e); + } + assertThrows(AssertionError.class, () -> ConcurrentHashtable.unlink(buckets, 0, null, e)); + } + + @Test + void concurrentGetOrCreateViaStaticsProducesExactlyOneEntry() throws InterruptedException { + IntTable table = new IntTable(8); + int threads = 16; + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger createCount = new AtomicInteger(); + + Thread[] workers = new Thread[threads]; + for (int i = 0; i < threads; i++) { + workers[i] = new Thread(() -> { ready.countDown(); try { - go.await(); + go.await(); } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return; + Thread.currentThread().interrupt(); + return; } table.getOrCreateCounting(7, createCount); - }); - workers[i].start(); - } - ready.await(); - go.countDown(); - for (Thread w : workers) { - w.join(); - } + }); + workers[i].start(); + } + ready.await(); + go.countDown(); + for (Thread w : workers) { + w.join(); + } - assertEquals(1, table.size.get()); - assertEquals(1, createCount.get()); - } - - @Test - void concurrentReadsStaySafeWhileOneChainMemberChurnsViaStatics() throws InterruptedException { - // Capacity 1 puts every key in one bucket so unlink splices a chain the reader is walking. - IntTable table = new IntTable(1); - int n = 8; - for (int i = 0; i < n; i++) { - table.getOrCreate(i, i); + assertEquals(1, table.size.get()); + assertEquals(1, createCount.get()); } - int churn = 0; // keys 1..n-1 are stable and must never vanish - - AtomicBoolean stop = new AtomicBoolean(false); - AtomicInteger missed = new AtomicInteger(); - Thread reader = - new Thread( - () -> { - while (!stop.get()) { + + @Test + void concurrentReadsStaySafeWhileOneChainMemberChurnsViaStatics() throws InterruptedException { + // Capacity 1 puts every key in one bucket so unlink splices a chain the reader is walking. + IntTable table = new IntTable(1); + int n = 8; + for (int i = 0; i < n; i++) { + table.getOrCreate(i, i); + } + int churn = 0; // keys 1..n-1 are stable and must never vanish + + AtomicBoolean stop = new AtomicBoolean(false); + AtomicInteger missed = new AtomicInteger(); + Thread reader = new Thread(() -> { + while (!stop.get()) { for (int i = 1; i < n; i++) { - if (table.get(i) == null) { - missed.incrementAndGet(); - } + if (table.get(i) == null) { + missed.incrementAndGet(); + } } - } - }); - reader.start(); - for (int r = 0; r < 100_000; r++) { - table.remove(churn); - table.getOrCreate(churn, churn); - } - stop.set(true); - reader.join(); - - assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); - } - - private static boolean assertionsEnabled() { - boolean enabled = false; - assert enabled = true; - return enabled; - } - - /** Primitive-{@code int}-key entry: no boxing, keyHash is the key itself. */ - private static final class IntEntry extends ConcurrentHashtable.Entry { - final int key; - final int value; - - IntEntry(int key, int value) { - super(key); - this.key = key; - this.value = value; - } + } + }); + reader.start(); + for (int r = 0; r < 100_000; r++) { + table.remove(churn); + table.getOrCreate(churn, churn); + } + stop.set(true); + reader.join(); - boolean matches(int key) { - return this.key == key; + assertEquals(0, missed.get(), "stable chain members must never be unreachable during removal"); } - @Override - public boolean matches(IntEntry other) { - return matches(other.key); - } - } - - /** - * Minimal hand-written table over a caller-owned {@link AtomicReferenceArray}, following the - * documented recipe: lock-free pre-check, then re-check + mutate under {@code - * getWriteLock(buckets, keyHash)}. - */ - private static final class IntTable { - final AtomicReferenceArray buckets; - final AtomicInteger size = new AtomicInteger(); - - IntTable(int capacity) { - this.buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, capacity); + private static boolean assertionsEnabled() { + boolean enabled = false; + assert enabled = true; + return enabled; } - IntEntry get(int key) { - for (IntEntry e = ConcurrentHashtable.bucketFor(buckets, (long) key); - e != null; - e = e.next()) { - if (e.matches(key)) { - return e; + /** Primitive-{@code int}-key entry: no boxing, keyHash is the key itself. */ + private static final class IntEntry extends ConcurrentHashtable.Entry { + final int key; + final int value; + + IntEntry(int key, int value) { + super(key); + this.key = key; + this.value = value; } - } - return null; - } - IntEntry getOrCreate(int key, int value) { - int index = ConcurrentHashtable.bucketIndex(buckets, key); - for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { - if (e.matches(key)) { - return e; + boolean matches(int key) { + return this.key == key; } - } - synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { - for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { - if (e.matches(key)) { - return e; - } + + @Override + public boolean matches(IntEntry other) { + return matches(other.key); } - IntEntry created = new IntEntry(key, value); - ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); - size.incrementAndGet(); - return created; - } } - /** {@link #getOrCreate} variant that counts real creations, for the exactly-once race test. */ - IntEntry getOrCreateCounting(int key, AtomicInteger createCount) { - int index = ConcurrentHashtable.bucketIndex(buckets, key); - for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { - if (e.matches(key)) { - return e; + /** + * Minimal hand-written table over a caller-owned {@link AtomicReferenceArray}, following the + * documented recipe: lock-free pre-check, then re-check + mutate under {@code + * getWriteLock(buckets, keyHash)}. + */ + private static final class IntTable { + final AtomicReferenceArray buckets; + final AtomicInteger size = new AtomicInteger(); + + IntTable(int capacity) { + this.buckets = ConcurrentHashtable.createFixedBuckets(IntEntry.class, capacity); } - } - synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { - for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { - if (e.matches(key)) { - return e; - } + + IntEntry get(int key) { + for (IntEntry e = ConcurrentHashtable.bucketFor(buckets, (long) key); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + return null; + } + + IntEntry getOrCreate(int key, int value) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + IntEntry created = new IntEntry(key, value); + ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); + size.incrementAndGet(); + return created; + } + } + + /** {@link #getOrCreate} variant that counts real creations, for the exactly-once race test. */ + IntEntry getOrCreateCounting(int key, AtomicInteger createCount) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + return e; + } + } + createCount.incrementAndGet(); + IntEntry created = new IntEntry(key, 0); + ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); + size.incrementAndGet(); + return created; + } } - createCount.incrementAndGet(); - IntEntry created = new IntEntry(key, 0); - ConcurrentHashtable.insertHeadEntryAt(buckets, index, created); - size.incrementAndGet(); - return created; - } - } - IntEntry remove(int key) { - int index = ConcurrentHashtable.bucketIndex(buckets, key); - synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { - IntEntry prev = null; - for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { - if (e.matches(key)) { - ConcurrentHashtable.unlink(buckets, index, prev, e); - size.decrementAndGet(); - return e; - } - prev = e; + IntEntry remove(int key) { + int index = ConcurrentHashtable.bucketIndex(buckets, key); + synchronized (ConcurrentHashtable.getWriteLockAt(buckets, index)) { + IntEntry prev = null; + for (IntEntry e = ConcurrentHashtable.bucketAt(buckets, index); e != null; e = e.next()) { + if (e.matches(key)) { + ConcurrentHashtable.unlink(buckets, index, prev, e); + size.decrementAndGet(); + return e; + } + prev = e; + } + return null; + } } - return null; - } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java index 4fc6838974b..79ed610a39e 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD1Test.java @@ -13,256 +13,250 @@ class FlatHashtableD1Test { - static final class StringIntEntry extends FlatHashtable.D1.Entry { - int value; + static final class StringIntEntry extends FlatHashtable.D1.Entry { + int value; - StringIntEntry(String key, int value) { - super(key); - this.value = value; + StringIntEntry(String key, int value) { + super(key); + this.value = value; + } } - } - /** Key whose hashCode is fully controllable, to force probe collisions deterministically. */ - static final class CollidingKey { - final String label; - final int hash; + /** Key whose hashCode is fully controllable, to force probe collisions deterministically. */ + static final class CollidingKey { + final String label; + final int hash; - CollidingKey(String label, int hash) { - this.label = label; - this.hash = hash; - } + CollidingKey(String label, int hash) { + this.label = label; + this.hash = hash; + } - @Override - public int hashCode() { - return hash; - } + @Override + public int hashCode() { + return hash; + } - @Override - public boolean equals(Object o) { - if (!(o instanceof CollidingKey)) { - return false; - } - CollidingKey that = (CollidingKey) o; - return hash == that.hash && label.equals(that.label); + @Override + public boolean equals(Object o) { + if (!(o instanceof CollidingKey)) { + return false; + } + CollidingKey that = (CollidingKey) o; + return hash == that.hash && label.equals(that.label); + } } - } - static final class CollidingKeyEntry extends FlatHashtable.D1.Entry { - int value; + static final class CollidingKeyEntry extends FlatHashtable.D1.Entry { + int value; - CollidingKeyEntry(CollidingKey key, int value) { - super(key); - this.value = value; + CollidingKeyEntry(CollidingKey key, int value) { + super(key); + this.value = value; + } } - } - - private static FlatHashtable.D1 growable(int initialCapacity) { - return FlatHashtable.D1.createGrowable(StringIntEntry.class, initialCapacity); - } - private static FlatHashtable.D1 fixed(int maxCapacity) { - return FlatHashtable.D1.createFixed(StringIntEntry.class, maxCapacity); - } + private static FlatHashtable.D1 growable(int initialCapacity) { + return FlatHashtable.D1.createGrowable(StringIntEntry.class, initialCapacity); + } - @Test - void emptyTableLookupReturnsNull() { - FlatHashtable.D1 table = growable(8); - assertNull(table.get("missing")); - assertEquals(0, table.size()); - } + private static FlatHashtable.D1 fixed(int maxCapacity) { + return FlatHashtable.D1.createFixed(StringIntEntry.class, maxCapacity); + } - @Test - void insertedEntryIsRetrievable() { - FlatHashtable.D1 table = growable(8); - StringIntEntry e = new StringIntEntry("foo", 1); - assertTrue(table.insert(e)); - assertEquals(1, table.size()); - assertSame(e, table.get("foo")); - } + @Test + void emptyTableLookupReturnsNull() { + FlatHashtable.D1 table = growable(8); + assertNull(table.get("missing")); + assertEquals(0, table.size()); + } - @Test - void multipleInsertsRetrievableSeparately() { - FlatHashtable.D1 table = growable(16); - StringIntEntry a = new StringIntEntry("alpha", 1); - StringIntEntry b = new StringIntEntry("beta", 2); - StringIntEntry c = new StringIntEntry("gamma", 3); - table.insert(a); - table.insert(b); - table.insert(c); - assertEquals(3, table.size()); - assertSame(a, table.get("alpha")); - assertSame(b, table.get("beta")); - assertSame(c, table.get("gamma")); - } + @Test + void insertedEntryIsRetrievable() { + FlatHashtable.D1 table = growable(8); + StringIntEntry e = new StringIntEntry("foo", 1); + assertTrue(table.insert(e)); + assertEquals(1, table.size()); + assertSame(e, table.get("foo")); + } - @Test - void keyAccessorExposesConstructionKey() { - StringIntEntry e = new StringIntEntry("foo", 1); - assertEquals("foo", e.key()); - } + @Test + void multipleInsertsRetrievableSeparately() { + FlatHashtable.D1 table = growable(16); + StringIntEntry a = new StringIntEntry("alpha", 1); + StringIntEntry b = new StringIntEntry("beta", 2); + StringIntEntry c = new StringIntEntry("gamma", 3); + table.insert(a); + table.insert(b); + table.insert(c); + assertEquals(3, table.size()); + assertSame(a, table.get("alpha")); + assertSame(b, table.get("beta")); + assertSame(c, table.get("gamma")); + } - @Test - void inPlaceMutationVisibleViaSubsequentGet() { - FlatHashtable.D1 table = growable(8); - table.insert(new StringIntEntry("counter", 0)); - for (int i = 0; i < 10; i++) { - StringIntEntry e = table.get("counter"); - e.value++; + @Test + void keyAccessorExposesConstructionKey() { + StringIntEntry e = new StringIntEntry("foo", 1); + assertEquals("foo", e.key()); } - assertEquals(10, table.get("counter").value); - } - @Test - void clearEmptiesTheTable() { - FlatHashtable.D1 table = growable(8); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - table.clear(); - assertEquals(0, table.size()); - assertNull(table.get("a")); - // Reinsertion works after clear. - table.insert(new StringIntEntry("a", 99)); - assertEquals(99, table.get("a").value); - } + @Test + void inPlaceMutationVisibleViaSubsequentGet() { + FlatHashtable.D1 table = growable(8); + table.insert(new StringIntEntry("counter", 0)); + for (int i = 0; i < 10; i++) { + StringIntEntry e = table.get("counter"); + e.value++; + } + assertEquals(10, table.get("counter").value); + } - @Test - void forEachVisitsEveryInsertedEntry() { - FlatHashtable.D1 table = growable(8); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - table.insert(new StringIntEntry("c", 3)); - Map seen = new HashMap<>(); - table.forEach(e -> seen.put(e.key(), e.value)); - assertEquals(3, seen.size()); - assertEquals(1, seen.get("a")); - assertEquals(2, seen.get("b")); - assertEquals(3, seen.get("c")); - } + @Test + void clearEmptiesTheTable() { + FlatHashtable.D1 table = growable(8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a")); + // Reinsertion works after clear. + table.insert(new StringIntEntry("a", 99)); + assertEquals(99, table.get("a").value); + } - @Test - void forEachWithContextPassesContextToConsumer() { - FlatHashtable.D1 table = growable(8); - table.insert(new StringIntEntry("a", 10)); - table.insert(new StringIntEntry("b", 20)); - Map seen = new HashMap<>(); - table.forEach(seen, (ctx, e) -> ctx.put(e.key(), e.value)); - assertEquals(2, seen.size()); - assertEquals(10, seen.get("a")); - assertEquals(20, seen.get("b")); - } + @Test + void forEachVisitsEveryInsertedEntry() { + FlatHashtable.D1 table = growable(8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + table.insert(new StringIntEntry("c", 3)); + Map seen = new HashMap<>(); + table.forEach(e -> seen.put(e.key(), e.value)); + assertEquals(3, seen.size()); + assertEquals(1, seen.get("a")); + assertEquals(2, seen.get("b")); + assertEquals(3, seen.get("c")); + } - @Test - void nullKeyIsPermittedAndDistinctFromAbsent() { - FlatHashtable.D1 table = growable(8); - assertNull(table.get(null)); - StringIntEntry nullKeyed = new StringIntEntry(null, 7); - table.insert(nullKeyed); - assertSame(nullKeyed, table.get(null)); - assertEquals(1, table.size()); - // A real key that hashes to 0 must not collide with the null-key sentinel. - StringIntEntry zeroHashed = new StringIntEntry("", 0); - assertEquals(0, "".hashCode()); - table.insert(zeroHashed); - assertSame(zeroHashed, table.get("")); - assertSame(nullKeyed, table.get(null)); - } + @Test + void forEachWithContextPassesContextToConsumer() { + FlatHashtable.D1 table = growable(8); + table.insert(new StringIntEntry("a", 10)); + table.insert(new StringIntEntry("b", 20)); + Map seen = new HashMap<>(); + table.forEach(seen, (ctx, e) -> ctx.put(e.key(), e.value)); + assertEquals(2, seen.size()); + assertEquals(10, seen.get("a")); + assertEquals(20, seen.get("b")); + } - @Test - void hashCollisionsResolveByEquality() { - // Two distinct keys with the same hashCode: the probe must distinguish them via matches(). - FlatHashtable.D1 table = - FlatHashtable.D1.createGrowable(CollidingKeyEntry.class, 4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); - CollidingKeyEntry e2 = new CollidingKeyEntry(k2, 200); - table.insert(e1); - table.insert(e2); - assertEquals(2, table.size()); - assertSame(e1, table.get(k1)); - assertSame(e2, table.get(k2)); - } + @Test + void nullKeyIsPermittedAndDistinctFromAbsent() { + FlatHashtable.D1 table = growable(8); + assertNull(table.get(null)); + StringIntEntry nullKeyed = new StringIntEntry(null, 7); + table.insert(nullKeyed); + assertSame(nullKeyed, table.get(null)); + assertEquals(1, table.size()); + // A real key that hashes to 0 must not collide with the null-key sentinel. + StringIntEntry zeroHashed = new StringIntEntry("", 0); + assertEquals(0, "".hashCode()); + table.insert(zeroHashed); + assertSame(zeroHashed, table.get("")); + assertSame(nullKeyed, table.get(null)); + } - @Test - void getOrCreateOnMissBuildsEntryViaCreator() { - FlatHashtable.D1 table = growable(8); - int[] createCount = {0}; - StringIntEntry created = - table.getOrCreate( - "foo", - k -> { - createCount[0]++; - return new StringIntEntry(k, 42); - }); - assertNotNull(created); - assertEquals("foo", created.key()); - assertEquals(42, created.value); - assertEquals(1, table.size()); - assertEquals(1, createCount[0]); - assertSame(created, table.get("foo")); - } + @Test + void hashCollisionsResolveByEquality() { + // Two distinct keys with the same hashCode: the probe must distinguish them via matches(). + FlatHashtable.D1 table = + FlatHashtable.D1.createGrowable(CollidingKeyEntry.class, 4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); + CollidingKeyEntry e2 = new CollidingKeyEntry(k2, 200); + table.insert(e1); + table.insert(e2); + assertEquals(2, table.size()); + assertSame(e1, table.get(k1)); + assertSame(e2, table.get(k2)); + } - @Test - void getOrCreateOnHitSkipsCreator() { - FlatHashtable.D1 table = growable(8); - StringIntEntry seeded = new StringIntEntry("foo", 1); - table.insert(seeded); - int[] createCount = {0}; - StringIntEntry got = - table.getOrCreate( - "foo", - k -> { - createCount[0]++; - return new StringIntEntry(k, 999); - }); - assertSame(seeded, got); - assertEquals(1, table.size()); - assertEquals(0, createCount[0]); - } + @Test + void getOrCreateOnMissBuildsEntryViaCreator() { + FlatHashtable.D1 table = growable(8); + int[] createCount = {0}; + StringIntEntry created = table.getOrCreate("foo", k -> { + createCount[0]++; + return new StringIntEntry(k, 42); + }); + assertNotNull(created); + assertEquals("foo", created.key()); + assertEquals(42, created.value); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("foo")); + } - @Test - void growableGrowsPastInitialCapacity() { - // initialCapacity 1 => a tiny 2-slot table; inserting far past it must trigger growth. - FlatHashtable.D1 table = growable(1); - for (int i = 0; i < 50; i++) { - assertTrue(table.insert(new StringIntEntry("k" + i, i))); + @Test + void getOrCreateOnHitSkipsCreator() { + FlatHashtable.D1 table = growable(8); + StringIntEntry seeded = new StringIntEntry("foo", 1); + table.insert(seeded); + int[] createCount = {0}; + StringIntEntry got = table.getOrCreate("foo", k -> { + createCount[0]++; + return new StringIntEntry(k, 999); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); } - assertEquals(50, table.size()); - for (int i = 0; i < 50; i++) { - assertEquals(i, table.get("k" + i).value); + + @Test + void growableGrowsPastInitialCapacity() { + // initialCapacity 1 => a tiny 2-slot table; inserting far past it must trigger growth. + FlatHashtable.D1 table = growable(1); + for (int i = 0; i < 50; i++) { + assertTrue(table.insert(new StringIntEntry("k" + i, i))); + } + assertEquals(50, table.size()); + for (int i = 0; i < 50; i++) { + assertEquals(i, table.get("k" + i).value); + } } - } - @Test - void growableGetOrCreateNeverReturnsNull() { - FlatHashtable.D1 table = growable(1); - for (int i = 0; i < 50; i++) { - StringIntEntry e = table.getOrCreate("k" + i, k -> new StringIntEntry(k, 0)); - assertNotNull(e); + @Test + void growableGetOrCreateNeverReturnsNull() { + FlatHashtable.D1 table = growable(1); + for (int i = 0; i < 50; i++) { + StringIntEntry e = table.getOrCreate("k" + i, k -> new StringIntEntry(k, 0)); + assertNotNull(e); + } + assertEquals(50, table.size()); } - assertEquals(50, table.size()); - } - @Test - void fixedGetOrCreateCapsWhenFull() { - FlatHashtable.D1 table = fixed(2); - assertNotNull(table.getOrCreate("a", k -> new StringIntEntry(k, 1))); - assertNotNull(table.getOrCreate("b", k -> new StringIntEntry(k, 2))); - assertEquals(2, table.size()); - // At capacity, a new key can't be created -> null (caller's overflow default). - assertNull(table.getOrCreate("c", k -> new StringIntEntry(k, 3))); - assertEquals(2, table.size()); - // ...but an existing key still resolves even at capacity (cap blocks creation, not lookup). - StringIntEntry a = table.get("a"); - assertSame(a, table.getOrCreate("a", k -> new StringIntEntry(k, 99))); - } + @Test + void fixedGetOrCreateCapsWhenFull() { + FlatHashtable.D1 table = fixed(2); + assertNotNull(table.getOrCreate("a", k -> new StringIntEntry(k, 1))); + assertNotNull(table.getOrCreate("b", k -> new StringIntEntry(k, 2))); + assertEquals(2, table.size()); + // At capacity, a new key can't be created -> null (caller's overflow default). + assertNull(table.getOrCreate("c", k -> new StringIntEntry(k, 3))); + assertEquals(2, table.size()); + // ...but an existing key still resolves even at capacity (cap blocks creation, not lookup). + StringIntEntry a = table.get("a"); + assertSame(a, table.getOrCreate("a", k -> new StringIntEntry(k, 99))); + } - @Test - void fixedInsertReturnsFalseWhenFull() { - FlatHashtable.D1 table = fixed(2); - assertTrue(table.insert(new StringIntEntry("a", 1))); - assertTrue(table.insert(new StringIntEntry("b", 2))); - assertFalse(table.insert(new StringIntEntry("c", 3))); - assertEquals(2, table.size()); - } + @Test + void fixedInsertReturnsFalseWhenFull() { + FlatHashtable.D1 table = fixed(2); + assertTrue(table.insert(new StringIntEntry("a", 1))); + assertTrue(table.insert(new StringIntEntry("b", 2))); + assertFalse(table.insert(new StringIntEntry("c", 3))); + assertEquals(2, table.size()); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java index 5d19af4fecf..fe1ac73ea54 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableD2Test.java @@ -13,257 +13,249 @@ class FlatHashtableD2Test { - static final class PairEntry extends FlatHashtable.D2.Entry { - int value; + static final class PairEntry extends FlatHashtable.D2.Entry { + int value; - PairEntry(String key1, Integer key2, int value) { - super(key1, key2); - this.value = value; + PairEntry(String key1, Integer key2, int value) { + super(key1, key2); + this.value = value; + } } - } - /** A key whose hash is fixed at construction so composite-hash collisions can be forced. */ - static final class CollidingKey { - final String label; - final int hash; + /** A key whose hash is fixed at construction so composite-hash collisions can be forced. */ + static final class CollidingKey { + final String label; + final int hash; - CollidingKey(String label, int hash) { - this.label = label; - this.hash = hash; - } + CollidingKey(String label, int hash) { + this.label = label; + this.hash = hash; + } - @Override - public int hashCode() { - return hash; - } + @Override + public int hashCode() { + return hash; + } - @Override - public boolean equals(Object o) { - if (!(o instanceof CollidingKey)) { - return false; - } - CollidingKey that = (CollidingKey) o; - return hash == that.hash && label.equals(that.label); + @Override + public boolean equals(Object o) { + if (!(o instanceof CollidingKey)) { + return false; + } + CollidingKey that = (CollidingKey) o; + return hash == that.hash && label.equals(that.label); + } } - } - static final class CollidingPairEntry extends FlatHashtable.D2.Entry { - int value; + static final class CollidingPairEntry extends FlatHashtable.D2.Entry { + int value; - CollidingPairEntry(CollidingKey key1, CollidingKey key2, int value) { - super(key1, key2); - this.value = value; + CollidingPairEntry(CollidingKey key1, CollidingKey key2, int value) { + super(key1, key2); + this.value = value; + } } - } - - private static FlatHashtable.D2 growable(int initialCapacity) { - return FlatHashtable.D2.createGrowable(PairEntry.class, initialCapacity); - } - private static FlatHashtable.D2 fixed(int maxCapacity) { - return FlatHashtable.D2.createFixed(PairEntry.class, maxCapacity); - } - - @Test - void emptyTableLookupReturnsNull() { - FlatHashtable.D2 table = growable(8); - assertNull(table.get("missing", 1)); - assertEquals(0, table.size()); - } + private static FlatHashtable.D2 growable(int initialCapacity) { + return FlatHashtable.D2.createGrowable(PairEntry.class, initialCapacity); + } - @Test - void insertedEntryIsRetrievableByBothParts() { - FlatHashtable.D2 table = growable(8); - PairEntry e = new PairEntry("a", 1, 100); - assertTrue(table.insert(e)); - assertEquals(1, table.size()); - assertSame(e, table.get("a", 1)); - } + private static FlatHashtable.D2 fixed(int maxCapacity) { + return FlatHashtable.D2.createFixed(PairEntry.class, maxCapacity); + } - @Test - void distinctCompositeKeysStaySeparate() { - FlatHashtable.D2 table = growable(16); - PairEntry a1 = new PairEntry("a", 1, 10); - PairEntry a2 = new PairEntry("a", 2, 20); // same key1, different key2 - PairEntry b1 = new PairEntry("b", 1, 30); // different key1, same key2 - table.insert(a1); - table.insert(a2); - table.insert(b1); - assertEquals(3, table.size()); - assertSame(a1, table.get("a", 1)); - assertSame(a2, table.get("a", 2)); - assertSame(b1, table.get("b", 1)); - // A composite that was never inserted is absent. - assertNull(table.get("b", 2)); - } + @Test + void emptyTableLookupReturnsNull() { + FlatHashtable.D2 table = growable(8); + assertNull(table.get("missing", 1)); + assertEquals(0, table.size()); + } - @Test - void keyAccessorsExposeConstructionKeys() { - PairEntry e = new PairEntry("a", 1, 100); - assertEquals("a", e.key1()); - assertEquals(1, e.key2()); - } + @Test + void insertedEntryIsRetrievableByBothParts() { + FlatHashtable.D2 table = growable(8); + PairEntry e = new PairEntry("a", 1, 100); + assertTrue(table.insert(e)); + assertEquals(1, table.size()); + assertSame(e, table.get("a", 1)); + } - @Test - void inPlaceMutationVisibleViaSubsequentGet() { - FlatHashtable.D2 table = growable(8); - table.insert(new PairEntry("counter", 7, 0)); - for (int i = 0; i < 10; i++) { - PairEntry e = table.get("counter", 7); - e.value++; + @Test + void distinctCompositeKeysStaySeparate() { + FlatHashtable.D2 table = growable(16); + PairEntry a1 = new PairEntry("a", 1, 10); + PairEntry a2 = new PairEntry("a", 2, 20); // same key1, different key2 + PairEntry b1 = new PairEntry("b", 1, 30); // different key1, same key2 + table.insert(a1); + table.insert(a2); + table.insert(b1); + assertEquals(3, table.size()); + assertSame(a1, table.get("a", 1)); + assertSame(a2, table.get("a", 2)); + assertSame(b1, table.get("b", 1)); + // A composite that was never inserted is absent. + assertNull(table.get("b", 2)); } - assertEquals(10, table.get("counter", 7).value); - } - @Test - void clearEmptiesTheTable() { - FlatHashtable.D2 table = growable(8); - table.insert(new PairEntry("a", 1, 1)); - table.insert(new PairEntry("b", 2, 2)); - table.clear(); - assertEquals(0, table.size()); - assertNull(table.get("a", 1)); - } + @Test + void keyAccessorsExposeConstructionKeys() { + PairEntry e = new PairEntry("a", 1, 100); + assertEquals("a", e.key1()); + assertEquals(1, e.key2()); + } - @Test - void forEachVisitsEveryInsertedEntry() { - FlatHashtable.D2 table = growable(8); - table.insert(new PairEntry("a", 1, 1)); - table.insert(new PairEntry("b", 2, 2)); - table.insert(new PairEntry("c", 3, 3)); - Map seen = new HashMap<>(); - table.forEach(e -> seen.put(e.key1() + e.key2(), e.value)); - assertEquals(3, seen.size()); - assertEquals(1, seen.get("a1")); - assertEquals(2, seen.get("b2")); - assertEquals(3, seen.get("c3")); - } + @Test + void inPlaceMutationVisibleViaSubsequentGet() { + FlatHashtable.D2 table = growable(8); + table.insert(new PairEntry("counter", 7, 0)); + for (int i = 0; i < 10; i++) { + PairEntry e = table.get("counter", 7); + e.value++; + } + assertEquals(10, table.get("counter", 7).value); + } - @Test - void forEachWithContextPassesContextToConsumer() { - FlatHashtable.D2 table = growable(8); - table.insert(new PairEntry("a", 1, 10)); - table.insert(new PairEntry("b", 2, 20)); - Map seen = new HashMap<>(); - table.forEach(seen, (ctx, e) -> ctx.put(e.key1() + e.key2(), e.value)); - assertEquals(2, seen.size()); - assertEquals(10, seen.get("a1")); - assertEquals(20, seen.get("b2")); - } + @Test + void clearEmptiesTheTable() { + FlatHashtable.D2 table = growable(8); + table.insert(new PairEntry("a", 1, 1)); + table.insert(new PairEntry("b", 2, 2)); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + } - @Test - void nullKeyPartsArePermitted() { - FlatHashtable.D2 table = growable(8); - PairEntry bothNull = new PairEntry(null, null, 1); - table.insert(bothNull); - assertSame(bothNull, table.get(null, null)); - assertNull(table.get("a", null)); - assertNull(table.get(null, 1)); - assertEquals(1, table.size()); - } + @Test + void forEachVisitsEveryInsertedEntry() { + FlatHashtable.D2 table = growable(8); + table.insert(new PairEntry("a", 1, 1)); + table.insert(new PairEntry("b", 2, 2)); + table.insert(new PairEntry("c", 3, 3)); + Map seen = new HashMap<>(); + table.forEach(e -> seen.put(e.key1() + e.key2(), e.value)); + assertEquals(3, seen.size()); + assertEquals(1, seen.get("a1")); + assertEquals(2, seen.get("b2")); + assertEquals(3, seen.get("c3")); + } - @Test - void getOrCreateOnMissBuildsEntryViaCreator() { - FlatHashtable.D2 table = growable(8); - int[] createCount = {0}; - PairEntry created = - table.getOrCreate( - "foo", - 1, - (k1, k2) -> { - createCount[0]++; - return new PairEntry(k1, k2, 42); - }); - assertNotNull(created); - assertEquals("foo", created.key1()); - assertEquals(1, created.key2()); - assertEquals(42, created.value); - assertEquals(1, table.size()); - assertEquals(1, createCount[0]); - assertSame(created, table.get("foo", 1)); - } + @Test + void forEachWithContextPassesContextToConsumer() { + FlatHashtable.D2 table = growable(8); + table.insert(new PairEntry("a", 1, 10)); + table.insert(new PairEntry("b", 2, 20)); + Map seen = new HashMap<>(); + table.forEach(seen, (ctx, e) -> ctx.put(e.key1() + e.key2(), e.value)); + assertEquals(2, seen.size()); + assertEquals(10, seen.get("a1")); + assertEquals(20, seen.get("b2")); + } - @Test - void getOrCreateOnHitSkipsCreator() { - FlatHashtable.D2 table = growable(8); - PairEntry seeded = new PairEntry("foo", 1, 1); - table.insert(seeded); - int[] createCount = {0}; - PairEntry got = - table.getOrCreate( - "foo", - 1, - (k1, k2) -> { - createCount[0]++; - return new PairEntry(k1, k2, 999); - }); - assertSame(seeded, got); - assertEquals(1, table.size()); - assertEquals(0, createCount[0]); - } + @Test + void nullKeyPartsArePermitted() { + FlatHashtable.D2 table = growable(8); + PairEntry bothNull = new PairEntry(null, null, 1); + table.insert(bothNull); + assertSame(bothNull, table.get(null, null)); + assertNull(table.get("a", null)); + assertNull(table.get(null, 1)); + assertEquals(1, table.size()); + } - @Test - void growableGrowsPastInitialCapacity() { - FlatHashtable.D2 table = growable(1); - for (int i = 0; i < 50; i++) { - assertTrue(table.insert(new PairEntry("k", i, i))); + @Test + void getOrCreateOnMissBuildsEntryViaCreator() { + FlatHashtable.D2 table = growable(8); + int[] createCount = {0}; + PairEntry created = table.getOrCreate("foo", 1, (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2, 42); + }); + assertNotNull(created); + assertEquals("foo", created.key1()); + assertEquals(1, created.key2()); + assertEquals(42, created.value); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("foo", 1)); } - assertEquals(50, table.size()); - for (int i = 0; i < 50; i++) { - assertEquals(i, table.get("k", i).value); + + @Test + void getOrCreateOnHitSkipsCreator() { + FlatHashtable.D2 table = growable(8); + PairEntry seeded = new PairEntry("foo", 1, 1); + table.insert(seeded); + int[] createCount = {0}; + PairEntry got = table.getOrCreate("foo", 1, (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2, 999); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); } - } - @Test - void fixedGetOrCreateCapsWhenFull() { - FlatHashtable.D2 table = fixed(2); - assertNotNull(table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); - assertNotNull(table.getOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2))); - assertEquals(2, table.size()); - assertNull(table.getOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3))); - assertEquals(2, table.size()); - PairEntry a = table.get("a", 1); - assertSame(a, table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); - } + @Test + void growableGrowsPastInitialCapacity() { + FlatHashtable.D2 table = growable(1); + for (int i = 0; i < 50; i++) { + assertTrue(table.insert(new PairEntry("k", i, i))); + } + assertEquals(50, table.size()); + for (int i = 0; i < 50; i++) { + assertEquals(i, table.get("k", i).value); + } + } - @Test - void fixedInsertReturnsFalseWhenFull() { - FlatHashtable.D2 table = fixed(2); - assertTrue(table.insert(new PairEntry("a", 1, 1))); - assertTrue(table.insert(new PairEntry("b", 2, 2))); - assertFalse(table.insert(new PairEntry("c", 3, 3))); - assertEquals(2, table.size()); - } + @Test + void fixedGetOrCreateCapsWhenFull() { + FlatHashtable.D2 table = fixed(2); + assertNotNull(table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 1))); + assertNotNull(table.getOrCreate("b", 2, (k1, k2) -> new PairEntry(k1, k2, 2))); + assertEquals(2, table.size()); + assertNull(table.getOrCreate("c", 3, (k1, k2) -> new PairEntry(k1, k2, 3))); + assertEquals(2, table.size()); + PairEntry a = table.get("a", 1); + assertSame(a, table.getOrCreate("a", 1, (k1, k2) -> new PairEntry(k1, k2, 99))); + } - @Test - void hashCollisionsResolveByKeyEquality() { - FlatHashtable.D2 table = - FlatHashtable.D2.createGrowable(CollidingPairEntry.class, 8); - CollidingKey key1 = new CollidingKey("a", 7); - CollidingKey key2 = new CollidingKey("x", 3); - CollidingPairEntry e = new CollidingPairEntry(key1, key2, 100); - table.insert(e); + @Test + void fixedInsertReturnsFalseWhenFull() { + FlatHashtable.D2 table = fixed(2); + assertTrue(table.insert(new PairEntry("a", 1, 1))); + assertTrue(table.insert(new PairEntry("b", 2, 2))); + assertFalse(table.insert(new PairEntry("c", 3, 3))); + assertEquals(2, table.size()); + } - // Same combined hash (31*7 + 3) as the stored entry, but key1 differs by equals: the entry's - // matches() must reject on key1, so the lookup misses rather than returning the wrong entry. - assertNull(table.get(new CollidingKey("b", 7), key2)); - // Same combined hash again, key1 equal but key2 differs by equals: matches() must reject on - // key2. - assertNull(table.get(key1, new CollidingKey("y", 3))); - // The genuine key still resolves. - assertSame(e, table.get(key1, key2)); - } + @Test + void hashCollisionsResolveByKeyEquality() { + FlatHashtable.D2 table = + FlatHashtable.D2.createGrowable(CollidingPairEntry.class, 8); + CollidingKey key1 = new CollidingKey("a", 7); + CollidingKey key2 = new CollidingKey("x", 3); + CollidingPairEntry e = new CollidingPairEntry(key1, key2, 100); + table.insert(e); - @Test - void growableGetOrCreateGrowsPastInitialCapacity() { - FlatHashtable.D2 table = growable(1); - for (int i = 0; i < 50; i++) { - PairEntry e = table.getOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); - assertNotNull(e); + // Same combined hash (31*7 + 3) as the stored entry, but key1 differs by equals: the entry's + // matches() must reject on key1, so the lookup misses rather than returning the wrong entry. + assertNull(table.get(new CollidingKey("b", 7), key2)); + // Same combined hash again, key1 equal but key2 differs by equals: matches() must reject on + // key2. + assertNull(table.get(key1, new CollidingKey("y", 3))); + // The genuine key still resolves. + assertSame(e, table.get(key1, key2)); } - assertEquals(50, table.size()); - for (int i = 0; i < 50; i++) { - assertEquals(i, table.get("k", i).value); + + @Test + void growableGetOrCreateGrowsPastInitialCapacity() { + FlatHashtable.D2 table = growable(1); + for (int i = 0; i < 50; i++) { + PairEntry e = table.getOrCreate("k", i, (k1, k2) -> new PairEntry(k1, k2, k2)); + assertNotNull(e); + } + assertEquals(50, table.size()); + for (int i = 0; i < 50; i++) { + assertEquals(i, table.get("k", i).value); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java index b1099210611..235e4654f53 100644 --- a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -17,570 +17,558 @@ class FlatHashtableTest { - /** Self-contained entry: carries its own key (the identity FlatHashtable relies on). */ - static final class TestEntry { - final String key; + /** Self-contained entry: carries its own key (the identity FlatHashtable relies on). */ + static final class TestEntry { + final String key; - TestEntry(String key) { - this.key = key; + TestEntry(String key) { + this.key = key; + } } - } - /** - * Stateless concrete strategy over String keys, exposed as a canonical {@code INSTANCE} singleton - * (private ctor) so the JIT can specialize each call site. {@code hashKey} defaults to {@code - * key.hashCode()}; {@code hashOf} recomputes from the entry's key (this entry doesn't cache it), - * consistently with that default. - */ - static final class TestEntryStrategy extends FlatHashtable.EntryStrategy { - static final TestEntryStrategy INSTANCE = new TestEntryStrategy(); + /** + * Stateless concrete strategy over String keys, exposed as a canonical {@code INSTANCE} singleton + * (private ctor) so the JIT can specialize each call site. {@code hashKey} defaults to {@code + * key.hashCode()}; {@code hashOf} recomputes from the entry's key (this entry doesn't cache it), + * consistently with that default. + */ + static final class TestEntryStrategy extends FlatHashtable.EntryStrategy { + static final TestEntryStrategy INSTANCE = new TestEntryStrategy(); + + private TestEntryStrategy() {} + + @Override + public boolean matches(TestEntry entry, String key) { + return key.equals(entry.key); + } + + @Override + public long hashOf(TestEntry entry) { + return entry.key.hashCode(); // consistent with the default hashKey (key.hashCode()) + } + } + + /** Non-capturing create strategy (a constructor method ref => singleton-cached, alloc-free). */ + private static final FlatHashtable.CreateStrategy CREATE = TestEntry::new; + + /** All keys/entries hash to slot 0, so inserts chain by linear probing — exercises the probe. */ + static final class TestCollidingStrategy extends FlatHashtable.EntryStrategy { + static final TestCollidingStrategy INSTANCE = new TestCollidingStrategy(); + + private TestCollidingStrategy() {} + + @Override + public long hashKey(String key) { + return 0; + } + + @Override + public boolean matches(TestEntry entry, String key) { + return key.equals(entry.key); + } + + @Override + public long hashOf(TestEntry entry) { + return 0; + } + } + + /** + * Smallest hash {@code >= 1} that the table places on {@code slot} of a {@code mask + 1} table. + */ + private static long hashLandingOn(int slot, int mask) { + for (long h = 1; h < 1_000_000L; ++h) { + if (FlatHashtable.home(h, mask) == slot) { + return h; + } + } + throw new AssertionError("no hash found landing on slot " + slot); + } + + /** + * All keys hash to the last slot of a 2-slot table (so probing wraps around to index 0). The + * table owns the spread now, so we compute a hash that lands there rather than assuming {@code -1 + * & mask}. + */ + static final class TestLastSlotStrategy extends FlatHashtable.EntryStrategy { + static final TestLastSlotStrategy INSTANCE = new TestLastSlotStrategy(); + private static final long LAST_SLOT_HASH = hashLandingOn(1, 1); // slot 1 of a 2-slot table + + private TestLastSlotStrategy() {} + + @Override + public long hashKey(String key) { + return LAST_SLOT_HASH; + } + + @Override + public boolean matches(TestEntry entry, String key) { + return key.equals(entry.key); + } + + @Override + public long hashOf(TestEntry entry) { + return LAST_SLOT_HASH; + } + } + + /** + * Entry that caches its own hash (extends the Entry base) — for the entry-taking insert flavor. + */ + static final class TestHashedEntry extends FlatHashtable.Entry { + final String key; + + TestHashedEntry(String key) { + super(key.hashCode()); // cache the default hashKey (key.hashCode()) + this.key = key; + } + } + + /** Strategy for {@link TestHashedEntry}: {@code hashOf} reads the cached hash. */ + static final class TestHashedStrategy extends FlatHashtable.EntryStrategy { + static final TestHashedStrategy INSTANCE = new TestHashedStrategy(); + + private TestHashedStrategy() {} + + @Override + public boolean matches(TestHashedEntry entry, String key) { + return key.equals(entry.key); + } + + @Override + public long hashOf(TestHashedEntry entry) { + return entry.hash; + } + } - private TestEntryStrategy() {} + /** + * Case-insensitive strategy: {@code hashKey} sealed by the CI base, {@code matches} folds case. + */ + static final class TestCaseInsensitiveStrategy extends FlatHashtable.CaseInsensitiveStringStrategy { + static final TestCaseInsensitiveStrategy INSTANCE = new TestCaseInsensitiveStrategy(); - @Override - public boolean matches(TestEntry entry, String key) { - return key.equals(entry.key); + private TestCaseInsensitiveStrategy() {} + + @Override + public boolean matches(TestEntry entry, String key) { + return key.equalsIgnoreCase(entry.key); + } + + @Override + public long hashOf(TestEntry entry) { + return Strings.caseInsensitiveHashCode(entry.key); + } + } + + @Test + void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { + assertEquals(2, FlatHashtable.capacityFor(1)); + assertEquals(8, FlatHashtable.capacityFor(4)); + assertEquals(16, FlatHashtable.capacityFor(6)); // 6*2-1=11 -> 8 -> 16 + } + + @Test + void capacityFor_rejectsNonPositive() { + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(0)); + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(-1)); + } + + @Test + void create_allocatesTypedTableOfCapacity() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + assertEquals(8, table.length); + assertEquals(TestEntry.class, table.getClass().getComponentType()); + } + + @Test + void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry first = FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + assertEquals("a", first.key); + // A second call must return the SAME instance, not mint a new one. + assertSame(first, FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE)); + assertSame(first, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE)); } - @Override - public long hashOf(TestEntry entry) { - return entry.key.hashCode(); // consistent with the default hashKey (key.hashCode()) + @Test + void get_returnsNullForAbsentKey() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + assertNull(FlatHashtable.get(table, "missing", TestEntryStrategy.INSTANCE)); + FlatHashtable.getOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE); + assertNull(FlatHashtable.get(table, "still-missing", TestEntryStrategy.INSTANCE)); } - } - /** Non-capturing create strategy (a constructor method ref => singleton-cached, alloc-free). */ - private static final FlatHashtable.CreateStrategy CREATE = TestEntry::new; + @Test + void getOrCreate_returnsNullWhenTableIsFull() { + // capacityFor(1) == 2 slots. + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + assertTrue(FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null); + assertTrue(FlatHashtable.getOrCreate(table, "k1", TestEntryStrategy.INSTANCE, CREATE) != null); + // Both slots occupied by distinct keys -> a third distinct key finds no room. + assertNull(FlatHashtable.getOrCreate(table, "k2", TestEntryStrategy.INSTANCE, CREATE)); + // ...but an existing key still resolves even when full. + assertSame( + FlatHashtable.get(table, "k0", TestEntryStrategy.INSTANCE), + FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE)); + } + + @Test + void hashKey_isStableForEqualKeys() { + assertEquals( + TestEntryStrategy.INSTANCE.hashKey("route"), TestEntryStrategy.INSTANCE.hashKey(new String("route"))); + } + + @Test + void collision_probesPastOccupiedSlots_andResolvesEach() { + // 8 slots; COLLIDING sends all to slot 0 + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + // slot 0 taken -> 1 + TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + // -> slot 2 + TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); + + assertNotSame(a, b); + assertNotSame(b, c); + + // each resolves via probe-past-occupied + match-after-probe + assertSame(a, FlatHashtable.get(table, "a", TestCollidingStrategy.INSTANCE)); + assertSame(b, FlatHashtable.get(table, "b", TestCollidingStrategy.INSTANCE)); + assertSame(c, FlatHashtable.get(table, "c", TestCollidingStrategy.INSTANCE)); + + // existing colliding key: found after probing, no new entry minted + assertSame(b, FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE)); + + // absent key: probe past the 3 occupied slots, hit an empty slot -> null + assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); + } - /** All keys/entries hash to slot 0, so inserts chain by linear probing — exercises the probe. */ - static final class TestCollidingStrategy extends FlatHashtable.EntryStrategy { - static final TestCollidingStrategy INSTANCE = new TestCollidingStrategy(); + @Test + void collision_probeWrapsAroundToFront() { + // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + // -> slot 1 + TestEntry k0 = FlatHashtable.getOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE); + // taken -> wraps to 0 + TestEntry k1 = FlatHashtable.getOrCreate(table, "k1", TestLastSlotStrategy.INSTANCE, CREATE); + + assertNotSame(k0, k1); + assertSame(k0, FlatHashtable.get(table, "k0", TestLastSlotStrategy.INSTANCE)); + // start slot 1 is occupied (no match) -> probe wraps to slot 0 -> match + assertSame(k1, FlatHashtable.get(table, "k1", TestLastSlotStrategy.INSTANCE)); + } - private TestCollidingStrategy() {} + @Test + void get_returnsNullWhenTableFullAndKeyAbsent() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + FlatHashtable.getOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE); + // fills slots 0 and 1 + FlatHashtable.getOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE); - @Override - public long hashKey(String key) { - return 0; + // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch) + assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); } - @Override - public boolean matches(TestEntry entry, String key) { - return key.equals(entry.key); + @Test + void insert_generalFlavor_placesViaHashOfAndResolves() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + TestEntry e = new TestEntry("a"); + // flavor 2: the home comes from TestEntryStrategy.INSTANCE.hashOf(e) + assertTrue(FlatHashtable.insert(table, e, TestEntryStrategy.INSTANCE)); + assertSame(e, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE)); } - @Override - public long hashOf(TestEntry entry) { - return 0; + @Test + void insert_entryFlavor_placesViaCachedHashAndResolves() { + TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 8); + TestHashedEntry e = new TestHashedEntry("a"); + // flavor 1: the home comes from the Entry's own cached hash, no strategy needed + assertTrue(FlatHashtable.insert(table, e)); + assertSame(e, FlatHashtable.get(table, "a", TestHashedStrategy.INSTANCE)); } - } - /** - * Smallest hash {@code >= 1} that the table places on {@code slot} of a {@code mask + 1} table. - */ - private static long hashLandingOn(int slot, int mask) { - for (long h = 1; h < 1_000_000L; ++h) { - if (FlatHashtable.home(h, mask) == slot) { - return h; - } + @Test + void insert_returnsFalseWhenFull() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + assertTrue(FlatHashtable.insert(table, new TestEntry("k0"), TestEntryStrategy.INSTANCE)); + assertTrue(FlatHashtable.insert(table, new TestEntry("k1"), TestEntryStrategy.INSTANCE)); + // no room + assertFalse(FlatHashtable.insert(table, new TestEntry("k2"), TestEntryStrategy.INSTANCE)); } - throw new AssertionError("no hash found landing on slot " + slot); - } - /** - * All keys hash to the last slot of a 2-slot table (so probing wraps around to index 0). The - * table owns the spread now, so we compute a hash that lands there rather than assuming {@code -1 - * & mask}. - */ - static final class TestLastSlotStrategy extends FlatHashtable.EntryStrategy { - static final TestLastSlotStrategy INSTANCE = new TestLastSlotStrategy(); - private static final long LAST_SLOT_HASH = hashLandingOn(1, 1); // slot 1 of a 2-slot table + @Test + void forEach_visitsEveryEntry() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "c", TestEntryStrategy.INSTANCE, CREATE); + + Set seen = new HashSet<>(); + FlatHashtable.forEach(table, e -> seen.add(e.key)); + assertEquals(new HashSet<>(Arrays.asList("a", "b", "c")), seen); + } - private TestLastSlotStrategy() {} + @Test + void forEach_contextVariant_passesContextWithoutCapture() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); + FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); - @Override - public long hashKey(String key) { - return LAST_SLOT_HASH; + Set seen = new HashSet<>(); + FlatHashtable.forEach(table, seen, (ctx, e) -> ctx.add(e.key)); + assertEquals(new HashSet<>(Arrays.asList("a", "b")), seen); } - @Override - public boolean matches(TestEntry entry, String key) { - return key.equals(entry.key); + @Test + void iterator_yieldsEveryEntrySharingTheHash() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // COLLIDING sends all to slot 0 + TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); + + Set seen = new HashSet<>(); + Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); + while (it.hasNext()) { + seen.add(it.next()); + } + assertEquals(new HashSet<>(Arrays.asList(a, b, c)), seen); } - @Override - public long hashOf(TestEntry entry) { - return LAST_SLOT_HASH; + @Test + void iterator_filtersOutEntriesWithADifferentHash() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // entries at slot 0, hashOf == 0 + FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + + // a hash that shares the entries' home slot (0) but that no stored entry has as its hashOf + long sameHomeOtherHash = hashLandingOn(0, table.length - 1); + Iterator it = FlatHashtable.iterator(table, sameHomeOtherHash, TestCollidingStrategy.INSTANCE); + assertFalse(it.hasNext()); } - } - /** - * Entry that caches its own hash (extends the Entry base) — for the entry-taking insert flavor. - */ - static final class TestHashedEntry extends FlatHashtable.Entry { - final String key; - - TestHashedEntry(String key) { - super(key.hashCode()); // cache the default hashKey (key.hashCode()) - this.key = key; - } - } - - /** Strategy for {@link TestHashedEntry}: {@code hashOf} reads the cached hash. */ - static final class TestHashedStrategy - extends FlatHashtable.EntryStrategy { - static final TestHashedStrategy INSTANCE = new TestHashedStrategy(); - - private TestHashedStrategy() {} - - @Override - public boolean matches(TestHashedEntry entry, String key) { - return key.equals(entry.key); - } - - @Override - public long hashOf(TestHashedEntry entry) { - return entry.hash; - } - } - - /** - * Case-insensitive strategy: {@code hashKey} sealed by the CI base, {@code matches} folds case. - */ - static final class TestCaseInsensitiveStrategy - extends FlatHashtable.CaseInsensitiveStringStrategy { - static final TestCaseInsensitiveStrategy INSTANCE = new TestCaseInsensitiveStrategy(); - - private TestCaseInsensitiveStrategy() {} - - @Override - public boolean matches(TestEntry entry, String key) { - return key.equalsIgnoreCase(entry.key); - } - - @Override - public long hashOf(TestEntry entry) { - return Strings.caseInsensitiveHashCode(entry.key); - } - } - - @Test - void capacityFor_roundsToPowerOfTwoAtLeastTwiceLimit() { - assertEquals(2, FlatHashtable.capacityFor(1)); - assertEquals(8, FlatHashtable.capacityFor(4)); - assertEquals(16, FlatHashtable.capacityFor(6)); // 6*2-1=11 -> 8 -> 16 - } - - @Test - void capacityFor_rejectsNonPositive() { - assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(0)); - assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(-1)); - } - - @Test - void create_allocatesTypedTableOfCapacity() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - assertEquals(8, table.length); - assertEquals(TestEntry.class, table.getClass().getComponentType()); - } - - @Test - void getOrCreate_insertsOnceAndReturnsTheExistingEntry() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - TestEntry first = FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); - assertEquals("a", first.key); - // A second call must return the SAME instance, not mint a new one. - assertSame(first, FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE)); - assertSame(first, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE)); - } - - @Test - void get_returnsNullForAbsentKey() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - assertNull(FlatHashtable.get(table, "missing", TestEntryStrategy.INSTANCE)); - FlatHashtable.getOrCreate(table, "present", TestEntryStrategy.INSTANCE, CREATE); - assertNull(FlatHashtable.get(table, "still-missing", TestEntryStrategy.INSTANCE)); - } - - @Test - void getOrCreate_returnsNullWhenTableIsFull() { - // capacityFor(1) == 2 slots. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - assertTrue(FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE) != null); - assertTrue(FlatHashtable.getOrCreate(table, "k1", TestEntryStrategy.INSTANCE, CREATE) != null); - // Both slots occupied by distinct keys -> a third distinct key finds no room. - assertNull(FlatHashtable.getOrCreate(table, "k2", TestEntryStrategy.INSTANCE, CREATE)); - // ...but an existing key still resolves even when full. - assertSame( - FlatHashtable.get(table, "k0", TestEntryStrategy.INSTANCE), - FlatHashtable.getOrCreate(table, "k0", TestEntryStrategy.INSTANCE, CREATE)); - } - - @Test - void hashKey_isStableForEqualKeys() { - assertEquals( - TestEntryStrategy.INSTANCE.hashKey("route"), - TestEntryStrategy.INSTANCE.hashKey(new String("route"))); - } - - @Test - void collision_probesPastOccupiedSlots_andResolvesEach() { - // 8 slots; COLLIDING sends all to slot 0 - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - // slot 0 taken -> 1 - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); - // -> slot 2 - TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); - - assertNotSame(a, b); - assertNotSame(b, c); - - // each resolves via probe-past-occupied + match-after-probe - assertSame(a, FlatHashtable.get(table, "a", TestCollidingStrategy.INSTANCE)); - assertSame(b, FlatHashtable.get(table, "b", TestCollidingStrategy.INSTANCE)); - assertSame(c, FlatHashtable.get(table, "c", TestCollidingStrategy.INSTANCE)); - - // existing colliding key: found after probing, no new entry minted - assertSame(b, FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE)); - - // absent key: probe past the 3 occupied slots, hit an empty slot -> null - assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); - } - - @Test - void collision_probeWrapsAroundToFront() { - // 2 slots (0,1), mask=1; LAST_SLOT starts at 1 - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - // -> slot 1 - TestEntry k0 = FlatHashtable.getOrCreate(table, "k0", TestLastSlotStrategy.INSTANCE, CREATE); - // taken -> wraps to 0 - TestEntry k1 = FlatHashtable.getOrCreate(table, "k1", TestLastSlotStrategy.INSTANCE, CREATE); - - assertNotSame(k0, k1); - assertSame(k0, FlatHashtable.get(table, "k0", TestLastSlotStrategy.INSTANCE)); - // start slot 1 is occupied (no match) -> probe wraps to slot 0 -> match - assertSame(k1, FlatHashtable.get(table, "k1", TestLastSlotStrategy.INSTANCE)); - } - - @Test - void get_returnsNullWhenTableFullAndKeyAbsent() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots - FlatHashtable.getOrCreate(table, "k0", TestCollidingStrategy.INSTANCE, CREATE); - // fills slots 0 and 1 - FlatHashtable.getOrCreate(table, "k1", TestCollidingStrategy.INSTANCE, CREATE); - - // get() probes both occupied slots, wraps back to start -> null (get's full-wrap branch) - assertNull(FlatHashtable.get(table, "absent", TestCollidingStrategy.INSTANCE)); - } - - @Test - void insert_generalFlavor_placesViaHashOfAndResolves() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - TestEntry e = new TestEntry("a"); - // flavor 2: the home comes from TestEntryStrategy.INSTANCE.hashOf(e) - assertTrue(FlatHashtable.insert(table, e, TestEntryStrategy.INSTANCE)); - assertSame(e, FlatHashtable.get(table, "a", TestEntryStrategy.INSTANCE)); - } - - @Test - void insert_entryFlavor_placesViaCachedHashAndResolves() { - TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 8); - TestHashedEntry e = new TestHashedEntry("a"); - // flavor 1: the home comes from the Entry's own cached hash, no strategy needed - assertTrue(FlatHashtable.insert(table, e)); - assertSame(e, FlatHashtable.get(table, "a", TestHashedStrategy.INSTANCE)); - } - - @Test - void insert_returnsFalseWhenFull() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots - assertTrue(FlatHashtable.insert(table, new TestEntry("k0"), TestEntryStrategy.INSTANCE)); - assertTrue(FlatHashtable.insert(table, new TestEntry("k1"), TestEntryStrategy.INSTANCE)); - // no room - assertFalse(FlatHashtable.insert(table, new TestEntry("k2"), TestEntryStrategy.INSTANCE)); - } - - @Test - void forEach_visitsEveryEntry() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "c", TestEntryStrategy.INSTANCE, CREATE); - - Set seen = new HashSet<>(); - FlatHashtable.forEach(table, e -> seen.add(e.key)); - assertEquals(new HashSet<>(Arrays.asList("a", "b", "c")), seen); - } - - @Test - void forEach_contextVariant_passesContextWithoutCapture() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 8); - FlatHashtable.getOrCreate(table, "a", TestEntryStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestEntryStrategy.INSTANCE, CREATE); - - Set seen = new HashSet<>(); - FlatHashtable.forEach(table, seen, (ctx, e) -> ctx.add(e.key)); - assertEquals(new HashSet<>(Arrays.asList("a", "b")), seen); - } - - @Test - void iterator_yieldsEveryEntrySharingTheHash() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // COLLIDING sends all to slot 0 - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry c = FlatHashtable.getOrCreate(table, "c", TestCollidingStrategy.INSTANCE, CREATE); - - Set seen = new HashSet<>(); - Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); - while (it.hasNext()) { - seen.add(it.next()); - } - assertEquals(new HashSet<>(Arrays.asList(a, b, c)), seen); - } - - @Test - void iterator_filtersOutEntriesWithADifferentHash() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); // entries at slot 0, hashOf == 0 - FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); - - // a hash that shares the entries' home slot (0) but that no stored entry has as its hashOf - long sameHomeOtherHash = hashLandingOn(0, table.length - 1); - Iterator it = - FlatHashtable.iterator(table, sameHomeOtherHash, TestCollidingStrategy.INSTANCE); - assertFalse(it.hasNext()); - } - - @Test - void iterator_emptyRunHasNoNext() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); - assertFalse(it.hasNext()); - assertThrows(NoSuchElementException.class, it::next); - } - - @Test - void iterator_fullTable_yieldsMatchesIncludingTheWrappingSlot() { - // 2 slots, both filled by colliding (hash 0) entries -> the probe has no empty slot to stop at, - // so the traversal must yield the entry on the wrapping slot and then terminate on wrap. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); - - Set seen = new HashSet<>(); - Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); - while (it.hasNext()) { - seen.add(it.next()); - } - assertEquals(new HashSet<>(Arrays.asList(a, b)), seen); - } - - @Test - void iterator_fullTable_absentHash_terminatesOnWrap() { - // Full table, iterating a hash no stored entry has (all hashOf == 0) -> the traversal walks - // every slot and wraps without ever hitting an empty one, then reports no elements. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); - FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); - FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); - - Iterator it = FlatHashtable.iterator(table, 5, TestCollidingStrategy.INSTANCE); - assertFalse(it.hasNext()); - } - - @Test - void capacityFor_honorsLoadFactor() { - // default 0.5 -> >= 2x; LOW 0.25 -> >= 4x. - assertEquals(8, FlatHashtable.capacityFor(4, FlatHashtable.DEFAULT_LOAD_FACTOR)); - assertEquals(16, FlatHashtable.capacityFor(4, FlatHashtable.LOW_LOAD_FACTOR)); - // capacityFor(cardinalityLimit) delegates to the default. - assertEquals(FlatHashtable.capacityFor(6), FlatHashtable.capacityFor(6, 0.5f)); - } - - @Test - void capacityFor_rejectsLoadFactorOutOfRange() { - assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, 0f)); - assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, 1f)); - assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, -0.1f)); - } - - @Test - void capacityFor_rejectsCardinalityRequiringCapacityAboveMaxInt() { - // 536870913 at 0.5 needs 2^31 slots, which overflows a signed int -> clear error, not - // an opaque NegativeArraySizeException from create(). - assertThrows( - IllegalArgumentException.class, - () -> FlatHashtable.capacityFor(536870913, FlatHashtable.DEFAULT_LOAD_FACTOR)); - assertThrows( - IllegalArgumentException.class, - () -> FlatHashtable.capacityFor(268435457, FlatHashtable.LOW_LOAD_FACTOR)); - } - - @Test - void grownLength_doublesNormally() { - assertEquals(8, FlatHashtable.grownLength(4)); - assertEquals(1 << 30, FlatHashtable.grownLength(1 << 29)); - } - - @Test - void grownLength_rejectsOverflowingCurrentLength() { - // 1 << 30 doubled overflows a signed int -> clear error, not an opaque - // NegativeArraySizeException from Array.newInstance() deep inside resize()/grow(). - assertThrows(IllegalStateException.class, () -> FlatHashtable.grownLength(1 << 30)); - } - - @Test - void create_honorsLoadFactor() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4, FlatHashtable.LOW_LOAD_FACTOR); - assertEquals(16, table.length); - } - - @Test - void resize_generalFlavor_growsAndKeepsEveryEntryFindable() { - // 8 slots with only 2 entries -> the rehash loop skips empty slots as well as copying entries. - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - FlatHashtable.insert(table, new TestEntry("a"), TestEntryStrategy.INSTANCE); - FlatHashtable.insert(table, new TestEntry("b"), TestEntryStrategy.INSTANCE); - - TestEntry[] grown = FlatHashtable.resize(table, TestEntryStrategy.INSTANCE); - assertEquals(16, grown.length); - assertNotSame(table, grown); - assertEquals("a", FlatHashtable.get(grown, "a", TestEntryStrategy.INSTANCE).key); - assertEquals("b", FlatHashtable.get(grown, "b", TestEntryStrategy.INSTANCE).key); - } - - @Test - void resize_entryFlavor_growsAndKeepsEveryEntryFindable() { - TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 4); // 8 slots, 2 entries - FlatHashtable.insert(table, new TestHashedEntry("a")); - FlatHashtable.insert(table, new TestHashedEntry("b")); - - TestHashedEntry[] grown = FlatHashtable.resize(table); - assertEquals(16, grown.length); - assertEquals("a", FlatHashtable.get(grown, "a", TestHashedStrategy.INSTANCE).key); - assertEquals("b", FlatHashtable.get(grown, "b", TestHashedStrategy.INSTANCE).key); - } - - @Test - void resizingInsert_generalFlavor_growsPastCapacityAndReturnsTheTable() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots - for (int i = 0; i < 10; ++i) { - table = - FlatHashtable.resizingInsert(table, new TestEntry("k" + i), TestEntryStrategy.INSTANCE); - } - assertTrue(table.length >= 16); // grew from 2 to hold 10 at load factor <= 0.5 - for (int i = 0; i < 10; ++i) { - assertEquals("k" + i, FlatHashtable.get(table, "k" + i, TestEntryStrategy.INSTANCE).key); - } - } - - @Test - void resizingInsert_entryFlavor_growsPastCapacityAndReturnsTheTable() { - TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 1); // 2 slots - for (int i = 0; i < 10; ++i) { - table = FlatHashtable.resizingInsert(table, new TestHashedEntry("k" + i)); - } - assertTrue(table.length >= 16); - for (int i = 0; i < 10; ++i) { - assertEquals("k" + i, FlatHashtable.get(table, "k" + i, TestHashedStrategy.INSTANCE).key); - } - } - - /** Entry with an explicitly-set cached hash — lets a test force a shared-hash bucket. */ - static final class TestHashEntry extends FlatHashtable.Entry { - final String key; - - TestHashEntry(String key, long hash) { - super(hash); - this.key = key; - } - } - - @Test - void entryIterator_yieldsEveryEntrySharingTheHash() { - TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); - TestHashEntry a = new TestHashEntry("a", 0); - TestHashEntry b = new TestHashEntry("b", 0); - TestHashEntry c = new TestHashEntry("c", 0); - FlatHashtable.insert(table, a); - FlatHashtable.insert(table, b); - FlatHashtable.insert(table, c); - - Set seen = new HashSet<>(); - // 2-arg Entry overload -> the specialized iterator (constant strategy, inlined hashOf). - Iterator it = FlatHashtable.iterator(table, 0); - while (it.hasNext()) { - seen.add(it.next()); - } - assertEquals(new HashSet<>(Arrays.asList(a, b, c)), seen); - } - - @Test - void entryIterator_filtersOutEntriesWithADifferentHash() { - TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); // 8 slots, mask 7 - FlatHashtable.insert(table, new TestHashEntry("a", 0)); - FlatHashtable.insert(table, new TestHashEntry("b", 0)); - // shares the home slot (0) but a different hash -> must be filtered out - long otherHash = hashLandingOn(0, table.length - 1); - FlatHashtable.insert(table, new TestHashEntry("c", otherHash)); - - int yielded = 0; - Iterator it = FlatHashtable.iterator(table, 0); - while (it.hasNext()) { - assertEquals(0, it.next().hash); - yielded++; - } - assertEquals(2, yielded); - } - - @Test - void entryIterator_emptyRunHasNoNext() { - TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); - Iterator it = FlatHashtable.iterator(table, 0); - assertFalse(it.hasNext()); - assertThrows(NoSuchElementException.class, it::next); - } - - @Test - void caseInsensitiveStrategy_matchesRegardlessOfCase() { - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - TestEntry stored = - FlatHashtable.getOrCreate( - table, "Content-Type", TestCaseInsensitiveStrategy.INSTANCE, CREATE); - - // Look-ups in any case resolve to the same stored entry, allocation-free. - assertSame( - stored, FlatHashtable.get(table, "content-type", TestCaseInsensitiveStrategy.INSTANCE)); - assertSame( - stored, FlatHashtable.get(table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE)); - assertSame( - stored, FlatHashtable.get(table, "cOnTeNt-TyPe", TestCaseInsensitiveStrategy.INSTANCE)); - // getOrCreate with a differently-cased key does not mint a second entry. - assertSame( - stored, - FlatHashtable.getOrCreate( - table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE, CREATE)); - assertNull(FlatHashtable.get(table, "content-length", TestCaseInsensitiveStrategy.INSTANCE)); - } - - /** - * End-to-end regression for the supplementary-plane false-miss: a per-{@code char} hash fold - * hashed this pair differently even where {@code equalsIgnoreCase} treats them as equal (JDK 9+), - * so {@code get} would stop probing at the first empty slot and never find the stored entry. - * Folding by code point keeps the hash consistent with {@code matches}, so the lookup finds it. - * Guarded by {@code equalsIgnoreCase} itself so the test is correct on JDK 8 too, where the pair - * is merely a benign collision {@code matches} resolves to no match. - */ - @Test - void caseInsensitiveStrategy_doesNotFalseMissOnSupplementaryCasePair() { - String s1 = new String(Character.toChars(0x10400)); // DESERET CAPITAL LETTER LONG I - String s2 = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I - TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); - TestEntry stored = - FlatHashtable.getOrCreate(table, s1, TestCaseInsensitiveStrategy.INSTANCE, CREATE); - - if (s1.equalsIgnoreCase(s2)) { - assertSame(stored, FlatHashtable.get(table, s2, TestCaseInsensitiveStrategy.INSTANCE)); - } else { - assertNull(FlatHashtable.get(table, s2, TestCaseInsensitiveStrategy.INSTANCE)); - } - } + @Test + void iterator_emptyRunHasNoNext() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); + } + + @Test + void iterator_fullTable_yieldsMatchesIncludingTheWrappingSlot() { + // 2 slots, both filled by colliding (hash 0) entries -> the probe has no empty slot to stop at, + // so the traversal must yield the entry on the wrapping slot and then terminate on wrap. + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + TestEntry a = FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + TestEntry b = FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + + Set seen = new HashSet<>(); + Iterator it = FlatHashtable.iterator(table, 0, TestCollidingStrategy.INSTANCE); + while (it.hasNext()) { + seen.add(it.next()); + } + assertEquals(new HashSet<>(Arrays.asList(a, b)), seen); + } + + @Test + void iterator_fullTable_absentHash_terminatesOnWrap() { + // Full table, iterating a hash no stored entry has (all hashOf == 0) -> the traversal walks + // every slot and wraps without ever hitting an empty one, then reports no elements. + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); + FlatHashtable.getOrCreate(table, "a", TestCollidingStrategy.INSTANCE, CREATE); + FlatHashtable.getOrCreate(table, "b", TestCollidingStrategy.INSTANCE, CREATE); + + Iterator it = FlatHashtable.iterator(table, 5, TestCollidingStrategy.INSTANCE); + assertFalse(it.hasNext()); + } + + @Test + void capacityFor_honorsLoadFactor() { + // default 0.5 -> >= 2x; LOW 0.25 -> >= 4x. + assertEquals(8, FlatHashtable.capacityFor(4, FlatHashtable.DEFAULT_LOAD_FACTOR)); + assertEquals(16, FlatHashtable.capacityFor(4, FlatHashtable.LOW_LOAD_FACTOR)); + // capacityFor(cardinalityLimit) delegates to the default. + assertEquals(FlatHashtable.capacityFor(6), FlatHashtable.capacityFor(6, 0.5f)); + } + + @Test + void capacityFor_rejectsLoadFactorOutOfRange() { + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, 0f)); + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, 1f)); + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(4, -0.1f)); + } + + @Test + void capacityFor_rejectsCardinalityRequiringCapacityAboveMaxInt() { + // 536870913 at 0.5 needs 2^31 slots, which overflows a signed int -> clear error, not + // an opaque NegativeArraySizeException from create(). + assertThrows( + IllegalArgumentException.class, + () -> FlatHashtable.capacityFor(536870913, FlatHashtable.DEFAULT_LOAD_FACTOR)); + assertThrows( + IllegalArgumentException.class, + () -> FlatHashtable.capacityFor(268435457, FlatHashtable.LOW_LOAD_FACTOR)); + } + + @Test + void grownLength_doublesNormally() { + assertEquals(8, FlatHashtable.grownLength(4)); + assertEquals(1 << 30, FlatHashtable.grownLength(1 << 29)); + } + + @Test + void grownLength_rejectsOverflowingCurrentLength() { + // 1 << 30 doubled overflows a signed int -> clear error, not an opaque + // NegativeArraySizeException from Array.newInstance() deep inside resize()/grow(). + assertThrows(IllegalStateException.class, () -> FlatHashtable.grownLength(1 << 30)); + } + + @Test + void create_honorsLoadFactor() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4, FlatHashtable.LOW_LOAD_FACTOR); + assertEquals(16, table.length); + } + + @Test + void resize_generalFlavor_growsAndKeepsEveryEntryFindable() { + // 8 slots with only 2 entries -> the rehash loop skips empty slots as well as copying entries. + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + FlatHashtable.insert(table, new TestEntry("a"), TestEntryStrategy.INSTANCE); + FlatHashtable.insert(table, new TestEntry("b"), TestEntryStrategy.INSTANCE); + + TestEntry[] grown = FlatHashtable.resize(table, TestEntryStrategy.INSTANCE); + assertEquals(16, grown.length); + assertNotSame(table, grown); + assertEquals("a", FlatHashtable.get(grown, "a", TestEntryStrategy.INSTANCE).key); + assertEquals("b", FlatHashtable.get(grown, "b", TestEntryStrategy.INSTANCE).key); + } + + @Test + void resize_entryFlavor_growsAndKeepsEveryEntryFindable() { + TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 4); // 8 slots, 2 entries + FlatHashtable.insert(table, new TestHashedEntry("a")); + FlatHashtable.insert(table, new TestHashedEntry("b")); + + TestHashedEntry[] grown = FlatHashtable.resize(table); + assertEquals(16, grown.length); + assertEquals("a", FlatHashtable.get(grown, "a", TestHashedStrategy.INSTANCE).key); + assertEquals("b", FlatHashtable.get(grown, "b", TestHashedStrategy.INSTANCE).key); + } + + @Test + void resizingInsert_generalFlavor_growsPastCapacityAndReturnsTheTable() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 1); // 2 slots + for (int i = 0; i < 10; ++i) { + table = FlatHashtable.resizingInsert(table, new TestEntry("k" + i), TestEntryStrategy.INSTANCE); + } + assertTrue(table.length >= 16); // grew from 2 to hold 10 at load factor <= 0.5 + for (int i = 0; i < 10; ++i) { + assertEquals("k" + i, FlatHashtable.get(table, "k" + i, TestEntryStrategy.INSTANCE).key); + } + } + + @Test + void resizingInsert_entryFlavor_growsPastCapacityAndReturnsTheTable() { + TestHashedEntry[] table = FlatHashtable.create(TestHashedEntry.class, 1); // 2 slots + for (int i = 0; i < 10; ++i) { + table = FlatHashtable.resizingInsert(table, new TestHashedEntry("k" + i)); + } + assertTrue(table.length >= 16); + for (int i = 0; i < 10; ++i) { + assertEquals("k" + i, FlatHashtable.get(table, "k" + i, TestHashedStrategy.INSTANCE).key); + } + } + + /** Entry with an explicitly-set cached hash — lets a test force a shared-hash bucket. */ + static final class TestHashEntry extends FlatHashtable.Entry { + final String key; + + TestHashEntry(String key, long hash) { + super(hash); + this.key = key; + } + } + + @Test + void entryIterator_yieldsEveryEntrySharingTheHash() { + TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); + TestHashEntry a = new TestHashEntry("a", 0); + TestHashEntry b = new TestHashEntry("b", 0); + TestHashEntry c = new TestHashEntry("c", 0); + FlatHashtable.insert(table, a); + FlatHashtable.insert(table, b); + FlatHashtable.insert(table, c); + + Set seen = new HashSet<>(); + // 2-arg Entry overload -> the specialized iterator (constant strategy, inlined hashOf). + Iterator it = FlatHashtable.iterator(table, 0); + while (it.hasNext()) { + seen.add(it.next()); + } + assertEquals(new HashSet<>(Arrays.asList(a, b, c)), seen); + } + + @Test + void entryIterator_filtersOutEntriesWithADifferentHash() { + TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); // 8 slots, mask 7 + FlatHashtable.insert(table, new TestHashEntry("a", 0)); + FlatHashtable.insert(table, new TestHashEntry("b", 0)); + // shares the home slot (0) but a different hash -> must be filtered out + long otherHash = hashLandingOn(0, table.length - 1); + FlatHashtable.insert(table, new TestHashEntry("c", otherHash)); + + int yielded = 0; + Iterator it = FlatHashtable.iterator(table, 0); + while (it.hasNext()) { + assertEquals(0, it.next().hash); + yielded++; + } + assertEquals(2, yielded); + } + + @Test + void entryIterator_emptyRunHasNoNext() { + TestHashEntry[] table = FlatHashtable.create(TestHashEntry.class, 4); + Iterator it = FlatHashtable.iterator(table, 0); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); + } + + @Test + void caseInsensitiveStrategy_matchesRegardlessOfCase() { + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry stored = + FlatHashtable.getOrCreate(table, "Content-Type", TestCaseInsensitiveStrategy.INSTANCE, CREATE); + + // Look-ups in any case resolve to the same stored entry, allocation-free. + assertSame(stored, FlatHashtable.get(table, "content-type", TestCaseInsensitiveStrategy.INSTANCE)); + assertSame(stored, FlatHashtable.get(table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE)); + assertSame(stored, FlatHashtable.get(table, "cOnTeNt-TyPe", TestCaseInsensitiveStrategy.INSTANCE)); + // getOrCreate with a differently-cased key does not mint a second entry. + assertSame( + stored, FlatHashtable.getOrCreate(table, "CONTENT-TYPE", TestCaseInsensitiveStrategy.INSTANCE, CREATE)); + assertNull(FlatHashtable.get(table, "content-length", TestCaseInsensitiveStrategy.INSTANCE)); + } + + /** + * End-to-end regression for the supplementary-plane false-miss: a per-{@code char} hash fold + * hashed this pair differently even where {@code equalsIgnoreCase} treats them as equal (JDK 9+), + * so {@code get} would stop probing at the first empty slot and never find the stored entry. + * Folding by code point keeps the hash consistent with {@code matches}, so the lookup finds it. + * Guarded by {@code equalsIgnoreCase} itself so the test is correct on JDK 8 too, where the pair + * is merely a benign collision {@code matches} resolves to no match. + */ + @Test + void caseInsensitiveStrategy_doesNotFalseMissOnSupplementaryCasePair() { + String s1 = new String(Character.toChars(0x10400)); // DESERET CAPITAL LETTER LONG I + String s2 = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I + TestEntry[] table = FlatHashtable.create(TestEntry.class, 4); + TestEntry stored = FlatHashtable.getOrCreate(table, s1, TestCaseInsensitiveStrategy.INSTANCE, CREATE); + + if (s1.equalsIgnoreCase(s2)) { + assertSame(stored, FlatHashtable.get(table, s2, TestCaseInsensitiveStrategy.INSTANCE)); + } else { + assertNull(FlatHashtable.get(table, s2, TestCaseInsensitiveStrategy.INSTANCE)); + } + } } diff --git a/internal-api/src/test/java/datadog/trace/util/HashingUtilsTest.java b/internal-api/src/test/java/datadog/trace/util/HashingUtilsTest.java index a4997508f3b..a69376bf7a3 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashingUtilsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashingUtilsTest.java @@ -9,265 +9,251 @@ import org.junit.jupiter.params.provider.ValueSource; public class HashingUtilsTest { - @Test - public void hashCode_() { - assertEquals("bar".hashCode(), HashingUtils.hashCode("bar")); - } - - @Test - public void hashCodeNull() { - assertEquals(0, HashingUtils.hash((Object) null)); - } - - @Test - public void hash1() { - assertEquals("foo".hashCode(), HashingUtils.hashCode("foo")); - } - - @Test - public void hash1Null() { - assertEquals(0, HashingUtils.hashCode(null)); - } - - @Test - public void hash2() { - String str0 = "foo"; - String str1 = "bar"; - - assertNotEquals(0, HashingUtils.hash(str0, str1)); - - String clone0 = clone(str0); - String clone1 = clone(str1); - - assertEquals(HashingUtils.hash(str0, str1), HashingUtils.hash(clone0, clone1)); - } - - @Test - public void hash2Null() { - assertEquals(0, HashingUtils.hash(null, null)); - } - - @Test - public void hash3() { - String str0 = "foo"; - String str2 = "quux"; - String str1 = "bar"; - - assertNotEquals(0, HashingUtils.hash(str0, str1, str2)); - - String clone0 = clone(str0); - String clone1 = clone(str1); - String clone2 = clone(str2); - - assertEquals(HashingUtils.hash(str0, str1, str2), HashingUtils.hash(clone0, clone1, clone2)); - } - - @Test - public void hash3Null() { - assertEquals(0, HashingUtils.hash(null, null, null)); - } - - @Test - public void hash4() { - String str0 = "foo"; - String str1 = "bar"; - String str2 = "quux"; - String str3 = "foobar"; - - assertNotEquals(0, HashingUtils.hash(str0, str1, str2, str3)); - - String clone0 = clone(str0); - String clone1 = clone(str1); - String clone2 = clone(str2); - String clone3 = clone(str3); - - assertEquals( - HashingUtils.hash(str0, str1, str2, str3), - HashingUtils.hash(clone0, clone1, clone2, clone3)); - } - - @Test - public void hash4Null() { - assertEquals(0, HashingUtils.hash(null, null, null, null)); - } - - @Test - public void hash5() { - String str0 = "foo"; - String str1 = "bar"; - String str2 = "quux"; - String str3 = "foobar"; - String str4 = "hello"; - - assertNotEquals(0, HashingUtils.hash(str0, str1, str2, str3, str4)); - - String clone0 = clone(str0); - String clone1 = clone(str1); - String clone2 = clone(str2); - String clone3 = clone(str3); - String clone4 = clone(str4); - - assertEquals( - HashingUtils.hash(str0, str1, str2, str3, str4), - HashingUtils.hash(clone0, clone1, clone2, clone3, clone4)); - - // The 5th argument must actually affect the hash (regression for a missing-arg bug). - assertNotEquals( - HashingUtils.hash(str0, str1, str2, str3, str4), - HashingUtils.hash(str0, str1, str2, str3, "different")); - } - - @Test - public void hash5Null() { - assertEquals(0, HashingUtils.hash(null, null, null, null, null)); - } - - @Test - public void hashArrayAndIterable() { - String str0 = "foo"; - String str1 = "bar"; - String str2 = "quux"; - String str3 = "foobar"; - String str4 = "foobaz"; - String str5 = "hello"; - String str6 = "world"; - - Object[] array = new Object[] {str0, str1, str2, str3, str4, str5, str6}; - - int hashArray = HashingUtils.hash(array); - assertNotEquals(0, hashArray); - - int hashIterable = HashingUtils.hash(Arrays.asList(array)); - assertNotEquals(0, hashIterable); - - assertEquals(hashArray, hashIterable); - } - - @Test - public void addToHashArrayFoldsFromSeedLikeChainedAddToHash() { - Object[] array = new Object[] {"foo", "bar", "quux"}; - - // Full-array overload folds every element onto the seed; from zero it matches hash(Object[]). - assertEquals(HashingUtils.hash(array), HashingUtils.addToHash(0, array)); - - // A non-zero seed carries through, so the result differs from the zero-seed fold. - assertNotEquals(HashingUtils.addToHash(0, array), HashingUtils.addToHash(1, array)); - } - - @Test - public void addToHashArrayRespectsLen() { - Object[] array = new Object[] {"foo", "bar", "quux"}; - - // The len override folds only the first len elements. - assertEquals( - HashingUtils.hash(new Object[] {"foo", "bar"}), HashingUtils.addToHash(0, array, 2)); - assertNotEquals(HashingUtils.addToHash(0, array), HashingUtils.addToHash(0, array, 2)); - - // len==0 never enters the loop and returns the seed unchanged. - assertEquals(42, HashingUtils.addToHash(42, array, 0)); - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - public void booleans(boolean value) { - assertEquals(Boolean.hashCode(value), HashingUtils.hash(value)); - assertEquals(Boolean.hashCode(value), HashingUtils.addToHash(0, value)); - } - - @ParameterizedTest - @ValueSource(chars = {Character.MIN_VALUE, 'a', 'A', '\0', 'z', 'Z', Character.MAX_VALUE}) - public void chars(char value) { - assertEquals(Character.hashCode(value), HashingUtils.hash(value)); - assertEquals(Character.hashCode(value), HashingUtils.addToHash(0, value)); - } - - @ParameterizedTest - @ValueSource(bytes = {Byte.MIN_VALUE, -1, 0, 1, Byte.MAX_VALUE}) - public void bytes(byte value) { - assertEquals(Byte.hashCode(value), HashingUtils.hash(value)); - assertEquals(Byte.hashCode(value), HashingUtils.addToHash(0, value)); - } - - @ParameterizedTest - @ValueSource( - shorts = {Short.MIN_VALUE, Byte.MIN_VALUE, -1, 0, 1, Byte.MAX_VALUE, Short.MAX_VALUE}) - public void shorts(short value) { - assertEquals(Short.hashCode(value), HashingUtils.hash(value)); - assertEquals(Short.hashCode(value), HashingUtils.addToHash(0, value)); - } - - @ParameterizedTest - @ValueSource( - ints = { - Integer.MIN_VALUE, - Short.MIN_VALUE, - Byte.MIN_VALUE, - -1, - 0, - 1, - Byte.MAX_VALUE, - Short.MAX_VALUE, - Integer.MAX_VALUE - }) - public void ints(int value) { - assertEquals(Integer.hashCode(value), HashingUtils.hash(value)); - assertEquals(Integer.hashCode(value), HashingUtils.addToHash(0, value)); - } - - @ParameterizedTest - @ValueSource( - longs = { - Long.MIN_VALUE, - Integer.MIN_VALUE, - Short.MIN_VALUE, - Byte.MIN_VALUE, - -1, - 0, - 1, - Byte.MAX_VALUE, - Short.MAX_VALUE, - Integer.MAX_VALUE, - Long.MAX_VALUE - }) - public void longs(long value) { - assertEquals(Long.hashCode(value), HashingUtils.hash(value)); - assertEquals(Long.hashCode(value), HashingUtils.addToHash(0, value)); - } - - @ParameterizedTest - @ValueSource(floats = {Float.MIN_VALUE, -1, 0, 1, 2.71828f, 3.1415f, Float.MAX_VALUE}) - public void floats(float value) { - assertEquals(Float.hashCode(value), HashingUtils.hash(value)); - assertEquals(Float.hashCode(value), HashingUtils.addToHash(0, value)); - } - - @ParameterizedTest - @ValueSource( - doubles = { - Double.MIN_VALUE, - Float.MIN_VALUE, - -1, - 0, - 1, - 2.71828, - 3.1415, - Float.MAX_VALUE, - Double.MAX_VALUE - }) - public void floats(double value) { - assertEquals(Double.hashCode(value), HashingUtils.hash(value)); - assertEquals(Double.hashCode(value), HashingUtils.addToHash(0, value)); - } - - static final String clone(String str) { - return new String(str); - } - - static final String[] deepClone(String[] strings) { - String[] clones = new String[strings.length]; - for (int i = 0; i < strings.length; ++i) { - clones[i] = clone(strings[i]); + @Test + public void hashCode_() { + assertEquals("bar".hashCode(), HashingUtils.hashCode("bar")); + } + + @Test + public void hashCodeNull() { + assertEquals(0, HashingUtils.hash((Object) null)); + } + + @Test + public void hash1() { + assertEquals("foo".hashCode(), HashingUtils.hashCode("foo")); + } + + @Test + public void hash1Null() { + assertEquals(0, HashingUtils.hashCode(null)); + } + + @Test + public void hash2() { + String str0 = "foo"; + String str1 = "bar"; + + assertNotEquals(0, HashingUtils.hash(str0, str1)); + + String clone0 = clone(str0); + String clone1 = clone(str1); + + assertEquals(HashingUtils.hash(str0, str1), HashingUtils.hash(clone0, clone1)); + } + + @Test + public void hash2Null() { + assertEquals(0, HashingUtils.hash(null, null)); + } + + @Test + public void hash3() { + String str0 = "foo"; + String str2 = "quux"; + String str1 = "bar"; + + assertNotEquals(0, HashingUtils.hash(str0, str1, str2)); + + String clone0 = clone(str0); + String clone1 = clone(str1); + String clone2 = clone(str2); + + assertEquals(HashingUtils.hash(str0, str1, str2), HashingUtils.hash(clone0, clone1, clone2)); + } + + @Test + public void hash3Null() { + assertEquals(0, HashingUtils.hash(null, null, null)); + } + + @Test + public void hash4() { + String str0 = "foo"; + String str1 = "bar"; + String str2 = "quux"; + String str3 = "foobar"; + + assertNotEquals(0, HashingUtils.hash(str0, str1, str2, str3)); + + String clone0 = clone(str0); + String clone1 = clone(str1); + String clone2 = clone(str2); + String clone3 = clone(str3); + + assertEquals(HashingUtils.hash(str0, str1, str2, str3), HashingUtils.hash(clone0, clone1, clone2, clone3)); + } + + @Test + public void hash4Null() { + assertEquals(0, HashingUtils.hash(null, null, null, null)); + } + + @Test + public void hash5() { + String str0 = "foo"; + String str1 = "bar"; + String str2 = "quux"; + String str3 = "foobar"; + String str4 = "hello"; + + assertNotEquals(0, HashingUtils.hash(str0, str1, str2, str3, str4)); + + String clone0 = clone(str0); + String clone1 = clone(str1); + String clone2 = clone(str2); + String clone3 = clone(str3); + String clone4 = clone(str4); + + assertEquals( + HashingUtils.hash(str0, str1, str2, str3, str4), + HashingUtils.hash(clone0, clone1, clone2, clone3, clone4)); + + // The 5th argument must actually affect the hash (regression for a missing-arg bug). + assertNotEquals( + HashingUtils.hash(str0, str1, str2, str3, str4), + HashingUtils.hash(str0, str1, str2, str3, "different")); + } + + @Test + public void hash5Null() { + assertEquals(0, HashingUtils.hash(null, null, null, null, null)); + } + + @Test + public void hashArrayAndIterable() { + String str0 = "foo"; + String str1 = "bar"; + String str2 = "quux"; + String str3 = "foobar"; + String str4 = "foobaz"; + String str5 = "hello"; + String str6 = "world"; + + Object[] array = new Object[] {str0, str1, str2, str3, str4, str5, str6}; + + int hashArray = HashingUtils.hash(array); + assertNotEquals(0, hashArray); + + int hashIterable = HashingUtils.hash(Arrays.asList(array)); + assertNotEquals(0, hashIterable); + + assertEquals(hashArray, hashIterable); + } + + @Test + public void addToHashArrayFoldsFromSeedLikeChainedAddToHash() { + Object[] array = new Object[] {"foo", "bar", "quux"}; + + // Full-array overload folds every element onto the seed; from zero it matches hash(Object[]). + assertEquals(HashingUtils.hash(array), HashingUtils.addToHash(0, array)); + + // A non-zero seed carries through, so the result differs from the zero-seed fold. + assertNotEquals(HashingUtils.addToHash(0, array), HashingUtils.addToHash(1, array)); + } + + @Test + public void addToHashArrayRespectsLen() { + Object[] array = new Object[] {"foo", "bar", "quux"}; + + // The len override folds only the first len elements. + assertEquals(HashingUtils.hash(new Object[] {"foo", "bar"}), HashingUtils.addToHash(0, array, 2)); + assertNotEquals(HashingUtils.addToHash(0, array), HashingUtils.addToHash(0, array, 2)); + + // len==0 never enters the loop and returns the seed unchanged. + assertEquals(42, HashingUtils.addToHash(42, array, 0)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void booleans(boolean value) { + assertEquals(Boolean.hashCode(value), HashingUtils.hash(value)); + assertEquals(Boolean.hashCode(value), HashingUtils.addToHash(0, value)); + } + + @ParameterizedTest + @ValueSource(chars = {Character.MIN_VALUE, 'a', 'A', '\0', 'z', 'Z', Character.MAX_VALUE}) + public void chars(char value) { + assertEquals(Character.hashCode(value), HashingUtils.hash(value)); + assertEquals(Character.hashCode(value), HashingUtils.addToHash(0, value)); + } + + @ParameterizedTest + @ValueSource(bytes = {Byte.MIN_VALUE, -1, 0, 1, Byte.MAX_VALUE}) + public void bytes(byte value) { + assertEquals(Byte.hashCode(value), HashingUtils.hash(value)); + assertEquals(Byte.hashCode(value), HashingUtils.addToHash(0, value)); + } + + @ParameterizedTest + @ValueSource(shorts = {Short.MIN_VALUE, Byte.MIN_VALUE, -1, 0, 1, Byte.MAX_VALUE, Short.MAX_VALUE}) + public void shorts(short value) { + assertEquals(Short.hashCode(value), HashingUtils.hash(value)); + assertEquals(Short.hashCode(value), HashingUtils.addToHash(0, value)); + } + + @ParameterizedTest + @ValueSource( + ints = { + Integer.MIN_VALUE, + Short.MIN_VALUE, + Byte.MIN_VALUE, + -1, + 0, + 1, + Byte.MAX_VALUE, + Short.MAX_VALUE, + Integer.MAX_VALUE + }) + public void ints(int value) { + assertEquals(Integer.hashCode(value), HashingUtils.hash(value)); + assertEquals(Integer.hashCode(value), HashingUtils.addToHash(0, value)); + } + + @ParameterizedTest + @ValueSource( + longs = { + Long.MIN_VALUE, + Integer.MIN_VALUE, + Short.MIN_VALUE, + Byte.MIN_VALUE, + -1, + 0, + 1, + Byte.MAX_VALUE, + Short.MAX_VALUE, + Integer.MAX_VALUE, + Long.MAX_VALUE + }) + public void longs(long value) { + assertEquals(Long.hashCode(value), HashingUtils.hash(value)); + assertEquals(Long.hashCode(value), HashingUtils.addToHash(0, value)); + } + + @ParameterizedTest + @ValueSource(floats = {Float.MIN_VALUE, -1, 0, 1, 2.71828f, 3.1415f, Float.MAX_VALUE}) + public void floats(float value) { + assertEquals(Float.hashCode(value), HashingUtils.hash(value)); + assertEquals(Float.hashCode(value), HashingUtils.addToHash(0, value)); + } + + @ParameterizedTest + @ValueSource( + doubles = {Double.MIN_VALUE, Float.MIN_VALUE, -1, 0, 1, 2.71828, 3.1415, Float.MAX_VALUE, Double.MAX_VALUE}) + public void floats(double value) { + assertEquals(Double.hashCode(value), HashingUtils.hash(value)); + assertEquals(Double.hashCode(value), HashingUtils.addToHash(0, value)); + } + + static final String clone(String str) { + return new String(str); + } + + static final String[] deepClone(String[] strings) { + String[] clones = new String[strings.length]; + for (int i = 0; i < strings.length; ++i) { + clones[i] = clone(strings[i]); + } + return clones; } - return clones; - } } diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java index a3cd4c25247..e36fbaa2bfd 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD1Test.java @@ -14,228 +14,222 @@ class HashtableD1Test { - @Test - void emptyTableLookupReturnsNull() { - Hashtable.D1 table = new Hashtable.D1<>(8); - assertNull(table.get("missing")); - assertEquals(0, table.size()); - } - - @Test - void insertedEntryIsRetrievable() { - Hashtable.D1 table = new Hashtable.D1<>(8); - StringIntEntry e = new StringIntEntry("foo", 1); - table.insert(e); - assertEquals(1, table.size()); - assertSame(e, table.get("foo")); - } - - @Test - void keyExposesTheConstructionKey() { - StringIntEntry e = new StringIntEntry("foo", 1); - assertEquals("foo", e.key()); - } - - @Test - void multipleInsertsRetrievableSeparately() { - Hashtable.D1 table = new Hashtable.D1<>(16); - StringIntEntry a = new StringIntEntry("alpha", 1); - StringIntEntry b = new StringIntEntry("beta", 2); - StringIntEntry c = new StringIntEntry("gamma", 3); - table.insert(a); - table.insert(b); - table.insert(c); - assertEquals(3, table.size()); - assertSame(a, table.get("alpha")); - assertSame(b, table.get("beta")); - assertSame(c, table.get("gamma")); - } - - @Test - void inPlaceMutationVisibleViaSubsequentGet() { - Hashtable.D1 table = new Hashtable.D1<>(8); - table.insert(new StringIntEntry("counter", 0)); - for (int i = 0; i < 10; i++) { - StringIntEntry e = table.get("counter"); - e.value++; - } - assertEquals(10, table.get("counter").value); - } - - @Test - void removeUnlinksEntryAndDecrementsSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - assertEquals(2, table.size()); - - StringIntEntry removed = table.remove("a"); - assertNotNull(removed); - assertEquals("a", removed.key); - assertEquals(1, table.size()); - assertNull(table.get("a")); - assertNotNull(table.get("b")); - } - - @Test - void removeNonexistentReturnsNullAndDoesNotChangeSize() { - Hashtable.D1 table = new Hashtable.D1<>(8); - table.insert(new StringIntEntry("a", 1)); - assertNull(table.remove("nope")); - assertEquals(1, table.size()); - } - - @Test - void insertOrReplaceReturnsPriorEntryOrNullOnInsert() { - Hashtable.D1 table = new Hashtable.D1<>(8); - StringIntEntry first = new StringIntEntry("k", 1); - assertNull(table.insertOrReplace(first), "fresh insert returns null"); - assertEquals(1, table.size()); - - StringIntEntry second = new StringIntEntry("k", 2); - assertSame(first, table.insertOrReplace(second), "replace returns the prior entry"); - assertEquals(1, table.size()); - assertSame(second, table.get("k"), "new entry visible after replace"); - } - - @Test - void clearEmptiesTheTable() { - Hashtable.D1 table = new Hashtable.D1<>(8); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - table.clear(); - assertEquals(0, table.size()); - assertNull(table.get("a")); - // Reinsertion works after clear - table.insert(new StringIntEntry("a", 99)); - assertEquals(99, table.get("a").value); - } - - @Test - void forEachVisitsEveryInsertedEntry() { - Hashtable.D1 table = new Hashtable.D1<>(8); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - table.insert(new StringIntEntry("c", 3)); - Map seen = new HashMap<>(); - table.forEach(e -> seen.put(e.key, e.value)); - assertEquals(3, seen.size()); - assertEquals(1, seen.get("a")); - assertEquals(2, seen.get("b")); - assertEquals(3, seen.get("c")); - } - - @Test - void forEachWithContextPassesContextToConsumer() { - Hashtable.D1 table = new Hashtable.D1<>(8); - table.insert(new StringIntEntry("a", 10)); - table.insert(new StringIntEntry("b", 20)); - table.insert(new StringIntEntry("c", 30)); - Map seen = new HashMap<>(); - table.forEach(seen, (ctx, e) -> ctx.put(e.key, e.value)); - assertEquals(3, seen.size()); - assertEquals(10, seen.get("a")); - assertEquals(20, seen.get("b")); - assertEquals(30, seen.get("c")); - } - - @Test - void forEachWithContextOnEmptyTableDoesNothing() { - Hashtable.D1 table = new Hashtable.D1<>(8); - Map seen = new HashMap<>(); - table.forEach(seen, (ctx, e) -> ctx.put(e.key, e.value)); - assertEquals(0, seen.size()); - } - - @Test - void nullKeyIsPermittedAndDistinctFromAbsent() { - Hashtable.D1 table = new Hashtable.D1<>(8); - assertNull(table.get(null)); - StringIntEntry nullKeyed = new StringIntEntry(null, 7); - table.insert(nullKeyed); - assertSame(nullKeyed, table.get(null)); - assertEquals(1, table.size()); - assertSame(nullKeyed, table.remove(null)); - assertEquals(0, table.size()); - } - - @Test - void hashCollisionsResolveByEquality() { - // Force two distinct keys with the same hashCode -- the chain must still distinguish them - // via matches(). - Hashtable.D1 table = new Hashtable.D1<>(4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); - CollidingKeyEntry e2 = new CollidingKeyEntry(k2, 200); - table.insert(e1); - table.insert(e2); - assertEquals(2, table.size()); - assertSame(e1, table.get(k1)); - assertSame(e2, table.get(k2)); - } - - @Test - void hashCollisionsThenRemoveLeavesOtherIntact() { - Hashtable.D1 table = new Hashtable.D1<>(4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - CollidingKey k3 = new CollidingKey("third", 17); - table.insert(new CollidingKeyEntry(k1, 1)); - table.insert(new CollidingKeyEntry(k2, 2)); - table.insert(new CollidingKeyEntry(k3, 3)); - table.remove(k2); - assertEquals(2, table.size()); - assertNotNull(table.get(k1)); - assertNull(table.get(k2)); - assertNotNull(table.get(k3)); - } - - @Test - void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); - int[] createCount = {0}; - StringIntEntry created = - table.getOrCreate( - "foo", - k -> { - createCount[0]++; - return new StringIntEntry(k, 42); - }); - assertNotNull(created); - assertEquals("foo", created.key); - assertEquals(42, created.value); - assertEquals(1, table.size()); - assertEquals(1, createCount[0]); - assertSame(created, table.get("foo")); - } - - @Test - void getOrCreateOnHitSkipsCreator() { - Hashtable.D1 table = new Hashtable.D1<>(8); - StringIntEntry seeded = new StringIntEntry("foo", 1); - table.insert(seeded); - int[] createCount = {0}; - StringIntEntry got = - table.getOrCreate( - "foo", - k -> { - createCount[0]++; - return new StringIntEntry(k, 999); - }); - assertSame(seeded, got); - assertEquals(1, table.size()); - assertEquals(0, createCount[0]); - } - - @Test - void getOrCreateNullKeyIsPermitted() { - Hashtable.D1 table = new Hashtable.D1<>(8); - StringIntEntry created = table.getOrCreate(null, k -> new StringIntEntry(k, 7)); - assertNotNull(created); - assertNull(created.key); - assertEquals(7, created.value); - assertSame(created, table.getOrCreate(null, k -> new StringIntEntry(k, 999))); - assertEquals(1, table.size()); - } + @Test + void emptyTableLookupReturnsNull() { + Hashtable.D1 table = new Hashtable.D1<>(8); + assertNull(table.get("missing")); + assertEquals(0, table.size()); + } + + @Test + void insertedEntryIsRetrievable() { + Hashtable.D1 table = new Hashtable.D1<>(8); + StringIntEntry e = new StringIntEntry("foo", 1); + table.insert(e); + assertEquals(1, table.size()); + assertSame(e, table.get("foo")); + } + + @Test + void keyExposesTheConstructionKey() { + StringIntEntry e = new StringIntEntry("foo", 1); + assertEquals("foo", e.key()); + } + + @Test + void multipleInsertsRetrievableSeparately() { + Hashtable.D1 table = new Hashtable.D1<>(16); + StringIntEntry a = new StringIntEntry("alpha", 1); + StringIntEntry b = new StringIntEntry("beta", 2); + StringIntEntry c = new StringIntEntry("gamma", 3); + table.insert(a); + table.insert(b); + table.insert(c); + assertEquals(3, table.size()); + assertSame(a, table.get("alpha")); + assertSame(b, table.get("beta")); + assertSame(c, table.get("gamma")); + } + + @Test + void inPlaceMutationVisibleViaSubsequentGet() { + Hashtable.D1 table = new Hashtable.D1<>(8); + table.insert(new StringIntEntry("counter", 0)); + for (int i = 0; i < 10; i++) { + StringIntEntry e = table.get("counter"); + e.value++; + } + assertEquals(10, table.get("counter").value); + } + + @Test + void removeUnlinksEntryAndDecrementsSize() { + Hashtable.D1 table = new Hashtable.D1<>(8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + assertEquals(2, table.size()); + + StringIntEntry removed = table.remove("a"); + assertNotNull(removed); + assertEquals("a", removed.key); + assertEquals(1, table.size()); + assertNull(table.get("a")); + assertNotNull(table.get("b")); + } + + @Test + void removeNonexistentReturnsNullAndDoesNotChangeSize() { + Hashtable.D1 table = new Hashtable.D1<>(8); + table.insert(new StringIntEntry("a", 1)); + assertNull(table.remove("nope")); + assertEquals(1, table.size()); + } + + @Test + void insertOrReplaceReturnsPriorEntryOrNullOnInsert() { + Hashtable.D1 table = new Hashtable.D1<>(8); + StringIntEntry first = new StringIntEntry("k", 1); + assertNull(table.insertOrReplace(first), "fresh insert returns null"); + assertEquals(1, table.size()); + + StringIntEntry second = new StringIntEntry("k", 2); + assertSame(first, table.insertOrReplace(second), "replace returns the prior entry"); + assertEquals(1, table.size()); + assertSame(second, table.get("k"), "new entry visible after replace"); + } + + @Test + void clearEmptiesTheTable() { + Hashtable.D1 table = new Hashtable.D1<>(8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + table.clear(); + assertEquals(0, table.size()); + assertNull(table.get("a")); + // Reinsertion works after clear + table.insert(new StringIntEntry("a", 99)); + assertEquals(99, table.get("a").value); + } + + @Test + void forEachVisitsEveryInsertedEntry() { + Hashtable.D1 table = new Hashtable.D1<>(8); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + table.insert(new StringIntEntry("c", 3)); + Map seen = new HashMap<>(); + table.forEach(e -> seen.put(e.key, e.value)); + assertEquals(3, seen.size()); + assertEquals(1, seen.get("a")); + assertEquals(2, seen.get("b")); + assertEquals(3, seen.get("c")); + } + + @Test + void forEachWithContextPassesContextToConsumer() { + Hashtable.D1 table = new Hashtable.D1<>(8); + table.insert(new StringIntEntry("a", 10)); + table.insert(new StringIntEntry("b", 20)); + table.insert(new StringIntEntry("c", 30)); + Map seen = new HashMap<>(); + table.forEach(seen, (ctx, e) -> ctx.put(e.key, e.value)); + assertEquals(3, seen.size()); + assertEquals(10, seen.get("a")); + assertEquals(20, seen.get("b")); + assertEquals(30, seen.get("c")); + } + + @Test + void forEachWithContextOnEmptyTableDoesNothing() { + Hashtable.D1 table = new Hashtable.D1<>(8); + Map seen = new HashMap<>(); + table.forEach(seen, (ctx, e) -> ctx.put(e.key, e.value)); + assertEquals(0, seen.size()); + } + + @Test + void nullKeyIsPermittedAndDistinctFromAbsent() { + Hashtable.D1 table = new Hashtable.D1<>(8); + assertNull(table.get(null)); + StringIntEntry nullKeyed = new StringIntEntry(null, 7); + table.insert(nullKeyed); + assertSame(nullKeyed, table.get(null)); + assertEquals(1, table.size()); + assertSame(nullKeyed, table.remove(null)); + assertEquals(0, table.size()); + } + + @Test + void hashCollisionsResolveByEquality() { + // Force two distinct keys with the same hashCode -- the chain must still distinguish them + // via matches(). + Hashtable.D1 table = new Hashtable.D1<>(4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 100); + CollidingKeyEntry e2 = new CollidingKeyEntry(k2, 200); + table.insert(e1); + table.insert(e2); + assertEquals(2, table.size()); + assertSame(e1, table.get(k1)); + assertSame(e2, table.get(k2)); + } + + @Test + void hashCollisionsThenRemoveLeavesOtherIntact() { + Hashtable.D1 table = new Hashtable.D1<>(4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + CollidingKey k3 = new CollidingKey("third", 17); + table.insert(new CollidingKeyEntry(k1, 1)); + table.insert(new CollidingKeyEntry(k2, 2)); + table.insert(new CollidingKeyEntry(k3, 3)); + table.remove(k2); + assertEquals(2, table.size()); + assertNotNull(table.get(k1)); + assertNull(table.get(k2)); + assertNotNull(table.get(k3)); + } + + @Test + void getOrCreateOnMissBuildsEntryViaCreator() { + Hashtable.D1 table = new Hashtable.D1<>(8); + int[] createCount = {0}; + StringIntEntry created = table.getOrCreate("foo", k -> { + createCount[0]++; + return new StringIntEntry(k, 42); + }); + assertNotNull(created); + assertEquals("foo", created.key); + assertEquals(42, created.value); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("foo")); + } + + @Test + void getOrCreateOnHitSkipsCreator() { + Hashtable.D1 table = new Hashtable.D1<>(8); + StringIntEntry seeded = new StringIntEntry("foo", 1); + table.insert(seeded); + int[] createCount = {0}; + StringIntEntry got = table.getOrCreate("foo", k -> { + createCount[0]++; + return new StringIntEntry(k, 999); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); + } + + @Test + void getOrCreateNullKeyIsPermitted() { + Hashtable.D1 table = new Hashtable.D1<>(8); + StringIntEntry created = table.getOrCreate(null, k -> new StringIntEntry(k, 7)); + assertNotNull(created); + assertNull(created.key); + assertEquals(7, created.value); + assertSame(created, table.getOrCreate(null, k -> new StringIntEntry(k, 999))); + assertEquals(1, table.size()); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java index fb621f89482..d33c3f7f23d 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableD2Test.java @@ -13,181 +13,173 @@ class HashtableD2Test { - @Test - void pairKeysParticipateInIdentity() { - Hashtable.D2 table = new Hashtable.D2<>(8); - PairEntry ab = new PairEntry("a", 1, 100); - PairEntry ac = new PairEntry("a", 2, 200); - PairEntry bb = new PairEntry("b", 1, 300); - table.insert(ab); - table.insert(ac); - table.insert(bb); - assertEquals(3, table.size()); - assertSame(ab, table.get("a", 1)); - assertSame(ac, table.get("a", 2)); - assertSame(bb, table.get("b", 1)); - assertNull(table.get("a", 3)); - } - - @Test - void removePairUnlinks() { - Hashtable.D2 table = new Hashtable.D2<>(8); - PairEntry ab = new PairEntry("a", 1, 100); - PairEntry ac = new PairEntry("a", 2, 200); - table.insert(ab); - table.insert(ac); - assertSame(ab, table.remove("a", 1)); - assertEquals(1, table.size()); - assertNull(table.get("a", 1)); - assertSame(ac, table.get("a", 2)); - } - - @Test - void insertOrReplaceMatchesOnBothKeys() { - Hashtable.D2 table = new Hashtable.D2<>(8); - PairEntry first = new PairEntry("k", 7, 1); - assertNull(table.insertOrReplace(first)); - PairEntry second = new PairEntry("k", 7, 2); - assertSame(first, table.insertOrReplace(second)); - // Different second-key: should insert new, not replace - PairEntry third = new PairEntry("k", 8, 3); - assertNull(table.insertOrReplace(third)); - assertEquals(2, table.size()); - } - - @Test - void forEachVisitsBothPairs() { - Hashtable.D2 table = new Hashtable.D2<>(8); - table.insert(new PairEntry("a", 1, 100)); - table.insert(new PairEntry("b", 2, 200)); - Set seen = new HashSet<>(); - table.forEach(e -> seen.add(e.key1 + ":" + e.key2)); - assertEquals(2, seen.size()); - assertTrue(seen.contains("a:1")); - assertTrue(seen.contains("b:2")); - } - - @Test - void forEachWithContextPassesContextToConsumer() { - Hashtable.D2 table = new Hashtable.D2<>(8); - table.insert(new PairEntry("a", 1, 100)); - table.insert(new PairEntry("b", 2, 200)); - Set seen = new HashSet<>(); - table.forEach(seen, (ctx, e) -> ctx.add(e.key1 + ":" + e.key2)); - assertEquals(2, seen.size()); - assertTrue(seen.contains("a:1")); - assertTrue(seen.contains("b:2")); - } - - @Test - void getOrCreateOnMissBuildsEntryViaCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); - int[] createCount = {0}; - PairEntry created = - table.getOrCreate( - "a", - 1, - (k1, k2) -> { - createCount[0]++; - return new PairEntry(k1, k2, 100); - }); - assertNotNull(created); - assertEquals("a", created.key1); - assertEquals(Integer.valueOf(1), created.key2); - assertEquals(100, created.value); - assertEquals(1, table.size()); - assertEquals(1, createCount[0]); - assertSame(created, table.get("a", 1)); - } - - @Test - void getOrCreateOnHitSkipsCreator() { - Hashtable.D2 table = new Hashtable.D2<>(8); - PairEntry seeded = new PairEntry("a", 1, 100); - table.insert(seeded); - int[] createCount = {0}; - PairEntry got = - table.getOrCreate( - "a", - 1, - (k1, k2) -> { - createCount[0]++; - return new PairEntry(k1, k2, 999); - }); - assertSame(seeded, got); - assertEquals(1, table.size()); - assertEquals(0, createCount[0]); - } - - @Test - void entryMatchesTrueWhenBothKeysEqual() { - PairEntry entry = new PairEntry("a", 1, 100); - assertTrue(entry.matches("a", 1)); - } - - @Test - void entryMatchesFalseWhenKey1Differs() { - PairEntry entry = new PairEntry("a", 1, 100); - assertFalse(entry.matches("b", 1)); - } - - @Test - void entryMatchesFalseWhenKey2Differs() { - PairEntry entry = new PairEntry("a", 1, 100); - assertFalse(entry.matches("a", 2)); - } - - @Test - void keyAccessorsExposeConstructionKeys() { - PairEntry entry = new PairEntry("a", 1, 100); - assertEquals("a", entry.key1()); - assertEquals(1, entry.key2()); - } - - @Test - void entryHashIsConsistentForSameKeys() { - long h1 = Hashtable.D2.Entry.hash("x", 42); - long h2 = Hashtable.D2.Entry.hash("x", 42); - assertEquals(h1, h2); - } - - @Test - void entryHashDiffersForDifferentKeys() { - long h1 = Hashtable.D2.Entry.hash("x", 1); - long h2 = Hashtable.D2.Entry.hash("x", 2); - assertFalse(h1 == h2); - } - - @Test - void removeReturnsNullForMissingKey() { - Hashtable.D2 table = new Hashtable.D2<>(8); - table.insert(new PairEntry("a", 1, 100)); - - assertNull(table.remove("a", 2)); - assertNull(table.remove("z", 1)); - assertEquals(1, table.size()); - } - - @Test - void clearEmptiesTable() { - Hashtable.D2 table = new Hashtable.D2<>(8); - table.insert(new PairEntry("a", 1, 100)); - table.insert(new PairEntry("b", 2, 200)); - assertEquals(2, table.size()); - - table.clear(); - - assertEquals(0, table.size()); - assertNull(table.get("a", 1)); - assertNull(table.get("b", 2)); - } - - private static final class PairEntry extends Hashtable.D2.Entry { - int value; - - PairEntry(String key1, Integer key2, int value) { - super(key1, key2); - this.value = value; - } - } + @Test + void pairKeysParticipateInIdentity() { + Hashtable.D2 table = new Hashtable.D2<>(8); + PairEntry ab = new PairEntry("a", 1, 100); + PairEntry ac = new PairEntry("a", 2, 200); + PairEntry bb = new PairEntry("b", 1, 300); + table.insert(ab); + table.insert(ac); + table.insert(bb); + assertEquals(3, table.size()); + assertSame(ab, table.get("a", 1)); + assertSame(ac, table.get("a", 2)); + assertSame(bb, table.get("b", 1)); + assertNull(table.get("a", 3)); + } + + @Test + void removePairUnlinks() { + Hashtable.D2 table = new Hashtable.D2<>(8); + PairEntry ab = new PairEntry("a", 1, 100); + PairEntry ac = new PairEntry("a", 2, 200); + table.insert(ab); + table.insert(ac); + assertSame(ab, table.remove("a", 1)); + assertEquals(1, table.size()); + assertNull(table.get("a", 1)); + assertSame(ac, table.get("a", 2)); + } + + @Test + void insertOrReplaceMatchesOnBothKeys() { + Hashtable.D2 table = new Hashtable.D2<>(8); + PairEntry first = new PairEntry("k", 7, 1); + assertNull(table.insertOrReplace(first)); + PairEntry second = new PairEntry("k", 7, 2); + assertSame(first, table.insertOrReplace(second)); + // Different second-key: should insert new, not replace + PairEntry third = new PairEntry("k", 8, 3); + assertNull(table.insertOrReplace(third)); + assertEquals(2, table.size()); + } + + @Test + void forEachVisitsBothPairs() { + Hashtable.D2 table = new Hashtable.D2<>(8); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + Set seen = new HashSet<>(); + table.forEach(e -> seen.add(e.key1 + ":" + e.key2)); + assertEquals(2, seen.size()); + assertTrue(seen.contains("a:1")); + assertTrue(seen.contains("b:2")); + } + + @Test + void forEachWithContextPassesContextToConsumer() { + Hashtable.D2 table = new Hashtable.D2<>(8); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + Set seen = new HashSet<>(); + table.forEach(seen, (ctx, e) -> ctx.add(e.key1 + ":" + e.key2)); + assertEquals(2, seen.size()); + assertTrue(seen.contains("a:1")); + assertTrue(seen.contains("b:2")); + } + + @Test + void getOrCreateOnMissBuildsEntryViaCreator() { + Hashtable.D2 table = new Hashtable.D2<>(8); + int[] createCount = {0}; + PairEntry created = table.getOrCreate("a", 1, (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2, 100); + }); + assertNotNull(created); + assertEquals("a", created.key1); + assertEquals(Integer.valueOf(1), created.key2); + assertEquals(100, created.value); + assertEquals(1, table.size()); + assertEquals(1, createCount[0]); + assertSame(created, table.get("a", 1)); + } + + @Test + void getOrCreateOnHitSkipsCreator() { + Hashtable.D2 table = new Hashtable.D2<>(8); + PairEntry seeded = new PairEntry("a", 1, 100); + table.insert(seeded); + int[] createCount = {0}; + PairEntry got = table.getOrCreate("a", 1, (k1, k2) -> { + createCount[0]++; + return new PairEntry(k1, k2, 999); + }); + assertSame(seeded, got); + assertEquals(1, table.size()); + assertEquals(0, createCount[0]); + } + + @Test + void entryMatchesTrueWhenBothKeysEqual() { + PairEntry entry = new PairEntry("a", 1, 100); + assertTrue(entry.matches("a", 1)); + } + + @Test + void entryMatchesFalseWhenKey1Differs() { + PairEntry entry = new PairEntry("a", 1, 100); + assertFalse(entry.matches("b", 1)); + } + + @Test + void entryMatchesFalseWhenKey2Differs() { + PairEntry entry = new PairEntry("a", 1, 100); + assertFalse(entry.matches("a", 2)); + } + + @Test + void keyAccessorsExposeConstructionKeys() { + PairEntry entry = new PairEntry("a", 1, 100); + assertEquals("a", entry.key1()); + assertEquals(1, entry.key2()); + } + + @Test + void entryHashIsConsistentForSameKeys() { + long h1 = Hashtable.D2.Entry.hash("x", 42); + long h2 = Hashtable.D2.Entry.hash("x", 42); + assertEquals(h1, h2); + } + + @Test + void entryHashDiffersForDifferentKeys() { + long h1 = Hashtable.D2.Entry.hash("x", 1); + long h2 = Hashtable.D2.Entry.hash("x", 2); + assertFalse(h1 == h2); + } + + @Test + void removeReturnsNullForMissingKey() { + Hashtable.D2 table = new Hashtable.D2<>(8); + table.insert(new PairEntry("a", 1, 100)); + + assertNull(table.remove("a", 2)); + assertNull(table.remove("z", 1)); + assertEquals(1, table.size()); + } + + @Test + void clearEmptiesTable() { + Hashtable.D2 table = new Hashtable.D2<>(8); + table.insert(new PairEntry("a", 1, 100)); + table.insert(new PairEntry("b", 2, 200)); + assertEquals(2, table.size()); + + table.clear(); + + assertEquals(0, table.size()); + assertNull(table.get("a", 1)); + assertNull(table.get("b", 2)); + } + + private static final class PairEntry extends Hashtable.D2.Entry { + int value; + + PairEntry(String key1, Integer key2, int value) { + super(key1, key2); + this.value = value; + } + } } diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java index 953453ca3aa..c745da61886 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java @@ -23,390 +23,383 @@ class HashtableTest { - // ============ Support ============ - - @Nested - class SupportTests { - - @Test - void createRoundsCapacityUpToPowerOfTwo() { - // The Hashtable.D1 / D2 size() reflects entries, but the bucket array length is - // a power of two >= requestedCapacity. We can verify indirectly via bucketIndex masking. - Hashtable.Entry[] buckets = Support.create(5); - // Length must be a power of two >= 5 - int len = buckets.length; - assertTrue(len >= 5); - assertEquals(0, len & (len - 1), "length must be a power of two"); - } + // ============ Support ============ + + @Nested + class SupportTests { + + @Test + void createRoundsCapacityUpToPowerOfTwo() { + // The Hashtable.D1 / D2 size() reflects entries, but the bucket array length is + // a power of two >= requestedCapacity. We can verify indirectly via bucketIndex masking. + Hashtable.Entry[] buckets = Support.create(5); + // Length must be a power of two >= 5 + int len = buckets.length; + assertTrue(len >= 5); + assertEquals(0, len & (len - 1), "length must be a power of two"); + } - @Test - void sizeForReturnsAtLeastOne() { - assertEquals(1, Support.sizeFor(0)); - assertEquals(1, Support.sizeFor(1)); - } + @Test + void sizeForReturnsAtLeastOne() { + assertEquals(1, Support.sizeFor(0)); + assertEquals(1, Support.sizeFor(1)); + } - @Test - void sizeForRoundsUpToPowerOfTwo() { - assertEquals(2, Support.sizeFor(2)); - assertEquals(4, Support.sizeFor(3)); - assertEquals(4, Support.sizeFor(4)); - assertEquals(8, Support.sizeFor(5)); - assertEquals(1 << 30, Support.sizeFor(1 << 30)); - } + @Test + void sizeForRoundsUpToPowerOfTwo() { + assertEquals(2, Support.sizeFor(2)); + assertEquals(4, Support.sizeFor(3)); + assertEquals(4, Support.sizeFor(4)); + assertEquals(8, Support.sizeFor(5)); + assertEquals(1 << 30, Support.sizeFor(1 << 30)); + } - @Test - void sizeForRejectsCapacityAboveMax() { - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor((1 << 30) + 1)); - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(Integer.MAX_VALUE)); - } + @Test + void sizeForRejectsCapacityAboveMax() { + assertThrows(IllegalArgumentException.class, () -> Support.sizeFor((1 << 30) + 1)); + assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(Integer.MAX_VALUE)); + } - @Test - void sizeForRejectsNegativeCapacity() { - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(-1)); - assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(Integer.MIN_VALUE)); - } + @Test + void sizeForRejectsNegativeCapacity() { + assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(-1)); + assertThrows(IllegalArgumentException.class, () -> Support.sizeFor(Integer.MIN_VALUE)); + } - @Test - void bucketIndexIsBoundedByArrayLength() { - Hashtable.Entry[] buckets = Support.create(16); - for (long h : new long[] {0L, 1L, -1L, Long.MIN_VALUE, Long.MAX_VALUE, 12345L}) { - int idx = Support.bucketIndex(buckets, h); - assertTrue(idx >= 0 && idx < buckets.length, "bucketIndex out of range for hash " + h); - } - } + @Test + void bucketIndexIsBoundedByArrayLength() { + Hashtable.Entry[] buckets = Support.create(16); + for (long h : new long[] {0L, 1L, -1L, Long.MIN_VALUE, Long.MAX_VALUE, 12345L}) { + int idx = Support.bucketIndex(buckets, h); + assertTrue(idx >= 0 && idx < buckets.length, "bucketIndex out of range for hash " + h); + } + } - @Test - void clearNullsAllBuckets() { - Hashtable.Entry[] buckets = Support.create(4); - buckets[0] = new StringIntEntry("x", 1); - buckets[1] = new StringIntEntry("y", 2); - Support.clear(buckets); - for (Hashtable.Entry b : buckets) { - assertNull(b); - } - } + @Test + void clearNullsAllBuckets() { + Hashtable.Entry[] buckets = Support.create(4); + buckets[0] = new StringIntEntry("x", 1); + buckets[1] = new StringIntEntry("y", 2); + Support.clear(buckets); + for (Hashtable.Entry b : buckets) { + assertNull(b); + } + } - @Test - void maxRatioScalesTargetForLoadFactor() { - // 75% load factor => bucket array sized at requestedSize * 4/3, rounded up to power of 2. - // 12 * (4/3) = 16 entries, rounded up to power-of-2 length = 16. - assertEquals(4.0f / 3.0f, Support.MAX_RATIO); - Hashtable.Entry[] buckets = Support.create(12, Support.MAX_RATIO); - assertEquals(16, buckets.length); - } + @Test + void maxRatioScalesTargetForLoadFactor() { + // 75% load factor => bucket array sized at requestedSize * 4/3, rounded up to power of 2. + // 12 * (4/3) = 16 entries, rounded up to power-of-2 length = 16. + assertEquals(4.0f / 3.0f, Support.MAX_RATIO); + Hashtable.Entry[] buckets = Support.create(12, Support.MAX_RATIO); + assertEquals(16, buckets.length); + } - @Test - void createWithScaleRoundsUpToPowerOfTwo() { - // 7 * 1.5 = 10.5 -> (int) 10 -> sizeFor rounds up to next power-of-two = 16 - Hashtable.Entry[] buckets = Support.create(7, 1.5f); - assertEquals(16, buckets.length); - } + @Test + void createWithScaleRoundsUpToPowerOfTwo() { + // 7 * 1.5 = 10.5 -> (int) 10 -> sizeFor rounds up to next power-of-two = 16 + Hashtable.Entry[] buckets = Support.create(7, 1.5f); + assertEquals(16, buckets.length); + } - @Test - void insertHeadEntrySplicesAsNewHead() { - Hashtable.Entry[] buckets = Support.create(4); - StringIntEntry a = new StringIntEntry("a", 1); - StringIntEntry b = new StringIntEntry("b", 2); - Support.insertHeadEntry(buckets, 0, a); - assertSame(a, buckets[0]); - assertNull(a.next()); - - Support.insertHeadEntry(buckets, 0, b); - assertSame(b, buckets[0]); - assertSame(a, b.next()); - assertNull(a.next()); - } - } - - // ============ BucketIterator ============ - - @Nested - class BucketIteratorTests { - - @Test - void walksOnlyMatchingHash() { - // Build a bucket array with two entries that share a bucket but have different hashes. - // Use Hashtable.D1 to seed; then call Support.bucketIterator directly with the matching - // hash and verify it only returns the matching entry. - Hashtable.D1 table = new Hashtable.D1<>(4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - CollidingKey k3 = new CollidingKey("third", 17); - table.insert(new CollidingKeyEntry(k1, 1)); - table.insert(new CollidingKeyEntry(k2, 2)); - table.insert(new CollidingKeyEntry(k3, 3)); - // All three share the same hash (17), so a bucket iterator over hash=17 yields all three. - BucketIterator it = Support.bucketIterator(table.buckets, 17L); - int count = 0; - while (it.hasNext()) { - assertNotNull(it.next()); - count++; - } - assertEquals(3, count); + @Test + void insertHeadEntrySplicesAsNewHead() { + Hashtable.Entry[] buckets = Support.create(4); + StringIntEntry a = new StringIntEntry("a", 1); + StringIntEntry b = new StringIntEntry("b", 2); + Support.insertHeadEntry(buckets, 0, a); + assertSame(a, buckets[0]); + assertNull(a.next()); + + Support.insertHeadEntry(buckets, 0, b); + assertSame(b, buckets[0]); + assertSame(a, b.next()); + assertNull(a.next()); + } } - @Test - void exhaustedIteratorThrowsNoSuchElement() { - Hashtable.D1 table = new Hashtable.D1<>(4); - table.insert(new StringIntEntry("only", 1)); - long h = Hashtable.D1.Entry.hash("only"); - BucketIterator it = Support.bucketIterator(table.buckets, h); - it.next(); - assertFalse(it.hasNext()); - assertThrows(NoSuchElementException.class, it::next); - } - } - - // ============ MutatingBucketIterator ============ - - @Nested - class MutatingBucketIteratorTests { - - @Test - void removeFromHeadOfChainUnlinks() { - // Make three entries with the same hash so they chain in one bucket - Hashtable.D1 table = new Hashtable.D1<>(4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - CollidingKey k3 = new CollidingKey("third", 17); - table.insert(new CollidingKeyEntry(k1, 1)); - table.insert(new CollidingKeyEntry(k2, 2)); - table.insert(new CollidingKeyEntry(k3, 3)); - - MutatingBucketIterator it = - Support.mutatingBucketIterator(table.buckets, 17L); - it.next(); // first match (head of chain in insertion-reverse order) - it.remove(); - // Two should remain - int remaining = 0; - while (it.hasNext()) { - it.next(); - remaining++; - } - assertEquals(2, remaining); - // And the table still finds the survivors via get(...) - // (which entry was the head depends on insertion order; we just verify count + that two - // of the three keys are still retrievable.) - int found = 0; - for (CollidingKey k : new CollidingKey[] {k1, k2, k3}) { - if (table.get(k) != null) { - found++; + // ============ BucketIterator ============ + + @Nested + class BucketIteratorTests { + + @Test + void walksOnlyMatchingHash() { + // Build a bucket array with two entries that share a bucket but have different hashes. + // Use Hashtable.D1 to seed; then call Support.bucketIterator directly with the matching + // hash and verify it only returns the matching entry. + Hashtable.D1 table = new Hashtable.D1<>(4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + CollidingKey k3 = new CollidingKey("third", 17); + table.insert(new CollidingKeyEntry(k1, 1)); + table.insert(new CollidingKeyEntry(k2, 2)); + table.insert(new CollidingKeyEntry(k3, 3)); + // All three share the same hash (17), so a bucket iterator over hash=17 yields all three. + BucketIterator it = Support.bucketIterator(table.buckets, 17L); + int count = 0; + while (it.hasNext()) { + assertNotNull(it.next()); + count++; + } + assertEquals(3, count); } - } - assertEquals(2, found); - } - @Test - void replaceSwapsEntryAndPreservesChain() { - Hashtable.D1 table = new Hashtable.D1<>(4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 1); - CollidingKeyEntry e2 = new CollidingKeyEntry(k2, 2); - table.insert(e1); - table.insert(e2); - - MutatingBucketIterator it = - Support.mutatingBucketIterator(table.buckets, 17L); - CollidingKeyEntry first = it.next(); - CollidingKeyEntry replacement = new CollidingKeyEntry(first.key, 999); - it.replace(replacement); - // Both entries still in the chain - assertNotNull(table.get(k1)); - assertNotNull(table.get(k2)); - // The replaced one now has value 999 - assertEquals(999, table.get(first.key).value); + @Test + void exhaustedIteratorThrowsNoSuchElement() { + Hashtable.D1 table = new Hashtable.D1<>(4); + table.insert(new StringIntEntry("only", 1)); + long h = Hashtable.D1.Entry.hash("only"); + BucketIterator it = Support.bucketIterator(table.buckets, h); + it.next(); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); + } } - @Test - void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); - table.insert(new StringIntEntry("a", 1)); - MutatingBucketIterator it = - Support.mutatingBucketIterator(table.buckets, Hashtable.D1.Entry.hash("a")); - assertThrows(IllegalStateException.class, it::remove); - } - } - - // ============ MutatingTableIterator ============ - - @Nested - class MutatingTableIteratorTests { - - @Test - void walksEveryEntryAcrossBuckets() { - Hashtable.D1 table = new Hashtable.D1<>(16); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - table.insert(new StringIntEntry("c", 3)); - - Set seen = new HashSet<>(); - for (MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - it.hasNext(); ) { - seen.add(it.next().key); - } - assertEquals(3, seen.size()); - assertTrue(seen.contains("a")); - assertTrue(seen.contains("b")); - assertTrue(seen.contains("c")); - } + // ============ MutatingBucketIterator ============ + + @Nested + class MutatingBucketIteratorTests { + + @Test + void removeFromHeadOfChainUnlinks() { + // Make three entries with the same hash so they chain in one bucket + Hashtable.D1 table = new Hashtable.D1<>(4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + CollidingKey k3 = new CollidingKey("third", 17); + table.insert(new CollidingKeyEntry(k1, 1)); + table.insert(new CollidingKeyEntry(k2, 2)); + table.insert(new CollidingKeyEntry(k3, 3)); + + MutatingBucketIterator it = Support.mutatingBucketIterator(table.buckets, 17L); + it.next(); // first match (head of chain in insertion-reverse order) + it.remove(); + // Two should remain + int remaining = 0; + while (it.hasNext()) { + it.next(); + remaining++; + } + assertEquals(2, remaining); + // And the table still finds the survivors via get(...) + // (which entry was the head depends on insertion order; we just verify count + that two + // of the three keys are still retrievable.) + int found = 0; + for (CollidingKey k : new CollidingKey[] {k1, k2, k3}) { + if (table.get(k) != null) { + found++; + } + } + assertEquals(2, found); + } - @Test - void emptyTableIteratorIsExhausted() { - Hashtable.D1 table = new Hashtable.D1<>(8); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - assertFalse(it.hasNext()); - assertThrows(NoSuchElementException.class, it::next); - } + @Test + void replaceSwapsEntryAndPreservesChain() { + Hashtable.D1 table = new Hashtable.D1<>(4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + CollidingKeyEntry e1 = new CollidingKeyEntry(k1, 1); + CollidingKeyEntry e2 = new CollidingKeyEntry(k2, 2); + table.insert(e1); + table.insert(e2); + + MutatingBucketIterator it = Support.mutatingBucketIterator(table.buckets, 17L); + CollidingKeyEntry first = it.next(); + CollidingKeyEntry replacement = new CollidingKeyEntry(first.key, 999); + it.replace(replacement); + // Both entries still in the chain + assertNotNull(table.get(k1)); + assertNotNull(table.get(k2)); + // The replaced one now has value 999 + assertEquals(999, table.get(first.key).value); + } - @Test - void removeUnlinksBucketHead() { - Hashtable.D1 table = new Hashtable.D1<>(4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - table.insert(new CollidingKeyEntry(k1, 1)); - table.insert(new CollidingKeyEntry(k2, 2)); - - // The head of the chain is whichever was inserted last (insert prepends). - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - CollidingKeyEntry head = it.next(); - it.remove(); - - // Survivor still reachable via the table; removed one is not. - CollidingKey survivorKey = head.key.equals(k1) ? k2 : k1; - assertNotNull(table.get(survivorKey)); - assertNull(table.get(head.key)); + @Test + void removeWithoutNextThrows() { + Hashtable.D1 table = new Hashtable.D1<>(4); + table.insert(new StringIntEntry("a", 1)); + MutatingBucketIterator it = + Support.mutatingBucketIterator(table.buckets, Hashtable.D1.Entry.hash("a")); + assertThrows(IllegalStateException.class, it::remove); + } } - @Test - void removeUnlinksMidChainEntry() { - Hashtable.D1 table = new Hashtable.D1<>(4); - CollidingKey k1 = new CollidingKey("first", 17); - CollidingKey k2 = new CollidingKey("second", 17); - CollidingKey k3 = new CollidingKey("third", 17); - table.insert(new CollidingKeyEntry(k1, 1)); - table.insert(new CollidingKeyEntry(k2, 2)); - table.insert(new CollidingKeyEntry(k3, 3)); - - // Walk to the second entry, remove it. - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - it.next(); - CollidingKeyEntry victim = it.next(); - it.remove(); - - assertNull(table.get(victim.key)); - // The remaining two keys still resolve. - int remaining = 0; - for (CollidingKey k : new CollidingKey[] {k1, k2, k3}) { - if (table.get(k) != null) { - remaining++; + // ============ MutatingTableIterator ============ + + @Nested + class MutatingTableIteratorTests { + + @Test + void walksEveryEntryAcrossBuckets() { + Hashtable.D1 table = new Hashtable.D1<>(16); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + table.insert(new StringIntEntry("c", 3)); + + Set seen = new HashSet<>(); + for (MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + it.hasNext(); ) { + seen.add(it.next().key); + } + assertEquals(3, seen.size()); + assertTrue(seen.contains("a")); + assertTrue(seen.contains("b")); + assertTrue(seen.contains("c")); } - } - assertEquals(2, remaining); - // Iteration can continue past a remove and yield the third entry. - assertTrue(it.hasNext()); - assertNotNull(it.next()); - assertFalse(it.hasNext()); - } + @Test + void emptyTableIteratorIsExhausted() { + Hashtable.D1 table = new Hashtable.D1<>(8); + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); + } - @Test - void removeSkipsOverEmptyBuckets() { - // Three distinct keys that land in different buckets (low entry count vs large bucket array - // makes empty buckets between them very likely). Verify the iterator skips empties cleanly - // after a remove. - Hashtable.D1 table = new Hashtable.D1<>(64); - table.insert(new StringIntEntry("alpha", 1)); - table.insert(new StringIntEntry("beta", 2)); - table.insert(new StringIntEntry("gamma", 3)); - - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - it.next(); - it.remove(); - int remaining = 0; - while (it.hasNext()) { - it.next(); - remaining++; - } - assertEquals(2, remaining); - } + @Test + void removeUnlinksBucketHead() { + Hashtable.D1 table = new Hashtable.D1<>(4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + table.insert(new CollidingKeyEntry(k1, 1)); + table.insert(new CollidingKeyEntry(k2, 2)); + + // The head of the chain is whichever was inserted last (insert prepends). + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + CollidingKeyEntry head = it.next(); + it.remove(); + + // Survivor still reachable via the table; removed one is not. + CollidingKey survivorKey = head.key.equals(k1) ? k2 : k1; + assertNotNull(table.get(survivorKey)); + assertNull(table.get(head.key)); + } - @Test - void removeWithoutNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); - table.insert(new StringIntEntry("a", 1)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - assertThrows(IllegalStateException.class, it::remove); - } + @Test + void removeUnlinksMidChainEntry() { + Hashtable.D1 table = new Hashtable.D1<>(4); + CollidingKey k1 = new CollidingKey("first", 17); + CollidingKey k2 = new CollidingKey("second", 17); + CollidingKey k3 = new CollidingKey("third", 17); + table.insert(new CollidingKeyEntry(k1, 1)); + table.insert(new CollidingKeyEntry(k2, 2)); + table.insert(new CollidingKeyEntry(k3, 3)); + + // Walk to the second entry, remove it. + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + it.next(); + CollidingKeyEntry victim = it.next(); + it.remove(); + + assertNull(table.get(victim.key)); + // The remaining two keys still resolve. + int remaining = 0; + for (CollidingKey k : new CollidingKey[] {k1, k2, k3}) { + if (table.get(k) != null) { + remaining++; + } + } + assertEquals(2, remaining); + + // Iteration can continue past a remove and yield the third entry. + assertTrue(it.hasNext()); + assertNotNull(it.next()); + assertFalse(it.hasNext()); + } - @Test - void removeTwiceWithoutInterveningNextThrows() { - Hashtable.D1 table = new Hashtable.D1<>(4); - table.insert(new StringIntEntry("a", 1)); - table.insert(new StringIntEntry("b", 2)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - it.next(); - it.remove(); - assertThrows(IllegalStateException.class, it::remove); - } + @Test + void removeSkipsOverEmptyBuckets() { + // Three distinct keys that land in different buckets (low entry count vs large bucket array + // makes empty buckets between them very likely). Verify the iterator skips empties cleanly + // after a remove. + Hashtable.D1 table = new Hashtable.D1<>(64); + table.insert(new StringIntEntry("alpha", 1)); + table.insert(new StringIntEntry("beta", 2)); + table.insert(new StringIntEntry("gamma", 3)); + + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + it.next(); + it.remove(); + int remaining = 0; + while (it.hasNext()) { + it.next(); + remaining++; + } + assertEquals(2, remaining); + } - @Test - void halfOpenRangeOmitsBucketsOutsideTheRange() { - // CollidingKey lets us pin entries to specific buckets via controlled hashCode. 16-slot - // table -> bucketIndex = hash & 15. Place entries in buckets 0, 5, and 10; iterate - // [5, 10) -- should see only bucket 5. - Hashtable.D1 table = new Hashtable.D1<>(16); - table.insert(new CollidingKeyEntry(new CollidingKey("b0", 0), 1)); - table.insert(new CollidingKeyEntry(new CollidingKey("b5", 5), 2)); - table.insert(new CollidingKeyEntry(new CollidingKey("b10", 10), 3)); - - Set seen = new HashSet<>(); - for (MutatingTableIterator it = - Support.mutatingTableIterator(table.buckets, 5, 10); - it.hasNext(); ) { - seen.add(it.next().key.label); - } - assertEquals(1, seen.size()); - assertTrue(seen.contains("b5")); - } + @Test + void removeWithoutNextThrows() { + Hashtable.D1 table = new Hashtable.D1<>(4); + table.insert(new StringIntEntry("a", 1)); + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + assertThrows(IllegalStateException.class, it::remove); + } - @Test - void emptyHalfOpenRangeIsExhausted() { - // start == end -> immediately-exhausted iterator. Important: this is the wrap-around - // pass [0, cursor) when cursor == 0 in resumable sweeps. - Hashtable.D1 table = new Hashtable.D1<>(8); - table.insert(new StringIntEntry("a", 1)); - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets, 0, 0); - assertFalse(it.hasNext()); - } + @Test + void removeTwiceWithoutInterveningNextThrows() { + Hashtable.D1 table = new Hashtable.D1<>(4); + table.insert(new StringIntEntry("a", 1)); + table.insert(new StringIntEntry("b", 2)); + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + it.next(); + it.remove(); + assertThrows(IllegalStateException.class, it::remove); + } - @Test - void rangeBoundsOutOfOrderThrows() { - Hashtable.D1 table = new Hashtable.D1<>(8); - assertThrows( - IndexOutOfBoundsException.class, - () -> Support.mutatingTableIterator(table.buckets, -1, 4)); - assertThrows( - IndexOutOfBoundsException.class, - () -> Support.mutatingTableIterator(table.buckets, 4, 2)); // end < start - assertThrows( - IndexOutOfBoundsException.class, - () -> - Support.mutatingTableIterator( - table.buckets, 0, table.buckets.length + 1)); // end > len - } + @Test + void halfOpenRangeOmitsBucketsOutsideTheRange() { + // CollidingKey lets us pin entries to specific buckets via controlled hashCode. 16-slot + // table -> bucketIndex = hash & 15. Place entries in buckets 0, 5, and 10; iterate + // [5, 10) -- should see only bucket 5. + Hashtable.D1 table = new Hashtable.D1<>(16); + table.insert(new CollidingKeyEntry(new CollidingKey("b0", 0), 1)); + table.insert(new CollidingKeyEntry(new CollidingKey("b5", 5), 2)); + table.insert(new CollidingKeyEntry(new CollidingKey("b10", 10), 3)); + + Set seen = new HashSet<>(); + for (MutatingTableIterator it = Support.mutatingTableIterator(table.buckets, 5, 10); + it.hasNext(); ) { + seen.add(it.next().key.label); + } + assertEquals(1, seen.size()); + assertTrue(seen.contains("b5")); + } + + @Test + void emptyHalfOpenRangeIsExhausted() { + // start == end -> immediately-exhausted iterator. Important: this is the wrap-around + // pass [0, cursor) when cursor == 0 in resumable sweeps. + Hashtable.D1 table = new Hashtable.D1<>(8); + table.insert(new StringIntEntry("a", 1)); + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets, 0, 0); + assertFalse(it.hasNext()); + } - @Test - void currentBucketReportsLandingIndex() { - // Pin one entry to a known bucket and check currentBucket() after next() reports that - // bucket. Before any next() (or after remove()), currentBucket() returns -1. - Hashtable.D1 table = new Hashtable.D1<>(16); - table.insert(new CollidingKeyEntry(new CollidingKey("b3", 3), 1)); - - MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); - assertEquals(-1, it.currentBucket(), "before any next() currentBucket should be -1"); - it.next(); - assertEquals(3, it.currentBucket(), "currentBucket should report the entry's bucket"); + @Test + void rangeBoundsOutOfOrderThrows() { + Hashtable.D1 table = new Hashtable.D1<>(8); + assertThrows(IndexOutOfBoundsException.class, () -> Support.mutatingTableIterator(table.buckets, -1, 4)); + assertThrows( + IndexOutOfBoundsException.class, + () -> Support.mutatingTableIterator(table.buckets, 4, 2)); // end < start + assertThrows( + IndexOutOfBoundsException.class, + () -> Support.mutatingTableIterator(table.buckets, 0, table.buckets.length + 1)); // end > len + } + + @Test + void currentBucketReportsLandingIndex() { + // Pin one entry to a known bucket and check currentBucket() after next() reports that + // bucket. Before any next() (or after remove()), currentBucket() returns -1. + Hashtable.D1 table = new Hashtable.D1<>(16); + table.insert(new CollidingKeyEntry(new CollidingKey("b3", 3), 1)); + + MutatingTableIterator it = Support.mutatingTableIterator(table.buckets); + assertEquals(-1, it.currentBucket(), "before any next() currentBucket should be -1"); + it.next(); + assertEquals(3, it.currentBucket(), "currentBucket should report the entry's bucket"); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableTestEntries.java b/internal-api/src/test/java/datadog/trace/util/HashtableTestEntries.java index e657028ee8b..4769f0c11d4 100644 --- a/internal-api/src/test/java/datadog/trace/util/HashtableTestEntries.java +++ b/internal-api/src/test/java/datadog/trace/util/HashtableTestEntries.java @@ -2,53 +2,53 @@ /** Shared test entry types for {@link HashtableTest}, {@link HashtableD1Test}, and friends. */ final class HashtableTestEntries { - private HashtableTestEntries() {} + private HashtableTestEntries() {} - static final class StringIntEntry extends Hashtable.D1.Entry { - int value; + static final class StringIntEntry extends Hashtable.D1.Entry { + int value; - StringIntEntry(String key, int value) { - super(key); - this.value = value; + StringIntEntry(String key, int value) { + super(key); + this.value = value; + } } - } - /** Key whose hashCode is fully controllable, to force chain collisions deterministically. */ - static final class CollidingKey { - final String label; - final int hash; - - CollidingKey(String label, int hash) { - this.label = label; - this.hash = hash; - } - - @Override - public int hashCode() { - return hash; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof CollidingKey)) { - return false; - } - CollidingKey that = (CollidingKey) o; - return hash == that.hash && label.equals(that.label); - } - - @Override - public String toString() { - return "CollidingKey(" + label + ", " + hash + ")"; + /** Key whose hashCode is fully controllable, to force chain collisions deterministically. */ + static final class CollidingKey { + final String label; + final int hash; + + CollidingKey(String label, int hash) { + this.label = label; + this.hash = hash; + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof CollidingKey)) { + return false; + } + CollidingKey that = (CollidingKey) o; + return hash == that.hash && label.equals(that.label); + } + + @Override + public String toString() { + return "CollidingKey(" + label + ", " + hash + ")"; + } } - } - static final class CollidingKeyEntry extends Hashtable.D1.Entry { - int value; + static final class CollidingKeyEntry extends Hashtable.D1.Entry { + int value; - CollidingKeyEntry(CollidingKey key, int value) { - super(key); - this.value = value; + CollidingKeyEntry(CollidingKey key, int value) { + super(key); + this.value = value; + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/LongHashingUtilsTest.java b/internal-api/src/test/java/datadog/trace/util/LongHashingUtilsTest.java index 2837fa9ff73..2df2980fc77 100644 --- a/internal-api/src/test/java/datadog/trace/util/LongHashingUtilsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/LongHashingUtilsTest.java @@ -11,182 +11,179 @@ class LongHashingUtilsTest { - // ----- single-value overloads ----- - - @Test - void hashOfObjectReturnsHashCodeOrSentinelForNull() { - Object o = new Object(); - assertEquals(o.hashCode(), hash(o)); - assertEquals(Long.MIN_VALUE, hash((Object) null)); - } - - @Test - void primitiveOverloadsMatchBoxedHashCodes() { - assertEquals(Boolean.hashCode(true), hash(true)); - assertEquals(Boolean.hashCode(false), hash(false)); - assertEquals(Character.hashCode('x'), hash('x')); - assertEquals(Byte.hashCode((byte) 42), hash((byte) 42)); - assertEquals(Short.hashCode((short) -7), hash((short) -7)); - assertEquals(Integer.hashCode(123456), hash(123456)); - assertEquals(123456L, hash(123456L)); - assertEquals(Float.hashCode(3.14f), hash(3.14f)); - assertEquals(Double.doubleToRawLongBits(2.71828), hash(2.71828)); - } - - // ----- multi-arg Object overloads vs chained addToHash ----- - - @Test - void twoArgHashMatchesChainedAddToHash() { - Object a = "alpha"; - Object b = 42; - assertEquals(addToHash(addToHash(0L, a), b), hash(a, b)); - } - - @Test - void threeArgHashMatchesChainedAddToHash() { - Object a = "alpha"; - Object b = 42; - Object c = true; - assertEquals(addToHash(addToHash(addToHash(0L, a), b), c), hash(a, b, c)); - } - - @Test - void fourArgHashMatchesChainedAddToHash() { - Object a = "alpha"; - Object b = 42; - Object c = true; - Object d = 3.14; - assertEquals(addToHash(addToHash(addToHash(addToHash(0L, a), b), c), d), hash(a, b, c, d)); - } - - @Test - void fiveArgHashMatchesChainedAddToHash() { - Object a = "alpha"; - Object b = 42; - Object c = true; - Object d = 3.14; - Object e = 'q'; - assertEquals( - addToHash(addToHash(addToHash(addToHash(addToHash(0L, a), b), c), d), e), - hash(a, b, c, d, e)); - } - - @Test - void multiArgHashHandlesNullsConsistentlyWithChainedAddToHash() { - assertEquals(addToHash(addToHash(0L, (Object) null), "x"), hash(null, "x")); - assertEquals( - addToHash(addToHash(addToHash(0L, "x"), (Object) null), "y"), hash("x", null, "y")); - } - - @Test - void differentInputsProduceDifferentHashes() { - // Sanity: ordering matters, and distinct values produce distinct results in general. - assertNotEquals(hash("a", "b"), hash("b", "a")); - assertNotEquals(hash("a", "b", "c"), hash("a", "c", "b")); - } - - // ----- addToHash primitive overloads ----- - - @Test - void addToHashPrimitivesMatchObjectVersion() { - long seed = 100L; - assertEquals(addToHash(seed, Boolean.hashCode(true)), addToHash(seed, true)); - assertEquals(addToHash(seed, Character.hashCode('z')), addToHash(seed, 'z')); - assertEquals(addToHash(seed, Byte.hashCode((byte) 9)), addToHash(seed, (byte) 9)); - assertEquals(addToHash(seed, Short.hashCode((short) 5)), addToHash(seed, (short) 5)); - assertEquals(addToHash(seed, Long.hashCode(999_999L)), addToHash(seed, 999_999L)); - assertEquals(addToHash(seed, Float.hashCode(1.5f)), addToHash(seed, 1.5f)); - assertEquals(addToHash(seed, Double.hashCode(2.5d)), addToHash(seed, 2.5d)); - } - - @Test - void addToHashIsLinearAcrossSteps() { - // 31*h + v formula -- verify by accumulating an explicit sequence. - long expected = 0L; - for (int v : new int[] {1, 2, 3, 4, 5}) { - expected = 31L * expected + v; - } - long actual = 0L; - for (int v : new int[] {1, 2, 3, 4, 5}) { - actual = addToHash(actual, v); - } - assertEquals(expected, actual); - } - - // ----- iterable / array versions ----- - - @Test - void hashIterableMatchesChainedAddToHash() { - Iterable values = Arrays.asList("a", 1, true, null); - long expected = 0L; - for (Object o : values) { - expected = addToHash(expected, o); - } - assertEquals(expected, hash(values)); - } - - @Test - @SuppressWarnings("deprecation") - void deprecatedIntArrayHashMatchesChainedAddToHash() { - int[] hashes = new int[] {7, 13, 31, 1024}; - long expected = 0L; - for (int h : hashes) { - expected = addToHash(expected, h); - } - assertEquals(expected, hash(hashes)); - } - - @Test - @SuppressWarnings("deprecation") - void deprecatedObjectArrayHashMatchesChainedAddToHash() { - Object[] objs = new Object[] {"alpha", 7, null, true}; - long expected = 0L; - for (Object o : objs) { - expected = addToHash(expected, o); - } - assertEquals(expected, hash(objs)); - } - - @Test - void addToHashArrayFoldsFromSeedLikeChainedAddToHash() { - Object[] objs = new Object[] {"alpha", 7, null, true}; - - // Full-array overload folds every element onto the seed, matching an explicit chain. - long fromZero = 0L; - for (Object o : objs) { - fromZero = addToHash(fromZero, o); - } - assertEquals(fromZero, addToHash(0L, objs)); - - // A non-zero seed carries through, so the result differs from the zero-seed fold. - long fromSeed = 100L; - for (Object o : objs) { - fromSeed = addToHash(fromSeed, o); - } - assertEquals(fromSeed, addToHash(100L, objs)); - assertNotEquals(addToHash(0L, objs), addToHash(100L, objs)); - } - - @Test - void addToHashArrayRespectsLen() { - Object[] objs = new Object[] {"alpha", 7, null, true}; - - // The len override folds only the first len elements. - long firstTwo = addToHash(addToHash(0L, objs[0]), objs[1]); - assertEquals(firstTwo, addToHash(0L, objs, 2)); - assertNotEquals(addToHash(0L, objs), addToHash(0L, objs, 2)); - - // len==0 never enters the loop and returns the seed unchanged. - assertEquals(42L, addToHash(42L, objs, 0)); - } - - // ----- intHash null behavior is observable via multi-arg overloads ----- - - @Test - void multiArgHashTreatsNullAsZero() { - // hash(Object,Object) feeds intHash(...) which returns 0 for null. - // Verify: hash(null, "x") == 31L*0 + "x".hashCode() - int xHash = Objects.hashCode("x"); - assertEquals(31L * 0 + xHash, hash(null, "x")); - } + // ----- single-value overloads ----- + + @Test + void hashOfObjectReturnsHashCodeOrSentinelForNull() { + Object o = new Object(); + assertEquals(o.hashCode(), hash(o)); + assertEquals(Long.MIN_VALUE, hash((Object) null)); + } + + @Test + void primitiveOverloadsMatchBoxedHashCodes() { + assertEquals(Boolean.hashCode(true), hash(true)); + assertEquals(Boolean.hashCode(false), hash(false)); + assertEquals(Character.hashCode('x'), hash('x')); + assertEquals(Byte.hashCode((byte) 42), hash((byte) 42)); + assertEquals(Short.hashCode((short) -7), hash((short) -7)); + assertEquals(Integer.hashCode(123456), hash(123456)); + assertEquals(123456L, hash(123456L)); + assertEquals(Float.hashCode(3.14f), hash(3.14f)); + assertEquals(Double.doubleToRawLongBits(2.71828), hash(2.71828)); + } + + // ----- multi-arg Object overloads vs chained addToHash ----- + + @Test + void twoArgHashMatchesChainedAddToHash() { + Object a = "alpha"; + Object b = 42; + assertEquals(addToHash(addToHash(0L, a), b), hash(a, b)); + } + + @Test + void threeArgHashMatchesChainedAddToHash() { + Object a = "alpha"; + Object b = 42; + Object c = true; + assertEquals(addToHash(addToHash(addToHash(0L, a), b), c), hash(a, b, c)); + } + + @Test + void fourArgHashMatchesChainedAddToHash() { + Object a = "alpha"; + Object b = 42; + Object c = true; + Object d = 3.14; + assertEquals(addToHash(addToHash(addToHash(addToHash(0L, a), b), c), d), hash(a, b, c, d)); + } + + @Test + void fiveArgHashMatchesChainedAddToHash() { + Object a = "alpha"; + Object b = 42; + Object c = true; + Object d = 3.14; + Object e = 'q'; + assertEquals(addToHash(addToHash(addToHash(addToHash(addToHash(0L, a), b), c), d), e), hash(a, b, c, d, e)); + } + + @Test + void multiArgHashHandlesNullsConsistentlyWithChainedAddToHash() { + assertEquals(addToHash(addToHash(0L, (Object) null), "x"), hash(null, "x")); + assertEquals(addToHash(addToHash(addToHash(0L, "x"), (Object) null), "y"), hash("x", null, "y")); + } + + @Test + void differentInputsProduceDifferentHashes() { + // Sanity: ordering matters, and distinct values produce distinct results in general. + assertNotEquals(hash("a", "b"), hash("b", "a")); + assertNotEquals(hash("a", "b", "c"), hash("a", "c", "b")); + } + + // ----- addToHash primitive overloads ----- + + @Test + void addToHashPrimitivesMatchObjectVersion() { + long seed = 100L; + assertEquals(addToHash(seed, Boolean.hashCode(true)), addToHash(seed, true)); + assertEquals(addToHash(seed, Character.hashCode('z')), addToHash(seed, 'z')); + assertEquals(addToHash(seed, Byte.hashCode((byte) 9)), addToHash(seed, (byte) 9)); + assertEquals(addToHash(seed, Short.hashCode((short) 5)), addToHash(seed, (short) 5)); + assertEquals(addToHash(seed, Long.hashCode(999_999L)), addToHash(seed, 999_999L)); + assertEquals(addToHash(seed, Float.hashCode(1.5f)), addToHash(seed, 1.5f)); + assertEquals(addToHash(seed, Double.hashCode(2.5d)), addToHash(seed, 2.5d)); + } + + @Test + void addToHashIsLinearAcrossSteps() { + // 31*h + v formula -- verify by accumulating an explicit sequence. + long expected = 0L; + for (int v : new int[] {1, 2, 3, 4, 5}) { + expected = 31L * expected + v; + } + long actual = 0L; + for (int v : new int[] {1, 2, 3, 4, 5}) { + actual = addToHash(actual, v); + } + assertEquals(expected, actual); + } + + // ----- iterable / array versions ----- + + @Test + void hashIterableMatchesChainedAddToHash() { + Iterable values = Arrays.asList("a", 1, true, null); + long expected = 0L; + for (Object o : values) { + expected = addToHash(expected, o); + } + assertEquals(expected, hash(values)); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedIntArrayHashMatchesChainedAddToHash() { + int[] hashes = new int[] {7, 13, 31, 1024}; + long expected = 0L; + for (int h : hashes) { + expected = addToHash(expected, h); + } + assertEquals(expected, hash(hashes)); + } + + @Test + @SuppressWarnings("deprecation") + void deprecatedObjectArrayHashMatchesChainedAddToHash() { + Object[] objs = new Object[] {"alpha", 7, null, true}; + long expected = 0L; + for (Object o : objs) { + expected = addToHash(expected, o); + } + assertEquals(expected, hash(objs)); + } + + @Test + void addToHashArrayFoldsFromSeedLikeChainedAddToHash() { + Object[] objs = new Object[] {"alpha", 7, null, true}; + + // Full-array overload folds every element onto the seed, matching an explicit chain. + long fromZero = 0L; + for (Object o : objs) { + fromZero = addToHash(fromZero, o); + } + assertEquals(fromZero, addToHash(0L, objs)); + + // A non-zero seed carries through, so the result differs from the zero-seed fold. + long fromSeed = 100L; + for (Object o : objs) { + fromSeed = addToHash(fromSeed, o); + } + assertEquals(fromSeed, addToHash(100L, objs)); + assertNotEquals(addToHash(0L, objs), addToHash(100L, objs)); + } + + @Test + void addToHashArrayRespectsLen() { + Object[] objs = new Object[] {"alpha", 7, null, true}; + + // The len override folds only the first len elements. + long firstTwo = addToHash(addToHash(0L, objs[0]), objs[1]); + assertEquals(firstTwo, addToHash(0L, objs, 2)); + assertNotEquals(addToHash(0L, objs), addToHash(0L, objs, 2)); + + // len==0 never enters the loop and returns the seed unchanged. + assertEquals(42L, addToHash(42L, objs, 0)); + } + + // ----- intHash null behavior is observable via multi-arg overloads ----- + + @Test + void multiArgHashTreatsNullAsZero() { + // hash(Object,Object) feeds intHash(...) which returns 0 for null. + // Verify: hash(null, "x") == 31L*0 + "x".hashCode() + int xHash = Objects.hashCode("x"); + assertEquals(31L * 0 + xHash, hash(null, "x")); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/MaybeTest.java b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java index 1799c4a2157..e8ed6c91d3f 100644 --- a/internal-api/src/test/java/datadog/trace/util/MaybeTest.java +++ b/internal-api/src/test/java/datadog/trace/util/MaybeTest.java @@ -9,91 +9,91 @@ public class MaybeTest { - static final class Widget { - long count; - } - - @Test - public void ofPresent() { - Maybe maybe = Maybe.of("value"); - assertTrue(maybe.isPresent()); - assertEquals("value", maybe.getOrNull()); - } - - @Test - public void ofAbsent() { - Maybe maybe = Maybe.of(null); - assertFalse(maybe.isPresent()); - assertNull(maybe.getOrNull()); - } - - @Test - public void ofReceiverFunctionPresent() { - Maybe maybe = Maybe.of("value", String::length); - assertTrue(maybe.isPresent()); - assertEquals(5, maybe.getOrNull()); - } - - @Test - public void ofReceiverFunctionAbsent() { - Maybe maybe = Maybe.of("value", r -> null); - assertFalse(maybe.isPresent()); - assertNull(maybe.getOrNull()); - } - - @Test - public void updateConsumerRunsWhenPresent() { - Widget w = new Widget(); - Maybe.of(w).update(widget -> widget.count = 42); - assertEquals(42, w.count); - } - - @Test - public void updateConsumerNoOpWhenAbsent() { - Maybe maybe = Maybe.of(null); - maybe.update(widget -> widget.count = 42); - assertFalse(maybe.isPresent()); - } - - @Test - public void updateBiConsumerRunsWhenPresent() { - Widget w = new Widget(); - Maybe.of(w).update("context", (widget, ctx) -> widget.count = ctx.length()); - assertEquals(7, w.count); - } - - @Test - public void updateBiConsumerNoOpWhenAbsent() { - Maybe maybe = Maybe.of(null); - maybe.update("context", (widget, ctx) -> widget.count = ctx.length()); - assertFalse(maybe.isPresent()); - } - - @Test - public void updateLongRunsWhenPresent() { - Widget w = new Widget(); - Maybe.of(w).update(5L, (widget, delta) -> widget.count += delta); - assertEquals(5, w.count); - } - - @Test - public void updateLongNoOpWhenAbsent() { - Maybe maybe = Maybe.of(null); - maybe.update(5L, (widget, delta) -> widget.count += delta); - assertFalse(maybe.isPresent()); - } - - @Test - public void ifPresentOrElseRunsActionWhenPresent() { - StringBuilder sb = new StringBuilder(); - Maybe.of("value").ifPresentOrElse(sb::append, () -> sb.append("empty")); - assertEquals("value", sb.toString()); - } - - @Test - public void ifPresentOrElseRunsEmptyActionWhenAbsent() { - StringBuilder sb = new StringBuilder(); - Maybe.of(null).ifPresentOrElse(sb::append, () -> sb.append("empty")); - assertEquals("empty", sb.toString()); - } + static final class Widget { + long count; + } + + @Test + public void ofPresent() { + Maybe maybe = Maybe.of("value"); + assertTrue(maybe.isPresent()); + assertEquals("value", maybe.getOrNull()); + } + + @Test + public void ofAbsent() { + Maybe maybe = Maybe.of(null); + assertFalse(maybe.isPresent()); + assertNull(maybe.getOrNull()); + } + + @Test + public void ofReceiverFunctionPresent() { + Maybe maybe = Maybe.of("value", String::length); + assertTrue(maybe.isPresent()); + assertEquals(5, maybe.getOrNull()); + } + + @Test + public void ofReceiverFunctionAbsent() { + Maybe maybe = Maybe.of("value", r -> null); + assertFalse(maybe.isPresent()); + assertNull(maybe.getOrNull()); + } + + @Test + public void updateConsumerRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update(widget -> widget.count = 42); + assertEquals(42, w.count); + } + + @Test + public void updateConsumerNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update(widget -> widget.count = 42); + assertFalse(maybe.isPresent()); + } + + @Test + public void updateBiConsumerRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update("context", (widget, ctx) -> widget.count = ctx.length()); + assertEquals(7, w.count); + } + + @Test + public void updateBiConsumerNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update("context", (widget, ctx) -> widget.count = ctx.length()); + assertFalse(maybe.isPresent()); + } + + @Test + public void updateLongRunsWhenPresent() { + Widget w = new Widget(); + Maybe.of(w).update(5L, (widget, delta) -> widget.count += delta); + assertEquals(5, w.count); + } + + @Test + public void updateLongNoOpWhenAbsent() { + Maybe maybe = Maybe.of(null); + maybe.update(5L, (widget, delta) -> widget.count += delta); + assertFalse(maybe.isPresent()); + } + + @Test + public void ifPresentOrElseRunsActionWhenPresent() { + StringBuilder sb = new StringBuilder(); + Maybe.of("value").ifPresentOrElse(sb::append, () -> sb.append("empty")); + assertEquals("value", sb.toString()); + } + + @Test + public void ifPresentOrElseRunsEmptyActionWhenAbsent() { + StringBuilder sb = new StringBuilder(); + Maybe.of(null).ifPresentOrElse(sb::append, () -> sb.append("empty")); + assertEquals("empty", sb.toString()); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/RandomUtilsTest.java b/internal-api/src/test/java/datadog/trace/util/RandomUtilsTest.java index 1a3969a5a77..fad05b93aec 100644 --- a/internal-api/src/test/java/datadog/trace/util/RandomUtilsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/RandomUtilsTest.java @@ -6,20 +6,23 @@ import org.junit.jupiter.api.Test; public class RandomUtilsTest { - private static final Pattern VERSION_4_UUID = - Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[8-9a-f][0-9a-f]{3}-[0-9a-f]{12}"); + private static final Pattern VERSION_4_UUID = + Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[8-9a-f][0-9a-f]{3}-[0-9a-f]{12}"); - @Test - public void testRandomUUIDMatchesSpec() { - for (int i = 0; i < 8; i++) { - assertTrue(VERSION_4_UUID.matcher(RandomUtils.randomUUID().toString()).matches()); + @Test + public void testRandomUUIDMatchesSpec() { + for (int i = 0; i < 8; i++) { + assertTrue( + VERSION_4_UUID.matcher(RandomUtils.randomUUID().toString()).matches()); + } } - } - @Test - public void testSecureRandomUUIDMatchesSpec() { - for (int i = 0; i < 8; i++) { - assertTrue(VERSION_4_UUID.matcher(RandomUtils.secureRandomUUID().toString()).matches()); + @Test + public void testSecureRandomUUIDMatchesSpec() { + for (int i = 0; i < 8; i++) { + assertTrue(VERSION_4_UUID + .matcher(RandomUtils.secureRandomUUID().toString()) + .matches()); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java index 63c62bc24f4..65e18dc95ff 100644 --- a/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java @@ -47,53 +47,49 @@ */ class StringIndexFootprintTest { - @BeforeAll - static void assumeNotJ9Jvm() { - // JOL's GraphLayout relies on HotSpot-specific Unsafe internals and throws - // IllegalStateException on J9-based JVMs (IBM/Semeru) — same guard as - // ScopeAndContinuationLayoutTest. - assumeFalse(JavaVirtualMachine.isJ9()); - } + @BeforeAll + static void assumeNotJ9Jvm() { + // JOL's GraphLayout relies on HotSpot-specific Unsafe internals and throws + // IllegalStateException on J9-based JVMs (IBM/Semeru) — same guard as + // ScopeAndContinuationLayoutTest. + assumeFalse(JavaVirtualMachine.isJ9()); + } - static String[] elements(int n) { - String[] a = new String[n]; - for (int i = 0; i < n; ++i) { - a[i] = "element-key-" + i; + static String[] elements(int n) { + String[] a = new String[n]; + for (int i = 0; i < n; ++i) { + a[i] = "element-key-" + i; + } + return a; } - return a; - } - static long bytes(Object root) { - return GraphLayout.parseInstance(root).totalSize(); - } + static long bytes(Object root) { + return GraphLayout.parseInstance(root).totalSize(); + } - @Test - void footprintComparison() { - System.out.printf( - "%-6s %12s %12s %12s %12s %12s%n", - "n", "array", "hashSet", "treeSet", "copyOf", "stringIndex"); - System.out.printf( - "%-6s %12s %12s %12s %12s %12s (overhead above array)%n", "", "", "", "", "", ""); + @Test + void footprintComparison() { + System.out.printf( + "%-6s %12s %12s %12s %12s %12s%n", "n", "array", "hashSet", "treeSet", "copyOf", "stringIndex"); + System.out.printf("%-6s %12s %12s %12s %12s %12s (overhead above array)%n", "", "", "", "", "", ""); - for (int n : new int[] {8, 32, 128}) { - String[] el = elements(n); + for (int n : new int[] {8, 32, 128}) { + String[] el = elements(n); - long array = bytes((Object) el); // baseline: strings + reference array - long hashSet = bytes(new HashSet<>(Arrays.asList(el))); - long treeSet = bytes(new TreeSet<>(Arrays.asList(el))); - Set copy = CollectionUtils.tryMakeImmutableSet(Arrays.asList(el)); - long copyOf = bytes(copy); - long stringIndex = bytes(StringIndex.of(el)); + long array = bytes((Object) el); // baseline: strings + reference array + long hashSet = bytes(new HashSet<>(Arrays.asList(el))); + long treeSet = bytes(new TreeSet<>(Arrays.asList(el))); + Set copy = CollectionUtils.tryMakeImmutableSet(Arrays.asList(el)); + long copyOf = bytes(copy); + long stringIndex = bytes(StringIndex.of(el)); - System.out.printf( - "%-6d %12d %12d %12d %12d %12d%n", n, array, hashSet, treeSet, copyOf, stringIndex); - System.out.printf( - "%-6s %12s %12d %12d %12d %12d%n", - "", "", hashSet - array, treeSet - array, copyOf - array, stringIndex - array); + System.out.printf("%-6d %12d %12d %12d %12d %12d%n", n, array, hashSet, treeSet, copyOf, stringIndex); + System.out.printf( + "%-6s %12s %12d %12d %12d %12d%n", + "", "", hashSet - array, treeSet - array, copyOf - array, stringIndex - array); - // Robust cross-JVM invariant: no per-element Node objects -> lighter than HashSet. - assertTrue( - stringIndex < hashSet, "StringIndex should retain fewer bytes than HashSet at n=" + n); + // Robust cross-JVM invariant: no per-element Node objects -> lighter than HashSet. + assertTrue(stringIndex < hashSet, "StringIndex should retain fewer bytes than HashSet at n=" + n); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java index 966d0d0d4ae..e4bda7e00b0 100644 --- a/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java @@ -13,218 +13,210 @@ class StringIndexTest { - @Test - void hash_spread_and_zeroSentinel() { - // "".hashCode() == 0 -> remapped to the non-zero sentinel so 0 can mean "empty slot" - assertEquals(0xDD06, EmbeddingSupport.hash("")); - - int raw = "foo".hashCode(); - assertEquals(raw ^ (raw >>> 16), EmbeddingSupport.hash("foo")); - assertNotEquals(0, EmbeddingSupport.hash("foo")); - } - - @Test - void capacityFor_isPow2_andAtLeastDoubled() { - assertEquals(2, EmbeddingSupport.capacityFor(0)); // empty set -> minimal table - assertEquals(2, EmbeddingSupport.capacityFor(1)); // >= 2x, smallest power of two - assertEquals(8, EmbeddingSupport.capacityFor(3)); // ceil(3/0.5)=6 -> 8 - assertEquals(8, EmbeddingSupport.capacityFor(4)); // ceil(4/0.5)=8 -> 8 (was 16: tightened) - assertEquals(64, EmbeddingSupport.capacityFor(16, EmbeddingSupport.LOW_LOAD_FACTOR)); // 4x - } - - @Test - void capacityFor_rejectsBadArgs() { - assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(-1)); - assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(4, 0f)); - assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(4, 1f)); - } - - @Test - void capacityFor_boundsAtMaxCapacity() { - // ceil(n / loadFactor) landing exactly on MAX_CAPACITY still succeeds. - int n = EmbeddingSupport.MAX_CAPACITY / 2; // ceil(n / 0.5) == MAX_CAPACITY - assertEquals(EmbeddingSupport.MAX_CAPACITY, EmbeddingSupport.capacityFor(n)); - } - - @Test - void capacityFor_rejectsCapacityAboveMax() { - // Required capacity exceeds MAX_CAPACITY: previously the double->int narrowing conversion - // saturated at Integer.MAX_VALUE and the subsequent << 1 wrapped to a negative array size. - assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(1, 1e-10f)); - assertThrows( - IllegalArgumentException.class, - () -> EmbeddingSupport.capacityFor(EmbeddingSupport.MAX_CAPACITY + 1)); - } - - @Test - void instance_contains_internedAndCopy_andMiss() { - StringIndex set = StringIndex.of("foo", "bar", "baz"); - - assertEquals(8, set.numSlots()); // 3 names -> capacityFor(3) == 8 - - assertTrue(set.contains("foo")); // interned literal -> == fast path in eq - assertTrue(set.contains(new String("bar"))); // non-interned -> .equals path - assertFalse(set.contains("nope")); - - assertTrue(set.indexOf("baz") >= 0); - assertEquals(-1, set.indexOf("nope")); - } - - @Test - void support_create_then_indexOf() { - Data d = EmbeddingSupport.create("x", "y"); - - int slot = EmbeddingSupport.indexOf(d.hashes, d.names, "x"); // 3-arg overload computes the hash - assertTrue(slot >= 0); - assertEquals("x", d.names[slot]); - - assertEquals(-1, EmbeddingSupport.indexOf(d.hashes, d.names, "q")); - } - - @Test - void support_contains_internedAndCopy_andMiss() { - Data d = EmbeddingSupport.create("foo", "bar", "baz"); - - assertTrue( - EmbeddingSupport.contains(d.hashes, d.names, "foo")); // interned literal -> == fast path - assertTrue(EmbeddingSupport.contains(d.hashes, d.names, new String("bar"))); // non-interned - assertFalse(EmbeddingSupport.contains(d.hashes, d.names, "nope")); - } - - /** Controlled hashes force collision, linear-probe wraparound, and the already-present path. */ - @Test - void put_and_indexOf_collisionAndWraparound() { - int[] hashes = new int[4]; // mask = 3 - String[] names = new String[4]; - - assertEquals(3, EmbeddingSupport.put(hashes, names, "a", 7)); // 7 & 3 == 3 - assertEquals( - 0, EmbeddingSupport.put(hashes, names, "b", 7)); // collides at 3, probes (3+1)&3 == 0 - assertEquals( - 3, EmbeddingSupport.put(hashes, names, "a", 7)); // already present -> existing slot - - assertEquals(3, EmbeddingSupport.indexOf(hashes, names, "a", 7)); // direct hit - assertEquals( - 0, EmbeddingSupport.indexOf(hashes, names, "b", 7)); // hit after collision + wraparound - assertEquals( - -1, - EmbeddingSupport.indexOf(hashes, names, "c", 7)); // miss after probing 3 -> 0 -> 1(empty) - assertEquals( - -1, EmbeddingSupport.indexOf(hashes, names, "z", 6)); // 6 & 3 == 2, empty -> immediate miss - } - - @Test - void put_throwsWhenFull() { - int[] hashes = new int[2]; // mask = 1 - String[] names = new String[2]; - - EmbeddingSupport.put(hashes, names, "a", 4); // 4 & 1 == 0 - EmbeddingSupport.put(hashes, names, "b", 5); // 5 & 1 == 1 - - // both slots occupied, no match -> probe exhausts -> throw - assertThrows(IllegalStateException.class, () -> EmbeddingSupport.put(hashes, names, "c", 6)); - } - - /** The documented usage: build a StringIndex, attach a parallel payload indexed by slot. */ - @Test - void parallelPayloadBySlot() { - String[] names = {"a", "b", "c"}; - Data d = EmbeddingSupport.create(names); - - long[] ids = new long[d.names.length]; - for (int j = 0; j < names.length; j++) { - ids[EmbeddingSupport.indexOf(d.hashes, d.names, names[j])] = j + 1L; - } - - assertEquals(1L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "a")]); - assertEquals(2L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "b")]); - assertEquals(3L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "c")]); - } - - @Test - void mapIntValues_slotAligned_andLookup() { - StringIndex idx = StringIndex.of("a", "b", "c"); - // 1-based ids; 0 stays the empty-slot / not-found sentinel. - int[] ids = idx.mapIntValues(s -> s.charAt(0) - 'a' + 1); - assertEquals(idx.numSlots(), ids.length); // sized to the table, not the name count - - assertEquals(1, idx.lookup(ids, "a")); - assertEquals(2, idx.lookup(ids, "b")); - assertEquals(3, idx.lookup(ids, "c")); - assertEquals(0, idx.lookup(ids, "z")); // miss -> 0 - assertEquals(-1, idx.lookupOrDefault(ids, "z", -1)); // miss -> supplied default - } - - @Test - void mapLongValues_slotAligned_andLookup() { - Data d = EmbeddingSupport.create("a", "b", "c"); - long[] vals = EmbeddingSupport.mapLongValues(d.names, s -> s.charAt(0) - 'a' + 1L); - - assertEquals(1L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "a")); - assertEquals(3L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "c")); - assertEquals(0L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "z")); // miss -> 0 - assertEquals(-1L, EmbeddingSupport.lookupOrDefault(d.hashes, d.names, vals, "z", -1L)); - } - - @Test - void mapValues_objects_typedArray_andLookup() { - StringIndex idx = StringIndex.of("a", "bb", "ccc"); - Integer[] lengths = idx.mapValues(Integer.class, String::length); - - // Class drives a real Integer[], not an Object[]. - assertEquals(Integer[].class, lengths.getClass()); - - assertEquals(Integer.valueOf(1), idx.lookup(lengths, "a")); - assertEquals(Integer.valueOf(3), idx.lookup(lengths, "ccc")); - assertNull(idx.lookup(lengths, "z")); // miss -> null - assertEquals(Integer.valueOf(-1), idx.lookupOrDefault(lengths, "z", -1)); - } - - @Test - void support_mapValues_objects_sizedToSlots_emptyStayNull() { - Data d = EmbeddingSupport.create("a", "b", "c"); - String[] tagged = EmbeddingSupport.mapValues(d.names, String.class, s -> s + "!"); - - assertEquals(d.names.length, tagged.length); // sized to the table - int nonNull = 0; - for (String s : tagged) { - if (s != null) { - nonNull++; - } - } - assertEquals(3, nonNull); // only the placed names map; unfilled slots stay null - - assertEquals("a!", EmbeddingSupport.lookup(d.hashes, d.names, tagged, "a")); - assertEquals("dflt", EmbeddingSupport.lookupOrDefault(d.hashes, d.names, tagged, "z", "dflt")); - } - - @Test - void instance_lookup_delegatesToSupportArrays() { - StringIndex idx = StringIndex.of("x", "y"); - int[] ids = idx.mapIntValues(s -> "x".equals(s) ? 7 : 9); - - assertEquals(7, idx.lookup(ids, "x")); - assertEquals(9, idx.lookup(ids, "y")); - assertEquals(0, idx.lookup(ids, "missing")); - assertEquals(42, idx.lookupOrDefault(ids, "missing", 42)); - } - - @Test - void instance_longValues_mapAndLookup() { - StringIndex idx = StringIndex.of("a", "b", "c"); - long[] vals = idx.mapLongValues(s -> s.charAt(0) - 'a' + 1L); - assertEquals(idx.numSlots(), vals.length); // sized to the table, not the name count - - assertEquals(1L, idx.lookup(vals, "a")); - assertEquals(3L, idx.lookup(vals, "c")); - assertEquals(0L, idx.lookup(vals, "z")); // miss -> 0 - assertEquals(2L, idx.lookupOrDefault(vals, "b", -1L)); // hit - assertEquals(-1L, idx.lookupOrDefault(vals, "z", -1L)); // miss -> supplied default - } - - @Test - void support_numSlots_matchesTableSize() { - Data d = EmbeddingSupport.create("a", "b", "c"); - assertEquals(d.hashes.length, EmbeddingSupport.numSlots(d.hashes)); - } + @Test + void hash_spread_and_zeroSentinel() { + // "".hashCode() == 0 -> remapped to the non-zero sentinel so 0 can mean "empty slot" + assertEquals(0xDD06, EmbeddingSupport.hash("")); + + int raw = "foo".hashCode(); + assertEquals(raw ^ (raw >>> 16), EmbeddingSupport.hash("foo")); + assertNotEquals(0, EmbeddingSupport.hash("foo")); + } + + @Test + void capacityFor_isPow2_andAtLeastDoubled() { + assertEquals(2, EmbeddingSupport.capacityFor(0)); // empty set -> minimal table + assertEquals(2, EmbeddingSupport.capacityFor(1)); // >= 2x, smallest power of two + assertEquals(8, EmbeddingSupport.capacityFor(3)); // ceil(3/0.5)=6 -> 8 + assertEquals(8, EmbeddingSupport.capacityFor(4)); // ceil(4/0.5)=8 -> 8 (was 16: tightened) + assertEquals(64, EmbeddingSupport.capacityFor(16, EmbeddingSupport.LOW_LOAD_FACTOR)); // 4x + } + + @Test + void capacityFor_rejectsBadArgs() { + assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(-1)); + assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(4, 0f)); + assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(4, 1f)); + } + + @Test + void capacityFor_boundsAtMaxCapacity() { + // ceil(n / loadFactor) landing exactly on MAX_CAPACITY still succeeds. + int n = EmbeddingSupport.MAX_CAPACITY / 2; // ceil(n / 0.5) == MAX_CAPACITY + assertEquals(EmbeddingSupport.MAX_CAPACITY, EmbeddingSupport.capacityFor(n)); + } + + @Test + void capacityFor_rejectsCapacityAboveMax() { + // Required capacity exceeds MAX_CAPACITY: previously the double->int narrowing conversion + // saturated at Integer.MAX_VALUE and the subsequent << 1 wrapped to a negative array size. + assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(1, 1e-10f)); + assertThrows( + IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(EmbeddingSupport.MAX_CAPACITY + 1)); + } + + @Test + void instance_contains_internedAndCopy_andMiss() { + StringIndex set = StringIndex.of("foo", "bar", "baz"); + + assertEquals(8, set.numSlots()); // 3 names -> capacityFor(3) == 8 + + assertTrue(set.contains("foo")); // interned literal -> == fast path in eq + assertTrue(set.contains(new String("bar"))); // non-interned -> .equals path + assertFalse(set.contains("nope")); + + assertTrue(set.indexOf("baz") >= 0); + assertEquals(-1, set.indexOf("nope")); + } + + @Test + void support_create_then_indexOf() { + Data d = EmbeddingSupport.create("x", "y"); + + int slot = EmbeddingSupport.indexOf(d.hashes, d.names, "x"); // 3-arg overload computes the hash + assertTrue(slot >= 0); + assertEquals("x", d.names[slot]); + + assertEquals(-1, EmbeddingSupport.indexOf(d.hashes, d.names, "q")); + } + + @Test + void support_contains_internedAndCopy_andMiss() { + Data d = EmbeddingSupport.create("foo", "bar", "baz"); + + assertTrue(EmbeddingSupport.contains(d.hashes, d.names, "foo")); // interned literal -> == fast path + assertTrue(EmbeddingSupport.contains(d.hashes, d.names, new String("bar"))); // non-interned + assertFalse(EmbeddingSupport.contains(d.hashes, d.names, "nope")); + } + + /** Controlled hashes force collision, linear-probe wraparound, and the already-present path. */ + @Test + void put_and_indexOf_collisionAndWraparound() { + int[] hashes = new int[4]; // mask = 3 + String[] names = new String[4]; + + assertEquals(3, EmbeddingSupport.put(hashes, names, "a", 7)); // 7 & 3 == 3 + assertEquals(0, EmbeddingSupport.put(hashes, names, "b", 7)); // collides at 3, probes (3+1)&3 == 0 + assertEquals(3, EmbeddingSupport.put(hashes, names, "a", 7)); // already present -> existing slot + + assertEquals(3, EmbeddingSupport.indexOf(hashes, names, "a", 7)); // direct hit + assertEquals(0, EmbeddingSupport.indexOf(hashes, names, "b", 7)); // hit after collision + wraparound + assertEquals(-1, EmbeddingSupport.indexOf(hashes, names, "c", 7)); // miss after probing 3 -> 0 -> 1(empty) + assertEquals(-1, EmbeddingSupport.indexOf(hashes, names, "z", 6)); // 6 & 3 == 2, empty -> immediate miss + } + + @Test + void put_throwsWhenFull() { + int[] hashes = new int[2]; // mask = 1 + String[] names = new String[2]; + + EmbeddingSupport.put(hashes, names, "a", 4); // 4 & 1 == 0 + EmbeddingSupport.put(hashes, names, "b", 5); // 5 & 1 == 1 + + // both slots occupied, no match -> probe exhausts -> throw + assertThrows(IllegalStateException.class, () -> EmbeddingSupport.put(hashes, names, "c", 6)); + } + + /** The documented usage: build a StringIndex, attach a parallel payload indexed by slot. */ + @Test + void parallelPayloadBySlot() { + String[] names = {"a", "b", "c"}; + Data d = EmbeddingSupport.create(names); + + long[] ids = new long[d.names.length]; + for (int j = 0; j < names.length; j++) { + ids[EmbeddingSupport.indexOf(d.hashes, d.names, names[j])] = j + 1L; + } + + assertEquals(1L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "a")]); + assertEquals(2L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "b")]); + assertEquals(3L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "c")]); + } + + @Test + void mapIntValues_slotAligned_andLookup() { + StringIndex idx = StringIndex.of("a", "b", "c"); + // 1-based ids; 0 stays the empty-slot / not-found sentinel. + int[] ids = idx.mapIntValues(s -> s.charAt(0) - 'a' + 1); + assertEquals(idx.numSlots(), ids.length); // sized to the table, not the name count + + assertEquals(1, idx.lookup(ids, "a")); + assertEquals(2, idx.lookup(ids, "b")); + assertEquals(3, idx.lookup(ids, "c")); + assertEquals(0, idx.lookup(ids, "z")); // miss -> 0 + assertEquals(-1, idx.lookupOrDefault(ids, "z", -1)); // miss -> supplied default + } + + @Test + void mapLongValues_slotAligned_andLookup() { + Data d = EmbeddingSupport.create("a", "b", "c"); + long[] vals = EmbeddingSupport.mapLongValues(d.names, s -> s.charAt(0) - 'a' + 1L); + + assertEquals(1L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "a")); + assertEquals(3L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "c")); + assertEquals(0L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "z")); // miss -> 0 + assertEquals(-1L, EmbeddingSupport.lookupOrDefault(d.hashes, d.names, vals, "z", -1L)); + } + + @Test + void mapValues_objects_typedArray_andLookup() { + StringIndex idx = StringIndex.of("a", "bb", "ccc"); + Integer[] lengths = idx.mapValues(Integer.class, String::length); + + // Class drives a real Integer[], not an Object[]. + assertEquals(Integer[].class, lengths.getClass()); + + assertEquals(Integer.valueOf(1), idx.lookup(lengths, "a")); + assertEquals(Integer.valueOf(3), idx.lookup(lengths, "ccc")); + assertNull(idx.lookup(lengths, "z")); // miss -> null + assertEquals(Integer.valueOf(-1), idx.lookupOrDefault(lengths, "z", -1)); + } + + @Test + void support_mapValues_objects_sizedToSlots_emptyStayNull() { + Data d = EmbeddingSupport.create("a", "b", "c"); + String[] tagged = EmbeddingSupport.mapValues(d.names, String.class, s -> s + "!"); + + assertEquals(d.names.length, tagged.length); // sized to the table + int nonNull = 0; + for (String s : tagged) { + if (s != null) { + nonNull++; + } + } + assertEquals(3, nonNull); // only the placed names map; unfilled slots stay null + + assertEquals("a!", EmbeddingSupport.lookup(d.hashes, d.names, tagged, "a")); + assertEquals("dflt", EmbeddingSupport.lookupOrDefault(d.hashes, d.names, tagged, "z", "dflt")); + } + + @Test + void instance_lookup_delegatesToSupportArrays() { + StringIndex idx = StringIndex.of("x", "y"); + int[] ids = idx.mapIntValues(s -> "x".equals(s) ? 7 : 9); + + assertEquals(7, idx.lookup(ids, "x")); + assertEquals(9, idx.lookup(ids, "y")); + assertEquals(0, idx.lookup(ids, "missing")); + assertEquals(42, idx.lookupOrDefault(ids, "missing", 42)); + } + + @Test + void instance_longValues_mapAndLookup() { + StringIndex idx = StringIndex.of("a", "b", "c"); + long[] vals = idx.mapLongValues(s -> s.charAt(0) - 'a' + 1L); + assertEquals(idx.numSlots(), vals.length); // sized to the table, not the name count + + assertEquals(1L, idx.lookup(vals, "a")); + assertEquals(3L, idx.lookup(vals, "c")); + assertEquals(0L, idx.lookup(vals, "z")); // miss -> 0 + assertEquals(2L, idx.lookupOrDefault(vals, "b", -1L)); // hit + assertEquals(-1L, idx.lookupOrDefault(vals, "z", -1L)); // miss -> supplied default + } + + @Test + void support_numSlots_matchesTableSize() { + Data d = EmbeddingSupport.create("a", "b", "c"); + assertEquals(d.hashes.length, EmbeddingSupport.numSlots(d.hashes)); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java b/internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java index 52195d73693..04173165ad8 100644 --- a/internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java +++ b/internal-api/src/test/java/datadog/trace/util/StringsCaseInsensitiveHashCodeTest.java @@ -8,96 +8,93 @@ class StringsCaseInsensitiveHashCodeTest { - @Test - void equalIgnoringCaseProducesEqualHash() { - assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("content-type")); - assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("CONTENT-TYPE")); - assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("cOnTeNt-TyPe")); - } + @Test + void equalIgnoringCaseProducesEqualHash() { + assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("content-type")); + assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("CONTENT-TYPE")); + assertEquals(caseInsensitiveHashCode("Content-Type"), caseInsensitiveHashCode("cOnTeNt-TyPe")); + } - @Test - void emptyStringHashesToZero() { - // Matches String.hashCode("") == 0. - assertEquals(0, caseInsensitiveHashCode("")); - } + @Test + void emptyStringHashesToZero() { + // Matches String.hashCode("") == 0. + assertEquals(0, caseInsensitiveHashCode("")); + } - @Test - void distinctContentHashesDiffer() { - // Not a guarantee in general, but these representative keys must not collide. - assertNotEquals(caseInsensitiveHashCode("foo"), caseInsensitiveHashCode("bar")); - assertNotEquals(caseInsensitiveHashCode("Accept"), caseInsensitiveHashCode("Host")); - } + @Test + void distinctContentHashesDiffer() { + // Not a guarantee in general, but these representative keys must not collide. + assertNotEquals(caseInsensitiveHashCode("foo"), caseInsensitiveHashCode("bar")); + assertNotEquals(caseInsensitiveHashCode("Accept"), caseInsensitiveHashCode("Host")); + } - @Test - void staysConsistentWithEqualsIgnoreCase() { - // For any pair, equalsIgnoreCase => equal hash. (The converse — unequal hash implies not - // equalsIgnoreCase — is what a table relies on to never miss a present key.) - String[] samples = { - "Accept", - "accept", - "ACCEPT", - "Accept-Encoding", - "accept-encoding", - "X-Forwarded-For", - "x-forwarded-for", - "Host", - "host" - }; - for (String a : samples) { - for (String b : samples) { - if (a.equalsIgnoreCase(b)) { - assertEquals( - caseInsensitiveHashCode(a), - caseInsensitiveHashCode(b), - () -> "hash mismatch for equalIgnoreCase pair"); + @Test + void staysConsistentWithEqualsIgnoreCase() { + // For any pair, equalsIgnoreCase => equal hash. (The converse — unequal hash implies not + // equalsIgnoreCase — is what a table relies on to never miss a present key.) + String[] samples = { + "Accept", + "accept", + "ACCEPT", + "Accept-Encoding", + "accept-encoding", + "X-Forwarded-For", + "x-forwarded-for", + "Host", + "host" + }; + for (String a : samples) { + for (String b : samples) { + if (a.equalsIgnoreCase(b)) { + assertEquals( + caseInsensitiveHashCode(a), + caseInsensitiveHashCode(b), + () -> "hash mismatch for equalIgnoreCase pair"); + } + } } - } } - } - /** - * A supplementary-plane pair that {@code equalsIgnoreCase} unifies on JDK 9+ (code-point-based - * folding) but a per-{@code char} hash fold kept distinct -- the bug this fold fixes. Folding by - * code point unifies the hash too, so this holds on every JDK regardless of what {@code - * equalsIgnoreCase} itself does with the pair on that JDK (see {@link - * #staysConsistentAcrossAllDefinedCodePoints} for the guarded, cross-JDK-safe version of this - * check). - */ - @Test - void foldsSupplementaryCasePairTogether() { - String s1 = new String(Character.toChars(0x10400)); // DESERET CAPITAL LETTER LONG I - String s2 = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I - assertEquals(caseInsensitiveHashCode(s1), caseInsensitiveHashCode(s2)); - } + /** + * A supplementary-plane pair that {@code equalsIgnoreCase} unifies on JDK 9+ (code-point-based + * folding) but a per-{@code char} hash fold kept distinct -- the bug this fold fixes. Folding by + * code point unifies the hash too, so this holds on every JDK regardless of what {@code + * equalsIgnoreCase} itself does with the pair on that JDK (see {@link + * #staysConsistentAcrossAllDefinedCodePoints} for the guarded, cross-JDK-safe version of this + * check). + */ + @Test + void foldsSupplementaryCasePairTogether() { + String s1 = new String(Character.toChars(0x10400)); // DESERET CAPITAL LETTER LONG I + String s2 = new String(Character.toChars(0x10428)); // DESERET SMALL LETTER LONG I + assertEquals(caseInsensitiveHashCode(s1), caseInsensitiveHashCode(s2)); + } - /** - * Exhaustive sweep guarded by {@code equalsIgnoreCase} itself, so it stays portable across JDKs: - * on JDK 8 (no code-point reconstruction) the supplementary pairs above simply never enter the - * guard, and the sweep still passes. - */ - @Test - void staysConsistentAcrossAllDefinedCodePoints() { - for (int cp = 0; cp <= Character.MAX_CODE_POINT; cp++) { - if (!Character.isDefined(cp) || Character.isSurrogate((char) cp)) continue; - String s = new String(Character.toChars(cp)); - for (int variant : - new int[] { - Character.toUpperCase(cp), Character.toLowerCase(cp), Character.toTitleCase(cp) - }) { - String t = new String(Character.toChars(variant)); - if (s.equalsIgnoreCase(t)) { - final String fs = s; - final String ft = t; - assertEquals( - caseInsensitiveHashCode(s), - caseInsensitiveHashCode(t), - () -> - "hash mismatch for equalsIgnoreCase pair U+" - + Integer.toHexString(fs.codePointAt(0)) - + " / U+" - + Integer.toHexString(ft.codePointAt(0))); + /** + * Exhaustive sweep guarded by {@code equalsIgnoreCase} itself, so it stays portable across JDKs: + * on JDK 8 (no code-point reconstruction) the supplementary pairs above simply never enter the + * guard, and the sweep still passes. + */ + @Test + void staysConsistentAcrossAllDefinedCodePoints() { + for (int cp = 0; cp <= Character.MAX_CODE_POINT; cp++) { + if (!Character.isDefined(cp) || Character.isSurrogate((char) cp)) continue; + String s = new String(Character.toChars(cp)); + for (int variant : + new int[] {Character.toUpperCase(cp), Character.toLowerCase(cp), Character.toTitleCase(cp)}) { + String t = new String(Character.toChars(variant)); + if (s.equalsIgnoreCase(t)) { + final String fs = s; + final String ft = t; + assertEquals( + caseInsensitiveHashCode(s), + caseInsensitiveHashCode(t), + () -> "hash mismatch for equalsIgnoreCase pair U+" + + Integer.toHexString(fs.codePointAt(0)) + + " / U+" + + Integer.toHexString(ft.codePointAt(0))); + } + } } - } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/StringsRegionContainsTest.java b/internal-api/src/test/java/datadog/trace/util/StringsRegionContainsTest.java index 115fcd79a31..a4e8d87d7af 100644 --- a/internal-api/src/test/java/datadog/trace/util/StringsRegionContainsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/StringsRegionContainsTest.java @@ -9,45 +9,45 @@ /** Boundary semantics of {@link Strings#regionContains(String, int, int, String)}. */ class StringsRegionContainsTest { - // "abXYZcd": a0 b1 X2 Y3 Z4 c5 d6 -> "XYZ" spans [2,5). - private static final String S = "abXYZcd"; - - @Test - void foundFullyInside() { - assertTrue(regionContains(S, 0, S.length(), "XYZ")); - } - - @Test - void notPresent() { - assertFalse(regionContains(S, 0, S.length(), "QQ")); - } - - @Test - void exactFit() { - // idx == 2, idx + len == 5 == endIndex -> included. - assertTrue(regionContains(S, 2, 5, "XYZ")); - } - - @Test - void straddlingEndIndexExcluded() { - // endIndex == 4 cuts off the trailing 'Z' -> not fully inside. - assertFalse(regionContains(S, 2, 4, "XYZ")); - } - - @Test - void occurrenceBeforeBeginIndexExcluded() { - // beginIndex == 3 starts past the needle's first char -> no occurrence at/after beginIndex. - assertFalse(regionContains(S, 3, S.length(), "XYZ")); - } - - @Test - void emptyRegion() { - assertFalse(regionContains(S, 2, 2, "XYZ")); - } - - @Test - void matchesWholeStringContains() { - assertTrue(regionContains("hello", 0, 5, "ll")); - assertFalse(regionContains("hello", 0, 5, "z")); - } + // "abXYZcd": a0 b1 X2 Y3 Z4 c5 d6 -> "XYZ" spans [2,5). + private static final String S = "abXYZcd"; + + @Test + void foundFullyInside() { + assertTrue(regionContains(S, 0, S.length(), "XYZ")); + } + + @Test + void notPresent() { + assertFalse(regionContains(S, 0, S.length(), "QQ")); + } + + @Test + void exactFit() { + // idx == 2, idx + len == 5 == endIndex -> included. + assertTrue(regionContains(S, 2, 5, "XYZ")); + } + + @Test + void straddlingEndIndexExcluded() { + // endIndex == 4 cuts off the trailing 'Z' -> not fully inside. + assertFalse(regionContains(S, 2, 4, "XYZ")); + } + + @Test + void occurrenceBeforeBeginIndexExcluded() { + // beginIndex == 3 starts past the needle's first char -> no occurrence at/after beginIndex. + assertFalse(regionContains(S, 3, S.length(), "XYZ")); + } + + @Test + void emptyRegion() { + assertFalse(regionContains(S, 2, 2, "XYZ")); + } + + @Test + void matchesWholeStringContains() { + assertTrue(regionContains("hello", 0, 5, "ll")); + assertFalse(regionContains("hello", 0, 5, "z")); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/StringsTest2.java b/internal-api/src/test/java/datadog/trace/util/StringsTest2.java index c949da41a5d..177b2ff6f2f 100644 --- a/internal-api/src/test/java/datadog/trace/util/StringsTest2.java +++ b/internal-api/src/test/java/datadog/trace/util/StringsTest2.java @@ -9,108 +9,107 @@ import org.junit.jupiter.api.Test; public class StringsTest2 { - @Test - @DisplayName("replaceAll - single replace") - public void replaceAllNoReplace() { - assertEquals("foobar", Strings.replaceAll("foobar", "dne", "unchanged")); - } - - @Test - @DisplayName("replaceAll - single replace") - public void replaceAllSingleReplace() { - assertEquals("foobaz", Strings.replaceAll("foobar", "bar", "baz")); - } - - @Test - @DisplayName("replaceAll - single replace") - public void replaceAllMultiReplace() { - assertEquals("foo=baz&quux=baz", Strings.replaceAll("foo=bar&quux=bar", "bar", "baz")); - } - - @Test - @DisplayName("split - empty") - public void splitEmpty() { - Iterator iter = Strings.split("", '&').iterator(); - assertFalse(iter.hasNext()); - } - - @Test - @DisplayName("split - no separator") - public void splitNoSeparator() { - Iterator iter = Strings.split("foo=bar", '&').iterator(); - assertContentEquals("foo=bar", iter.next()); - assertFalse(iter.hasNext()); - } - - @Test - @DisplayName("split - with separator") - public void splitWithSeparator() { - Iterator iter = Strings.split("foo=bar&hello=world", '&').iterator(); - assertContentEquals("foo=bar", iter.next()); - assertContentEquals("hello=world", iter.next()); - assertFalse(iter.hasNext()); - } - - @Test - @DisplayName("split - leading separator") - public void splitLeadingSeparator() { - Iterator iter = Strings.split("&foo=bar", '&').iterator(); - assertSubSeq("", 0, 0, iter.next()); // empty string before the separator - assertSubSeq("foo=bar", 1, 8, iter.next()); - assertFalse(iter.hasNext()); - } - - @Test - @DisplayName("split - trailing separator") - public void splitTrailingSeparator() { - Iterator iter = Strings.split("foo=bar&", '&').iterator(); - assertSubSeq("foo=bar", 0, 7, iter.next()); - assertSubSeq("", 8, 8, iter.next()); // empty string after the separator - assertFalse(iter.hasNext()); - } - - @Test - @DisplayName("split - only separator") - public void splitOnlySeparator() { - Iterator iter = Strings.split("&", '&').iterator(); - assertSubSeq("", 0, 0, iter.next()); // empty string before the separator - assertSubSeq("", 1, 1, iter.next()); // empty string after the separator - assertFalse(iter.hasNext()); - } - - @Test - @DisplayName("split - sanity check") - public void splitSanityCheck() { - String testStr = "foo=bar&hello=world&baz=quux&firstName=Jon&lastName=Doe"; - - String[] strSplit = testStr.split("\\&"); - Iterable iterable = Strings.split(testStr, '&'); - - Iterator iter = iterable.iterator(); - for (String expected : strSplit) { - assertTrue(iter.hasNext()); - assertContentEquals(expected, iter.next()); + @Test + @DisplayName("replaceAll - single replace") + public void replaceAllNoReplace() { + assertEquals("foobar", Strings.replaceAll("foobar", "dne", "unchanged")); } - assertFalse(iter.hasNext()); - // repeat, just to check iterable functionality - Iterator iter2 = iterable.iterator(); - for (String expected : strSplit) { - assertTrue(iter2.hasNext()); - assertContentEquals(expected, iter2.next()); + @Test + @DisplayName("replaceAll - single replace") + public void replaceAllSingleReplace() { + assertEquals("foobaz", Strings.replaceAll("foobar", "bar", "baz")); + } + + @Test + @DisplayName("replaceAll - single replace") + public void replaceAllMultiReplace() { + assertEquals("foo=baz&quux=baz", Strings.replaceAll("foo=bar&quux=bar", "bar", "baz")); + } + + @Test + @DisplayName("split - empty") + public void splitEmpty() { + Iterator iter = Strings.split("", '&').iterator(); + assertFalse(iter.hasNext()); + } + + @Test + @DisplayName("split - no separator") + public void splitNoSeparator() { + Iterator iter = Strings.split("foo=bar", '&').iterator(); + assertContentEquals("foo=bar", iter.next()); + assertFalse(iter.hasNext()); + } + + @Test + @DisplayName("split - with separator") + public void splitWithSeparator() { + Iterator iter = Strings.split("foo=bar&hello=world", '&').iterator(); + assertContentEquals("foo=bar", iter.next()); + assertContentEquals("hello=world", iter.next()); + assertFalse(iter.hasNext()); + } + + @Test + @DisplayName("split - leading separator") + public void splitLeadingSeparator() { + Iterator iter = Strings.split("&foo=bar", '&').iterator(); + assertSubSeq("", 0, 0, iter.next()); // empty string before the separator + assertSubSeq("foo=bar", 1, 8, iter.next()); + assertFalse(iter.hasNext()); + } + + @Test + @DisplayName("split - trailing separator") + public void splitTrailingSeparator() { + Iterator iter = Strings.split("foo=bar&", '&').iterator(); + assertSubSeq("foo=bar", 0, 7, iter.next()); + assertSubSeq("", 8, 8, iter.next()); // empty string after the separator + assertFalse(iter.hasNext()); + } + + @Test + @DisplayName("split - only separator") + public void splitOnlySeparator() { + Iterator iter = Strings.split("&", '&').iterator(); + assertSubSeq("", 0, 0, iter.next()); // empty string before the separator + assertSubSeq("", 1, 1, iter.next()); // empty string after the separator + assertFalse(iter.hasNext()); + } + + @Test + @DisplayName("split - sanity check") + public void splitSanityCheck() { + String testStr = "foo=bar&hello=world&baz=quux&firstName=Jon&lastName=Doe"; + + String[] strSplit = testStr.split("\\&"); + Iterable iterable = Strings.split(testStr, '&'); + + Iterator iter = iterable.iterator(); + for (String expected : strSplit) { + assertTrue(iter.hasNext()); + assertContentEquals(expected, iter.next()); + } + assertFalse(iter.hasNext()); + + // repeat, just to check iterable functionality + Iterator iter2 = iterable.iterator(); + for (String expected : strSplit) { + assertTrue(iter2.hasNext()); + assertContentEquals(expected, iter2.next()); + } + assertFalse(iter2.hasNext()); + } + + static void assertContentEquals(String expectedStr, SubSequence actualSeq) { + assertTrue(actualSeq.equals(expectedStr), "equals String"); + assertEquals(expectedStr, actualSeq.toString(), "toString"); + } + + static void assertSubSeq(String expected, int expectedBeginIndex, int expectedEndIndex, SubSequence actualSeq) { + assertContentEquals(expected, actualSeq); + assertEquals(expectedBeginIndex, actualSeq.beginIndex(), "beginIndex"); + assertEquals(expectedEndIndex, actualSeq.endIndex(), "endIndex"); } - assertFalse(iter2.hasNext()); - } - - static void assertContentEquals(String expectedStr, SubSequence actualSeq) { - assertTrue(actualSeq.equals(expectedStr), "equals String"); - assertEquals(expectedStr, actualSeq.toString(), "toString"); - } - - static void assertSubSeq( - String expected, int expectedBeginIndex, int expectedEndIndex, SubSequence actualSeq) { - assertContentEquals(expected, actualSeq); - assertEquals(expectedBeginIndex, actualSeq.beginIndex(), "beginIndex"); - assertEquals(expectedEndIndex, actualSeq.endIndex(), "endIndex"); - } } diff --git a/internal-api/src/test/java/datadog/trace/util/SubSequenceTest.java b/internal-api/src/test/java/datadog/trace/util/SubSequenceTest.java index ddb03b3b115..8979f0b4875 100644 --- a/internal-api/src/test/java/datadog/trace/util/SubSequenceTest.java +++ b/internal-api/src/test/java/datadog/trace/util/SubSequenceTest.java @@ -8,253 +8,253 @@ import org.junit.jupiter.api.Test; public class SubSequenceTest { - static final String LOREM_IPSUM = - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; - - @Test - public void empty() { - SubSequence subSeq = SubSequence.EMPTY; - assertEquals("", subSeq.toString()); - assertEquals("".hashCode(), subSeq.hashCode()); - - StringBuilder builder0 = new StringBuilder(); - builder0.append(subSeq); - assertEquals("", builder0.toString()); - - StringBuilder builder1 = new StringBuilder(); - subSeq.appendTo(builder1); - assertEquals("", builder1.toString()); - } - - @Test - public void emptyTail() { - // This is allowed to represent the logical empty string after a match at the end of - // backing string. There is an important distinction from the canonical empty - // SubSequence which wouldn't have the correct beginIndex / endIndex pair. - SubSequence subSeq = new SubSequence("foo", "foo".length(), "foo".length()); - assertEquals("foo".length(), subSeq.beginIndex()); - assertEquals("foo".length(), subSeq.endIndex()); - assertEquals("", subSeq.toString()); - assertEquals("".hashCode(), subSeq.hashCode()); - - StringBuilder builder0 = new StringBuilder(); - builder0.append(subSeq); - assertEquals("", builder0.toString()); - - StringBuilder builder1 = new StringBuilder(); - subSeq.appendTo(builder1); - assertEquals("", builder1.toString()); - } - - @Test - public void subSequence() { - String str = LOREM_IPSUM; - int len = str.length(); - - for (int i = 0; i < str.length(); i += 100) { - int endIndex = Math.min(i + 100, len); - - String subStr = str.substring(i, endIndex); - CharSequence subCharSeq = str.subSequence(i, endIndex); - SubSequence subSeq = SubSequence.of(str, i, endIndex); - SubSequence altSubSeq = Strings.subSequence(str, i, endIndex); - - assertTrue(subSeq.equals(subStr)); - assertEquals(subStr, subSeq.toString()); - assertEquals(subStr.hashCode(), subSeq.hashCode()); - - assertTrue(subSeq.equals(subCharSeq)); - assertEquals(subCharSeq.toString(), subSeq.toString()); - - assertEquals(subSeq, altSubSeq); - - assertSame(subSeq.toString(), subSeq.toString()); + static final String LOREM_IPSUM = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; + + @Test + public void empty() { + SubSequence subSeq = SubSequence.EMPTY; + assertEquals("", subSeq.toString()); + assertEquals("".hashCode(), subSeq.hashCode()); + + StringBuilder builder0 = new StringBuilder(); + builder0.append(subSeq); + assertEquals("", builder0.toString()); + + StringBuilder builder1 = new StringBuilder(); + subSeq.appendTo(builder1); + assertEquals("", builder1.toString()); } - } - @Test - public void subSequenceToEnd() { - String str = LOREM_IPSUM; - int len = str.length(); + @Test + public void emptyTail() { + // This is allowed to represent the logical empty string after a match at the end of + // backing string. There is an important distinction from the canonical empty + // SubSequence which wouldn't have the correct beginIndex / endIndex pair. + SubSequence subSeq = new SubSequence("foo", "foo".length(), "foo".length()); + assertEquals("foo".length(), subSeq.beginIndex()); + assertEquals("foo".length(), subSeq.endIndex()); + assertEquals("", subSeq.toString()); + assertEquals("".hashCode(), subSeq.hashCode()); + + StringBuilder builder0 = new StringBuilder(); + builder0.append(subSeq); + assertEquals("", builder0.toString()); + + StringBuilder builder1 = new StringBuilder(); + subSeq.appendTo(builder1); + assertEquals("", builder1.toString()); + } + + @Test + public void subSequence() { + String str = LOREM_IPSUM; + int len = str.length(); + + for (int i = 0; i < str.length(); i += 100) { + int endIndex = Math.min(i + 100, len); + + String subStr = str.substring(i, endIndex); + CharSequence subCharSeq = str.subSequence(i, endIndex); + SubSequence subSeq = SubSequence.of(str, i, endIndex); + SubSequence altSubSeq = Strings.subSequence(str, i, endIndex); + + assertTrue(subSeq.equals(subStr)); + assertEquals(subStr, subSeq.toString()); + assertEquals(subStr.hashCode(), subSeq.hashCode()); + + assertTrue(subSeq.equals(subCharSeq)); + assertEquals(subCharSeq.toString(), subSeq.toString()); + + assertEquals(subSeq, altSubSeq); + + assertSame(subSeq.toString(), subSeq.toString()); + } + } + + @Test + public void subSequenceToEnd() { + String str = LOREM_IPSUM; + int len = str.length(); + + for (int i = 0; i < str.length(); i += 100) { + String subStr = str.substring(i); + SubSequence subSeq = SubSequence.of(str, i); + SubSequence altSubSeq = Strings.subSequence(str, i); + + assertTrue(subSeq.equals(subStr)); + assertEquals(subStr, subSeq.toString()); + assertEquals(subStr.hashCode(), subSeq.hashCode()); + + assertEquals(subSeq, altSubSeq); + + assertSame(subSeq.toString(), subSeq.toString()); + } + } + + @Test + public void appendToBuilder() { + SubSequence subSeq = SubSequence.of(LOREM_IPSUM, 50, 150); + + StringBuilder expectedBuilder = new StringBuilder(); + expectedBuilder.append(LOREM_IPSUM, 50, 150); + + String expectedStr = expectedBuilder.toString(); + + StringBuilder builder0 = new StringBuilder(); + builder0.append(subSeq); + assertEquals(expectedStr, builder0.toString()); + + StringBuilder builder1 = new StringBuilder(); + subSeq.appendTo(builder1); + assertEquals(expectedStr, builder1.toString()); + } - for (int i = 0; i < str.length(); i += 100) { - String subStr = str.substring(i); - SubSequence subSeq = SubSequence.of(str, i); - SubSequence altSubSeq = Strings.subSequence(str, i); + @Test + public void contains() { + // "/*ddps='svc',dde='x'*/ rest" -- the comment body "ddps='svc',dde='x'" spans [2, 20). + String s = "/*ddps='svc',dde='x'*/ rest"; + SubSequence comment = SubSequence.of(s, 2, 20); + assertTrue(comment.contains("ddps=")); + assertTrue(comment.contains("dde=")); + assertFalse(comment.contains("ddh=")); + + // View-relative: a needle present in the backing string but outside this view is not found. + SubSequence dde = SubSequence.of(s, 13, 20); // "dde='x'" + assertFalse(dde.contains("ddps=")); // ddps= is before this view's range + } + + @Test + public void subSequenceOfView() { + // Instance subSequence(start, end): start/end are in THIS view's coordinates (CharSequence + // contract), regardless of where the view sits in the backing string. + SubSequence view = SubSequence.of("abcdefghij", 2, 8); // "cdefgh" + SubSequence mid = view.subSequence(1, 4); // chars [1, 4) of "cdefgh" -> "def" + assertEquals("def", mid.toString()); + assertEquals(3, mid.beginIndex()); // absolute begin = 2 + 1 + assertEquals(6, mid.endIndex()); // absolute end = 2 + 4 (NOT 2 + 1 + 4) + + // full window and empty are exact + assertEquals("cdefgh", view.subSequence(0, view.length()).toString()); + assertEquals("", view.subSequence(2, 2).toString()); + + // nested: subSequence of a non-zero-start view stays correct (the case the old bug broke worst) + assertEquals("ef", mid.subSequence(1, 3).toString()); // chars [1, 3) of "def" -> "ef" + } - assertTrue(subSeq.equals(subStr)); - assertEquals(subStr, subSeq.toString()); - assertEquals(subStr.hashCode(), subSeq.hashCode()); + @Test + public void equalsString() { + // "call" sits at [6, 10) inside the backing string, flanked by other text. + SubSequence call = SubSequence.of("xxxxx call yyyyy", 6, 10); + assertTrue(call.equals("call")); + assertFalse(call.equals("CALL")); // case-sensitive + assertFalse(call.equals("cal")); // shorter + assertFalse(call.equals("calls")); // longer (would overshoot endIndex) + assertFalse(call.equals((Object) null)); + + // equals(Object) routes a String through the region-compare fast path... + assertTrue(call.equals((Object) "call")); + // ...and any other CharSequence (incl. another SubSequence) through contentEquals. + assertTrue(call.equals((Object) new StringBuilder("call"))); + assertTrue(call.equals((Object) SubSequence.of("xxxxx call yyyyy", 6, 10))); + assertFalse(call.equals((Object) Integer.valueOf(4))); + } + + @Test + public void contentEqualsCharSequence() { + SubSequence call = SubSequence.of("xxxxx call yyyyy", 6, 10); // "call" + assertTrue(call.contentEquals("call")); // String is a CharSequence + assertTrue(call.contentEquals(new StringBuilder("call"))); + assertTrue(call.contentEquals(SubSequence.of("a call b", 2, 6))); + assertFalse(call.contentEquals("CALL")); // case-sensitive + assertFalse(call.contentEquals("cal")); // length mismatch + assertFalse(call.contentEquals(null)); + } - assertEquals(subSeq, altSubSeq); + @Test + public void equalsIgnoreCaseString() { + SubSequence call = SubSequence.of("xxxxx CaLl yyyyy", 6, 10); + assertTrue(call.equalsIgnoreCase("call")); + assertTrue(call.equalsIgnoreCase("CALL")); + assertFalse(call.equalsIgnoreCase("cal")); + assertFalse(call.equalsIgnoreCase("calls")); + assertFalse(call.equalsIgnoreCase(null)); // matches String.equalsIgnoreCase(null) + } + + @Test + public void startsWithString() { + SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" + assertTrue(view.startsWith("{")); + assertTrue(view.startsWith("{call")); + assertTrue(view.startsWith("{call}")); + assertFalse(view.startsWith("call")); + assertFalse(view.startsWith("{call}x")); // overshoots endIndex even though backing has 'x' + assertTrue(view.startsWith("")); // empty prefix + } + + @Test + public void endsWithString() { + SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" + assertTrue(view.endsWith("}")); + assertTrue(view.endsWith("call}")); + assertTrue(view.endsWith("{call}")); + assertFalse(view.endsWith("call")); + assertFalse(view.endsWith("x{call}")); // undershoots beginIndex even though backing has 'x' + assertTrue(view.endsWith("")); // empty suffix + } + + @Test + public void indexOfString() { + SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" + assertEquals(0, view.indexOf("bc")); // window-relative offset of the first occurrence + assertEquals(2, view.indexOf("-bc")); // non-zero relative offset + assertEquals(2, view.indexOf("-")); + assertEquals(-1, view.indexOf("aa")); // present in backing string but outside the window + assertEquals(-1, view.indexOf("bc-bc-")); // overshoots endIndex + } + + @Test + public void lastIndexOfString() { + SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" + assertEquals(3, view.lastIndexOf("bc")); // last "bc" -> relative 3 + assertEquals(2, view.lastIndexOf("-")); + assertEquals(-1, view.lastIndexOf("aa")); // outside the window on both ends + assertEquals(-1, view.lastIndexOf("bc-bc-")); // overshoots endIndex + } + + @Test + public void startsWithChar() { + SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" + assertTrue(view.startsWith('{')); + assertFalse(view.startsWith('c')); // 'c' is at offset 1, not the start + assertFalse(view.startsWith('x')); // backing char before beginIndex, outside the window + assertFalse(SubSequence.EMPTY.startsWith('x')); // empty window + } + + @Test + public void endsWithChar() { + SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" + assertTrue(view.endsWith('}')); + assertFalse(view.endsWith('l')); // 'l' is one before the end + assertFalse(view.endsWith('x')); // backing char at endIndex, outside the window + assertFalse(SubSequence.EMPTY.endsWith('x')); // empty window + } + + @Test + public void indexOfChar() { + SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" + assertEquals(0, view.indexOf('b')); // window-relative offset of the first occurrence + assertEquals(1, view.indexOf('c')); + assertEquals(2, view.indexOf('-')); + assertEquals(-1, view.indexOf('a')); // present in backing string but outside the window + } - assertSame(subSeq.toString(), subSeq.toString()); + @Test + public void lastIndexOfChar() { + SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" + assertEquals(3, view.lastIndexOf('b')); // last 'b' -> relative 3 + assertEquals(4, view.lastIndexOf('c')); + assertEquals(2, view.lastIndexOf('-')); + assertEquals(-1, view.lastIndexOf('a')); // outside the window on both ends } - } - - @Test - public void appendToBuilder() { - SubSequence subSeq = SubSequence.of(LOREM_IPSUM, 50, 150); - - StringBuilder expectedBuilder = new StringBuilder(); - expectedBuilder.append(LOREM_IPSUM, 50, 150); - - String expectedStr = expectedBuilder.toString(); - - StringBuilder builder0 = new StringBuilder(); - builder0.append(subSeq); - assertEquals(expectedStr, builder0.toString()); - - StringBuilder builder1 = new StringBuilder(); - subSeq.appendTo(builder1); - assertEquals(expectedStr, builder1.toString()); - } - - @Test - public void contains() { - // "/*ddps='svc',dde='x'*/ rest" -- the comment body "ddps='svc',dde='x'" spans [2, 20). - String s = "/*ddps='svc',dde='x'*/ rest"; - SubSequence comment = SubSequence.of(s, 2, 20); - assertTrue(comment.contains("ddps=")); - assertTrue(comment.contains("dde=")); - assertFalse(comment.contains("ddh=")); - - // View-relative: a needle present in the backing string but outside this view is not found. - SubSequence dde = SubSequence.of(s, 13, 20); // "dde='x'" - assertFalse(dde.contains("ddps=")); // ddps= is before this view's range - } - - @Test - public void subSequenceOfView() { - // Instance subSequence(start, end): start/end are in THIS view's coordinates (CharSequence - // contract), regardless of where the view sits in the backing string. - SubSequence view = SubSequence.of("abcdefghij", 2, 8); // "cdefgh" - SubSequence mid = view.subSequence(1, 4); // chars [1, 4) of "cdefgh" -> "def" - assertEquals("def", mid.toString()); - assertEquals(3, mid.beginIndex()); // absolute begin = 2 + 1 - assertEquals(6, mid.endIndex()); // absolute end = 2 + 4 (NOT 2 + 1 + 4) - - // full window and empty are exact - assertEquals("cdefgh", view.subSequence(0, view.length()).toString()); - assertEquals("", view.subSequence(2, 2).toString()); - - // nested: subSequence of a non-zero-start view stays correct (the case the old bug broke worst) - assertEquals("ef", mid.subSequence(1, 3).toString()); // chars [1, 3) of "def" -> "ef" - } - - @Test - public void equalsString() { - // "call" sits at [6, 10) inside the backing string, flanked by other text. - SubSequence call = SubSequence.of("xxxxx call yyyyy", 6, 10); - assertTrue(call.equals("call")); - assertFalse(call.equals("CALL")); // case-sensitive - assertFalse(call.equals("cal")); // shorter - assertFalse(call.equals("calls")); // longer (would overshoot endIndex) - assertFalse(call.equals((Object) null)); - - // equals(Object) routes a String through the region-compare fast path... - assertTrue(call.equals((Object) "call")); - // ...and any other CharSequence (incl. another SubSequence) through contentEquals. - assertTrue(call.equals((Object) new StringBuilder("call"))); - assertTrue(call.equals((Object) SubSequence.of("xxxxx call yyyyy", 6, 10))); - assertFalse(call.equals((Object) Integer.valueOf(4))); - } - - @Test - public void contentEqualsCharSequence() { - SubSequence call = SubSequence.of("xxxxx call yyyyy", 6, 10); // "call" - assertTrue(call.contentEquals("call")); // String is a CharSequence - assertTrue(call.contentEquals(new StringBuilder("call"))); - assertTrue(call.contentEquals(SubSequence.of("a call b", 2, 6))); - assertFalse(call.contentEquals("CALL")); // case-sensitive - assertFalse(call.contentEquals("cal")); // length mismatch - assertFalse(call.contentEquals(null)); - } - - @Test - public void equalsIgnoreCaseString() { - SubSequence call = SubSequence.of("xxxxx CaLl yyyyy", 6, 10); - assertTrue(call.equalsIgnoreCase("call")); - assertTrue(call.equalsIgnoreCase("CALL")); - assertFalse(call.equalsIgnoreCase("cal")); - assertFalse(call.equalsIgnoreCase("calls")); - assertFalse(call.equalsIgnoreCase(null)); // matches String.equalsIgnoreCase(null) - } - - @Test - public void startsWithString() { - SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" - assertTrue(view.startsWith("{")); - assertTrue(view.startsWith("{call")); - assertTrue(view.startsWith("{call}")); - assertFalse(view.startsWith("call")); - assertFalse(view.startsWith("{call}x")); // overshoots endIndex even though backing has 'x' - assertTrue(view.startsWith("")); // empty prefix - } - - @Test - public void endsWithString() { - SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" - assertTrue(view.endsWith("}")); - assertTrue(view.endsWith("call}")); - assertTrue(view.endsWith("{call}")); - assertFalse(view.endsWith("call")); - assertFalse(view.endsWith("x{call}")); // undershoots beginIndex even though backing has 'x' - assertTrue(view.endsWith("")); // empty suffix - } - - @Test - public void indexOfString() { - SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" - assertEquals(0, view.indexOf("bc")); // window-relative offset of the first occurrence - assertEquals(2, view.indexOf("-bc")); // non-zero relative offset - assertEquals(2, view.indexOf("-")); - assertEquals(-1, view.indexOf("aa")); // present in backing string but outside the window - assertEquals(-1, view.indexOf("bc-bc-")); // overshoots endIndex - } - - @Test - public void lastIndexOfString() { - SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" - assertEquals(3, view.lastIndexOf("bc")); // last "bc" -> relative 3 - assertEquals(2, view.lastIndexOf("-")); - assertEquals(-1, view.lastIndexOf("aa")); // outside the window on both ends - assertEquals(-1, view.lastIndexOf("bc-bc-")); // overshoots endIndex - } - - @Test - public void startsWithChar() { - SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" - assertTrue(view.startsWith('{')); - assertFalse(view.startsWith('c')); // 'c' is at offset 1, not the start - assertFalse(view.startsWith('x')); // backing char before beginIndex, outside the window - assertFalse(SubSequence.EMPTY.startsWith('x')); // empty window - } - - @Test - public void endsWithChar() { - SubSequence view = SubSequence.of("xx{call}xx", 2, 8); // "{call}" - assertTrue(view.endsWith('}')); - assertFalse(view.endsWith('l')); // 'l' is one before the end - assertFalse(view.endsWith('x')); // backing char at endIndex, outside the window - assertFalse(SubSequence.EMPTY.endsWith('x')); // empty window - } - - @Test - public void indexOfChar() { - SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" - assertEquals(0, view.indexOf('b')); // window-relative offset of the first occurrence - assertEquals(1, view.indexOf('c')); - assertEquals(2, view.indexOf('-')); - assertEquals(-1, view.indexOf('a')); // present in backing string but outside the window - } - - @Test - public void lastIndexOfChar() { - SubSequence view = SubSequence.of("aa-bc-bc-aa", 3, 8); // "bc-bc" - assertEquals(3, view.lastIndexOf('b')); // last 'b' -> relative 3 - assertEquals(4, view.lastIndexOf('c')); - assertEquals(2, view.lastIndexOf('-')); - assertEquals(-1, view.lastIndexOf('a')); // outside the window on both ends - } } diff --git a/internal-api/src/test/java/datadog/trace/util/TagsHelperTest.java b/internal-api/src/test/java/datadog/trace/util/TagsHelperTest.java index 2ffe305a04e..b00721680b3 100644 --- a/internal-api/src/test/java/datadog/trace/util/TagsHelperTest.java +++ b/internal-api/src/test/java/datadog/trace/util/TagsHelperTest.java @@ -8,61 +8,61 @@ public class TagsHelperTest { - @Test - public void validTagDoesNotChange() { - String keyValue = "key:value"; - String validChars = "abc..xyz1234567890-_./:"; - assertEquals(keyValue, TagsHelper.sanitize(keyValue)); - assertEquals(validChars, TagsHelper.sanitize(validChars)); - } + @Test + public void validTagDoesNotChange() { + String keyValue = "key:value"; + String validChars = "abc..xyz1234567890-_./:"; + assertEquals(keyValue, TagsHelper.sanitize(keyValue)); + assertEquals(validChars, TagsHelper.sanitize(validChars)); + } - @Test - public void nullIsSupported() { - assertNull(TagsHelper.sanitize(null)); - } + @Test + public void nullIsSupported() { + assertNull(TagsHelper.sanitize(null)); + } - @Test - public void upperCase() { - assertEquals("key:value", TagsHelper.sanitize("key:VALUE")); - } + @Test + public void upperCase() { + assertEquals("key:value", TagsHelper.sanitize("key:VALUE")); + } - @Test - public void trimSpaces() { - assertEquals("service-name", TagsHelper.sanitize(" service-name ")); - assertEquals("service_name", TagsHelper.sanitize(" service name ")); - } + @Test + public void trimSpaces() { + assertEquals("service-name", TagsHelper.sanitize(" service-name ")); + assertEquals("service_name", TagsHelper.sanitize(" service name ")); + } - @Test - public void invalidCharsConvertedToUnderscore() { - assertEquals("my_email.com", TagsHelper.sanitize("my@email.com")); - assertEquals("smile_and__", TagsHelper.sanitize("smile and \u1234")); - } + @Test + public void invalidCharsConvertedToUnderscore() { + assertEquals("my_email.com", TagsHelper.sanitize("my@email.com")); + assertEquals("smile_and__", TagsHelper.sanitize("smile and \u1234")); + } - @Test - public void tagTrimmedToMaxLength() { - StringBuilder tag = new StringBuilder(401); - for (int i = 0; i < 400; i++) { - tag.append('a'); + @Test + public void tagTrimmedToMaxLength() { + StringBuilder tag = new StringBuilder(401); + for (int i = 0; i < 400; i++) { + tag.append('a'); + } + assertEquals(200, TagsHelper.sanitize(tag.toString()).length()); + assertEquals(200, TagsHelper.sanitize(tag.toString()).getBytes(StandardCharsets.UTF_8).length); } - assertEquals(200, TagsHelper.sanitize(tag.toString()).length()); - assertEquals(200, TagsHelper.sanitize(tag.toString()).getBytes(StandardCharsets.UTF_8).length); - } - @Test - public void tagTrimmedToMaxLengthWorkWithUnicode() { - StringBuilder tag = new StringBuilder(401); - for (int i = 0; i < 400; i++) { - tag.append("\u1234"); + @Test + public void tagTrimmedToMaxLengthWorkWithUnicode() { + StringBuilder tag = new StringBuilder(401); + for (int i = 0; i < 400; i++) { + tag.append("\u1234"); + } + assertEquals(200, TagsHelper.sanitize(tag.toString()).length()); + assertEquals(200, TagsHelper.sanitize(tag.toString()).getBytes(StandardCharsets.UTF_8).length); } - assertEquals(200, TagsHelper.sanitize(tag.toString()).length()); - assertEquals(200, TagsHelper.sanitize(tag.toString()).getBytes(StandardCharsets.UTF_8).length); - } - @Test - public void concat() { - assertEquals("", TagsHelper.concatTags()); - assertEquals("", TagsHelper.concatTags("")); - assertEquals("foo:bar", TagsHelper.concatTags("foo:bar")); - assertEquals("foo0:bar0,foo1:bar1", TagsHelper.concatTags("foo0:bar0", "foo1:bar1")); - } + @Test + public void concat() { + assertEquals("", TagsHelper.concatTags()); + assertEquals("", TagsHelper.concatTags("")); + assertEquals("foo:bar", TagsHelper.concatTags("foo:bar")); + assertEquals("foo0:bar0,foo1:bar1", TagsHelper.concatTags("foo0:bar0", "foo1:bar1")); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/TempLocationManagerSecurityTest.java b/internal-api/src/test/java/datadog/trace/util/TempLocationManagerSecurityTest.java index 7906ea0e009..75394af9416 100644 --- a/internal-api/src/test/java/datadog/trace/util/TempLocationManagerSecurityTest.java +++ b/internal-api/src/test/java/datadog/trace/util/TempLocationManagerSecurityTest.java @@ -28,118 +28,113 @@ */ public class TempLocationManagerSecurityTest { - @TempDir Path baseDir; - - @BeforeEach - void requirePosix() { - assumeTrue( - FileSystems.getDefault().supportedFileAttributeViews().contains("posix"), - "Skipping POSIX-only security tests on non-POSIX file system"); - } - - @Test - void freshTempDirIsCreatedWithOwnerOnlyPermissions() throws Exception { - TempLocationManager mgr = instance(baseDir); - Path tempDir = mgr.getTempDir(); - assertNotNull(tempDir); - assertTrue0700(tempDir); - } - - @Test - void preExistingOwnerOwnedSecureDirIsAccepted() throws Exception { - // Create the full per-process tree with 0700 before constructing the manager - Path baseTempSubdir = baseDir.resolve(TempLocationManager.getBaseTempDirName()); - Files.createDirectories( - baseTempSubdir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - - String pid = PidHelper.getPid(); - Path pidDir = baseTempSubdir.resolve("pid_" + pid); - Files.createDirectories( - pidDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - - // Manager must not throw and must return the pre-existing dir - TempLocationManager mgr = assertDoesNotThrow(() -> instance(baseDir)); - Path tempDir = mgr.getTempDir(); - assertNotNull(tempDir); - } - - @ParameterizedTest - @ValueSource(strings = {"rwxrwxrwx", "rwxr-xr-x", "rwxrwx---"}) - void preExistingDirWithGroupWorldBitsIsRejected(String perms) throws Exception { - Path baseTempSubdir = baseDir.resolve(TempLocationManager.getBaseTempDirName()); - Files.createDirectories( - baseTempSubdir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(perms))); - - assertThrows(IllegalStateException.class, () -> instance(baseDir)); - } - - @ParameterizedTest - @ValueSource(strings = {"rwxrwxrwx", "rwxr-xr-x", "rwxrwx---"}) - void preExistingPidDirWithGroupWorldBitsIsRejected(String perms) throws Exception { - // Create baseTempSubdir with secure perms, but the pid subdir with insecure perms - Path baseTempSubdir = baseDir.resolve(TempLocationManager.getBaseTempDirName()); - Files.createDirectories( - baseTempSubdir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - - String pid = PidHelper.getPid(); - Path pidDir = baseTempSubdir.resolve("pid_" + pid); - Files.createDirectories( - pidDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(perms))); - - assertThrows(IllegalStateException.class, () -> instance(baseDir)); - } - - @Test - void getTempDirWithExistingSubdirGroupWorldBitsIsRejected() throws Exception { - // First build a clean tree so the manager initialises without error - TempLocationManager mgr = instance(baseDir); - Path tempDir = mgr.getTempDir(); - - // Now plant an insecure subdir - Path subDir = tempDir.resolve("sub"); - Files.createDirectories( - subDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxrwxrwx"))); - - // getTempDir(subPath, false) should detect group/world bits and throw - assertThrows( - IllegalStateException.class, () -> mgr.getTempDir(tempDir.relativize(subDir), false)); - } - - @Test - void nonPosixBehaviourIsUnchanged() { - // Guard already applied in @BeforeEach; if we reach here the FS is POSIX. - // The non-POSIX case is covered by the assumeTrue skip above. - // Nothing to assert — the test documents the requirement. - } - - private TempLocationManager instance(Path base) { - Properties props = new Properties(); - props.put(ProfilingConfig.PROFILING_TEMP_DIR, base.toString()); - props.put(ProfilingConfig.PROFILING_UPLOAD_PERIOD, "0"); - return new TempLocationManager( - datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride(props), - false, - TempLocationManager.CleanupHook.EMPTY, - SystemTimeSource.INSTANCE); - } - - private static void assertTrue0700(Path dir) throws IOException { - Set perms = Files.getPosixFilePermissions(dir); - for (PosixFilePermission bit : - new PosixFilePermission[] { - PosixFilePermission.GROUP_READ, - PosixFilePermission.GROUP_WRITE, - PosixFilePermission.GROUP_EXECUTE, - PosixFilePermission.OTHERS_READ, - PosixFilePermission.OTHERS_WRITE, - PosixFilePermission.OTHERS_EXECUTE + @TempDir + Path baseDir; + + @BeforeEach + void requirePosix() { + assumeTrue( + FileSystems.getDefault().supportedFileAttributeViews().contains("posix"), + "Skipping POSIX-only security tests on non-POSIX file system"); + } + + @Test + void freshTempDirIsCreatedWithOwnerOnlyPermissions() throws Exception { + TempLocationManager mgr = instance(baseDir); + Path tempDir = mgr.getTempDir(); + assertNotNull(tempDir); + assertTrue0700(tempDir); + } + + @Test + void preExistingOwnerOwnedSecureDirIsAccepted() throws Exception { + // Create the full per-process tree with 0700 before constructing the manager + Path baseTempSubdir = baseDir.resolve(TempLocationManager.getBaseTempDirName()); + Files.createDirectories( + baseTempSubdir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + + String pid = PidHelper.getPid(); + Path pidDir = baseTempSubdir.resolve("pid_" + pid); + Files.createDirectories( + pidDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + + // Manager must not throw and must return the pre-existing dir + TempLocationManager mgr = assertDoesNotThrow(() -> instance(baseDir)); + Path tempDir = mgr.getTempDir(); + assertNotNull(tempDir); + } + + @ParameterizedTest + @ValueSource(strings = {"rwxrwxrwx", "rwxr-xr-x", "rwxrwx---"}) + void preExistingDirWithGroupWorldBitsIsRejected(String perms) throws Exception { + Path baseTempSubdir = baseDir.resolve(TempLocationManager.getBaseTempDirName()); + Files.createDirectories( + baseTempSubdir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(perms))); + + assertThrows(IllegalStateException.class, () -> instance(baseDir)); + } + + @ParameterizedTest + @ValueSource(strings = {"rwxrwxrwx", "rwxr-xr-x", "rwxrwx---"}) + void preExistingPidDirWithGroupWorldBitsIsRejected(String perms) throws Exception { + // Create baseTempSubdir with secure perms, but the pid subdir with insecure perms + Path baseTempSubdir = baseDir.resolve(TempLocationManager.getBaseTempDirName()); + Files.createDirectories( + baseTempSubdir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + + String pid = PidHelper.getPid(); + Path pidDir = baseTempSubdir.resolve("pid_" + pid); + Files.createDirectories(pidDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(perms))); + + assertThrows(IllegalStateException.class, () -> instance(baseDir)); + } + + @Test + void getTempDirWithExistingSubdirGroupWorldBitsIsRejected() throws Exception { + // First build a clean tree so the manager initialises without error + TempLocationManager mgr = instance(baseDir); + Path tempDir = mgr.getTempDir(); + + // Now plant an insecure subdir + Path subDir = tempDir.resolve("sub"); + Files.createDirectories( + subDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxrwxrwx"))); + + // getTempDir(subPath, false) should detect group/world bits and throw + assertThrows(IllegalStateException.class, () -> mgr.getTempDir(tempDir.relativize(subDir), false)); + } + + @Test + void nonPosixBehaviourIsUnchanged() { + // Guard already applied in @BeforeEach; if we reach here the FS is POSIX. + // The non-POSIX case is covered by the assumeTrue skip above. + // Nothing to assert — the test documents the requirement. + } + + private TempLocationManager instance(Path base) { + Properties props = new Properties(); + props.put(ProfilingConfig.PROFILING_TEMP_DIR, base.toString()); + props.put(ProfilingConfig.PROFILING_UPLOAD_PERIOD, "0"); + return new TempLocationManager( + datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride(props), + false, + TempLocationManager.CleanupHook.EMPTY, + SystemTimeSource.INSTANCE); + } + + private static void assertTrue0700(Path dir) throws IOException { + Set perms = Files.getPosixFilePermissions(dir); + for (PosixFilePermission bit : new PosixFilePermission[] { + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_WRITE, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_WRITE, + PosixFilePermission.OTHERS_EXECUTE }) { - if (perms.contains(bit)) { - throw new AssertionError("Expected 0700 but found group/world bit " + bit + " on " + dir); - } + if (perms.contains(bit)) { + throw new AssertionError("Expected 0700 but found group/world bit " + bit + " on " + dir); + } + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java b/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java index af22b5fe804..113c0e9915c 100644 --- a/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java @@ -35,302 +35,279 @@ import org.junit.jupiter.params.provider.ValueSource; public class TempLocationManagerTest { - @ParameterizedTest - @ValueSource(strings = {"", "test1"}) - void testDefault(String subPath) throws Exception { - TempLocationManager tempLocationManager = TempLocationManager.getInstance(true); - Path tempDir = tempLocationManager.getTempDir(Paths.get(subPath)); - assertNotNull(tempDir); - assertTrue(Files.exists(tempDir)); - assertTrue(Files.isDirectory(tempDir)); - assertTrue(Files.isWritable(tempDir)); - assertTrue(Files.isReadable(tempDir)); - assertTrue(Files.isExecutable(tempDir)); - assertTrue(tempDir.toString().contains("pid_" + PidHelper.getPid())); - } + @ParameterizedTest + @ValueSource(strings = {"", "test1"}) + void testDefault(String subPath) throws Exception { + TempLocationManager tempLocationManager = TempLocationManager.getInstance(true); + Path tempDir = tempLocationManager.getTempDir(Paths.get(subPath)); + assertNotNull(tempDir); + assertTrue(Files.exists(tempDir)); + assertTrue(Files.isDirectory(tempDir)); + assertTrue(Files.isWritable(tempDir)); + assertTrue(Files.isReadable(tempDir)); + assertTrue(Files.isExecutable(tempDir)); + assertTrue(tempDir.toString().contains("pid_" + PidHelper.getPid())); + } - @ParameterizedTest - @ValueSource(strings = {"", "test1"}) - void testFromConfig(String subPath) throws Exception { - Path myDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - myDir.toFile().deleteOnExit(); - Properties props = new Properties(); - props.put(ProfilingConfig.PROFILING_TEMP_DIR, myDir.toString()); - ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(props); - TempLocationManager tempLocationManager = new TempLocationManager(configProvider); - Path tempDir = tempLocationManager.getTempDir(Paths.get(subPath)); - assertNotNull(tempDir); - assertTrue(tempDir.toString().startsWith(myDir.toString())); - } + @ParameterizedTest + @ValueSource(strings = {"", "test1"}) + void testFromConfig(String subPath) throws Exception { + Path myDir = Files.createTempDirectory( + "ddprof-test-", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + myDir.toFile().deleteOnExit(); + Properties props = new Properties(); + props.put(ProfilingConfig.PROFILING_TEMP_DIR, myDir.toString()); + ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(props); + TempLocationManager tempLocationManager = new TempLocationManager(configProvider); + Path tempDir = tempLocationManager.getTempDir(Paths.get(subPath)); + assertNotNull(tempDir); + assertTrue(tempDir.toString().startsWith(myDir.toString())); + } - @Test - void testFromConfigInvalid() { - Path myDir = Paths.get(System.getProperty("java.io.tmpdir"), UUID.randomUUID().toString()); - // do not create the directory - it should trigger an exception - Properties props = new Properties(); - props.put(ProfilingConfig.PROFILING_TEMP_DIR, myDir.toString()); - ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(props); - assertThrows(IllegalStateException.class, () -> new TempLocationManager(configProvider)); - } + @Test + void testFromConfigInvalid() { + Path myDir = Paths.get( + System.getProperty("java.io.tmpdir"), UUID.randomUUID().toString()); + // do not create the directory - it should trigger an exception + Properties props = new Properties(); + props.put(ProfilingConfig.PROFILING_TEMP_DIR, myDir.toString()); + ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(props); + assertThrows(IllegalStateException.class, () -> new TempLocationManager(configProvider)); + } - @Test - void testFromConfigNotWritable() throws Exception { - Path myDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("r-x------"))); - myDir.toFile().deleteOnExit(); - Properties props = new Properties(); - props.put(ProfilingConfig.PROFILING_TEMP_DIR, myDir.toString()); - ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(props); - assertThrows(IllegalStateException.class, () -> new TempLocationManager(configProvider)); - } + @Test + void testFromConfigNotWritable() throws Exception { + Path myDir = Files.createTempDirectory( + "ddprof-test-", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("r-x------"))); + myDir.toFile().deleteOnExit(); + Properties props = new Properties(); + props.put(ProfilingConfig.PROFILING_TEMP_DIR, myDir.toString()); + ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(props); + assertThrows(IllegalStateException.class, () -> new TempLocationManager(configProvider)); + } - @ParameterizedTest - @ValueSource(strings = {"", "test1"}) - void testCleanup(String subPath) throws Exception { - Path myDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - myDir.toFile().deleteOnExit(); - TempLocationManager tempLocationManager = - instance(myDir, false, TempLocationManager.CleanupHook.EMPTY); - Path tempDir = tempLocationManager.getTempDir(Paths.get(subPath)); - assertNotNull(tempDir); + @ParameterizedTest + @ValueSource(strings = {"", "test1"}) + void testCleanup(String subPath) throws Exception { + Path myDir = Files.createTempDirectory( + "ddprof-test-", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + myDir.toFile().deleteOnExit(); + TempLocationManager tempLocationManager = instance(myDir, false, TempLocationManager.CleanupHook.EMPTY); + Path tempDir = tempLocationManager.getTempDir(Paths.get(subPath)); + assertNotNull(tempDir); - // fake temp location - Path fakeTempDir = myDir.resolve(TempLocationManager.getBaseTempDirName()).resolve("pid_fake"); - Files.createDirectories(fakeTempDir); - Path tmpFile = Files.createFile(fakeTempDir.resolve("test.txt")); - tmpFile.toFile().deleteOnExit(); // make sure this is deleted at exit - fakeTempDir.toFile().deleteOnExit(); // also this one - boolean rslt = tempLocationManager.cleanup(); - // fake temp location should be deleted, real temp location should be kept - assertTrue(rslt, "cleanup should succeed"); - assertFalse(Files.exists(tmpFile), "stale pid file should be gone"); - assertFalse(Files.exists(fakeTempDir), "stale pid dir should be gone"); - assertTrue(Files.exists(tempDir)); - } + // fake temp location + Path fakeTempDir = + myDir.resolve(TempLocationManager.getBaseTempDirName()).resolve("pid_fake"); + Files.createDirectories(fakeTempDir); + Path tmpFile = Files.createFile(fakeTempDir.resolve("test.txt")); + tmpFile.toFile().deleteOnExit(); // make sure this is deleted at exit + fakeTempDir.toFile().deleteOnExit(); // also this one + boolean rslt = tempLocationManager.cleanup(); + // fake temp location should be deleted, real temp location should be kept + assertTrue(rslt, "cleanup should succeed"); + assertFalse(Files.exists(tmpFile), "stale pid file should be gone"); + assertFalse(Files.exists(fakeTempDir), "stale pid dir should be gone"); + assertTrue(Files.exists(tempDir)); + } - @ParameterizedTest - @ValueSource(strings = {"preVisitDirectory", "visitFile", "postVisitDirectory"}) - void testConcurrentCleanup(String section) throws Exception { - /* - * This test simulates concurrent cleanup. - * It utilizes a special hook to create synchronization points in the filetree walking routine, - * allowing to delete the files at various points of execution. - * The test makes sure that the cleanup is not interrupted and the file and directory being - * deleted stays deleted. - * - * Synchronization uses CountDownLatch for deterministic coordination: - * 1. Cleanup thread reaches hook point, signals via hookReached latch - * 2. Main thread deletes file, signals via proceedSignal latch - * 3. Cleanup thread continues and completes - */ - Path baseDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - baseDir.toFile().deleteOnExit(); + @ParameterizedTest + @ValueSource(strings = {"preVisitDirectory", "visitFile", "postVisitDirectory"}) + void testConcurrentCleanup(String section) throws Exception { + /* + * This test simulates concurrent cleanup. + * It utilizes a special hook to create synchronization points in the filetree walking routine, + * allowing to delete the files at various points of execution. + * The test makes sure that the cleanup is not interrupted and the file and directory being + * deleted stays deleted. + * + * Synchronization uses CountDownLatch for deterministic coordination: + * 1. Cleanup thread reaches hook point, signals via hookReached latch + * 2. Main thread deletes file, signals via proceedSignal latch + * 3. Cleanup thread continues and completes + */ + Path baseDir = Files.createTempDirectory( + "ddprof-test-", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + baseDir.toFile().deleteOnExit(); - Path fakeTempDir = - baseDir.resolve(TempLocationManager.getBaseTempDirName() + "/pid_fake/scratch"); - Files.createDirectories( - fakeTempDir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - Path fakeTempFile = fakeTempDir.resolve("libxxx.so"); - Files.createFile(fakeTempFile); + Path fakeTempDir = baseDir.resolve(TempLocationManager.getBaseTempDirName() + "/pid_fake/scratch"); + Files.createDirectories( + fakeTempDir, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path fakeTempFile = fakeTempDir.resolve("libxxx.so"); + Files.createFile(fakeTempFile); - fakeTempDir.toFile().deleteOnExit(); - fakeTempFile.toFile().deleteOnExit(); + fakeTempDir.toFile().deleteOnExit(); + fakeTempFile.toFile().deleteOnExit(); - CountDownLatch hookReached = new CountDownLatch(1); - CountDownLatch proceedSignal = new CountDownLatch(1); - AtomicBoolean withTimeout = new AtomicBoolean(false); - AtomicReference cleanupError = new AtomicReference<>(); + CountDownLatch hookReached = new CountDownLatch(1); + CountDownLatch proceedSignal = new CountDownLatch(1); + AtomicBoolean withTimeout = new AtomicBoolean(false); + AtomicReference cleanupError = new AtomicReference<>(); - TempLocationManager.CleanupHook blocker = - new TempLocationManager.CleanupHook() { - private void syncPoint(boolean timeout) { - withTimeout.compareAndSet(false, timeout); - hookReached.countDown(); - try { - if (!proceedSignal.await(30, TimeUnit.SECONDS)) { - cleanupError.set( - new AssertionError("Cleanup thread: proceed signal timeout after 30s")); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - cleanupError.set(e); + TempLocationManager.CleanupHook blocker = new TempLocationManager.CleanupHook() { + private void syncPoint(boolean timeout) { + withTimeout.compareAndSet(false, timeout); + hookReached.countDown(); + try { + if (!proceedSignal.await(30, TimeUnit.SECONDS)) { + cleanupError.set(new AssertionError("Cleanup thread: proceed signal timeout after 30s")); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + cleanupError.set(e); + } } - } - @Override - public FileVisitResult preVisitDirectory( - Path dir, BasicFileAttributes attrs, boolean timeout) throws IOException { - if (section.equals("preVisitDirectory") && dir.equals(fakeTempDir)) { - syncPoint(timeout); + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs, boolean timeout) + throws IOException { + if (section.equals("preVisitDirectory") && dir.equals(fakeTempDir)) { + syncPoint(timeout); + } + return TempLocationManager.CleanupHook.super.preVisitDirectory(dir, attrs, timeout); } - return TempLocationManager.CleanupHook.super.preVisitDirectory(dir, attrs, timeout); - } - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs, boolean timeout) - throws IOException { - if (section.equals("visitFile") && file.equals(fakeTempFile)) { - syncPoint(timeout); + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs, boolean timeout) throws IOException { + if (section.equals("visitFile") && file.equals(fakeTempFile)) { + syncPoint(timeout); + } + return TempLocationManager.CleanupHook.super.visitFile(file, attrs, timeout); } - return TempLocationManager.CleanupHook.super.visitFile(file, attrs, timeout); - } - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc, boolean timeout) - throws IOException { - if (section.equals("postVisitDirectory") && dir.equals(fakeTempDir)) { - syncPoint(timeout); + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc, boolean timeout) throws IOException { + if (section.equals("postVisitDirectory") && dir.equals(fakeTempDir)) { + syncPoint(timeout); + } + return TempLocationManager.CleanupHook.super.postVisitDirectory(dir, exc, timeout); } - return TempLocationManager.CleanupHook.super.postVisitDirectory(dir, exc, timeout); - } }; - TempLocationManager mgr = instance(baseDir, false, blocker); + TempLocationManager mgr = instance(baseDir, false, blocker); - // Run cleanup on a dedicated thread instead of the shared AgentTaskScheduler - // worker, which can be queued behind cleanup tasks scheduled by other tests. - Thread cleanupThread = new Thread(mgr::cleanup, "test-cleanup"); - cleanupThread.setDaemon(true); - cleanupThread.start(); + // Run cleanup on a dedicated thread instead of the shared AgentTaskScheduler + // worker, which can be queued behind cleanup tasks scheduled by other tests. + Thread cleanupThread = new Thread(mgr::cleanup, "test-cleanup"); + cleanupThread.setDaemon(true); + cleanupThread.start(); - try { - assertTrue( - hookReached.await(30, TimeUnit.SECONDS), - "Cleanup thread should reach hook within 30 seconds"); - // The visitor may have already deleted the file by this point; tolerate. - Files.deleteIfExists(fakeTempFile); - proceedSignal.countDown(); - cleanupThread.join(TimeUnit.SECONDS.toMillis(30)); - assertFalse(cleanupThread.isAlive(), "Cleanup should complete within 30s"); + try { + assertTrue(hookReached.await(30, TimeUnit.SECONDS), "Cleanup thread should reach hook within 30 seconds"); + // The visitor may have already deleted the file by this point; tolerate. + Files.deleteIfExists(fakeTempFile); + proceedSignal.countDown(); + cleanupThread.join(TimeUnit.SECONDS.toMillis(30)); + assertFalse(cleanupThread.isAlive(), "Cleanup should complete within 30s"); - Throwable error = cleanupError.get(); - if (error != null) { - throw new AssertionError("Cleanup thread encountered error", error); - } + Throwable error = cleanupError.get(); + if (error != null) { + throw new AssertionError("Cleanup thread encountered error", error); + } - assertFalse(withTimeout.get(), "Cleanup should not have timed out"); - assertFalse(Files.exists(fakeTempFile)); - assertFalse(Files.exists(fakeTempDir)); - } finally { - // Release the cleanup thread so it can drain on early assertion failure. - proceedSignal.countDown(); - cleanupThread.join(TimeUnit.SECONDS.toMillis(5)); + assertFalse(withTimeout.get(), "Cleanup should not have timed out"); + assertFalse(Files.exists(fakeTempFile)); + assertFalse(Files.exists(fakeTempDir)); + } finally { + // Release the cleanup thread so it can drain on early assertion failure. + proceedSignal.countDown(); + cleanupThread.join(TimeUnit.SECONDS.toMillis(5)); + } } - } - @ParameterizedTest - @MethodSource("timeoutTestArguments") - void testCleanupWithTimeout(boolean shouldSucceed, String section) throws Exception { - /* - * Test that cleanup correctly handles timeout conditions. - * Uses a ControllableTimeSource for fully deterministic behavior: - * - Success case: time is never advanced, so timeout never occurs - * - Failure case: time is advanced past timeout on first matching hook call - * - * This approach is independent of how many times hooks are invoked. - */ - long timeoutMs = 100; - ControllableTimeSource timeSource = new ControllableTimeSource(); + @ParameterizedTest + @MethodSource("timeoutTestArguments") + void testCleanupWithTimeout(boolean shouldSucceed, String section) throws Exception { + /* + * Test that cleanup correctly handles timeout conditions. + * Uses a ControllableTimeSource for fully deterministic behavior: + * - Success case: time is never advanced, so timeout never occurs + * - Failure case: time is advanced past timeout on first matching hook call + * + * This approach is independent of how many times hooks are invoked. + */ + long timeoutMs = 100; + ControllableTimeSource timeSource = new ControllableTimeSource(); - AtomicBoolean withTimeout = new AtomicBoolean(false); - AtomicBoolean timeAdvanced = new AtomicBoolean(false); - TempLocationManager.CleanupHook delayer = - new TempLocationManager.CleanupHook() { - private void maybeAdvanceTime() { - // Only advance time once, and only for failure case - if (!shouldSucceed && timeAdvanced.compareAndSet(false, true)) { - // Advance well past the timeout - timeSource.advance(TimeUnit.MILLISECONDS.toNanos(timeoutMs * 10)); + AtomicBoolean withTimeout = new AtomicBoolean(false); + AtomicBoolean timeAdvanced = new AtomicBoolean(false); + TempLocationManager.CleanupHook delayer = new TempLocationManager.CleanupHook() { + private void maybeAdvanceTime() { + // Only advance time once, and only for failure case + if (!shouldSucceed && timeAdvanced.compareAndSet(false, true)) { + // Advance well past the timeout + timeSource.advance(TimeUnit.MILLISECONDS.toNanos(timeoutMs * 10)); + } } - } - @Override - public FileVisitResult preVisitDirectory( - Path dir, BasicFileAttributes attrs, boolean timeout) throws IOException { - withTimeout.compareAndSet(false, timeout); - if (section.equals("preVisitDirectory")) { - maybeAdvanceTime(); + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs, boolean timeout) + throws IOException { + withTimeout.compareAndSet(false, timeout); + if (section.equals("preVisitDirectory")) { + maybeAdvanceTime(); + } + return TempLocationManager.CleanupHook.super.preVisitDirectory(dir, attrs, timeout); } - return TempLocationManager.CleanupHook.super.preVisitDirectory(dir, attrs, timeout); - } - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc, boolean timeout) - throws IOException { - withTimeout.compareAndSet(false, timeout); - if (section.equals("postVisitDirectory")) { - maybeAdvanceTime(); + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc, boolean timeout) throws IOException { + withTimeout.compareAndSet(false, timeout); + if (section.equals("postVisitDirectory")) { + maybeAdvanceTime(); + } + return TempLocationManager.CleanupHook.super.postVisitDirectory(dir, exc, timeout); } - return TempLocationManager.CleanupHook.super.postVisitDirectory(dir, exc, timeout); - } - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs, boolean timeout) - throws IOException { - withTimeout.compareAndSet(false, timeout); - if (section.equals("visitFile")) { - maybeAdvanceTime(); + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs, boolean timeout) throws IOException { + withTimeout.compareAndSet(false, timeout); + if (section.equals("visitFile")) { + maybeAdvanceTime(); + } + return TempLocationManager.CleanupHook.super.visitFile(file, attrs, timeout); } - return TempLocationManager.CleanupHook.super.visitFile(file, attrs, timeout); - } }; - Path baseDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - baseDir.toFile().deleteOnExit(); - TempLocationManager instance = instance(baseDir, false, delayer, timeSource); - Path mytempdir = instance.getTempDir(); - Path otherTempdir = mytempdir.getParent().resolve("pid_fake"); - Files.createDirectories(otherTempdir); - Files.createFile(mytempdir.resolve("dummy")); - Files.createFile(otherTempdir.resolve("dummy")); - // Set controllable time AFTER files are created so their modification times are in the past - timeSource.set(TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis())); - boolean rslt = instance.cleanup(timeoutMs, TimeUnit.MILLISECONDS); - assertEquals(shouldSucceed, rslt); - assertNotEquals(shouldSucceed, withTimeout.get()); // timeout = !shouldSucceed - } + Path baseDir = Files.createTempDirectory( + "ddprof-test-", PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + baseDir.toFile().deleteOnExit(); + TempLocationManager instance = instance(baseDir, false, delayer, timeSource); + Path mytempdir = instance.getTempDir(); + Path otherTempdir = mytempdir.getParent().resolve("pid_fake"); + Files.createDirectories(otherTempdir); + Files.createFile(mytempdir.resolve("dummy")); + Files.createFile(otherTempdir.resolve("dummy")); + // Set controllable time AFTER files are created so their modification times are in the past + timeSource.set(TimeUnit.MILLISECONDS.toNanos(System.currentTimeMillis())); + boolean rslt = instance.cleanup(timeoutMs, TimeUnit.MILLISECONDS); + assertEquals(shouldSucceed, rslt); + assertNotEquals(shouldSucceed, withTimeout.get()); // timeout = !shouldSucceed + } - private static Stream timeoutTestArguments() { - List argumentsList = new ArrayList<>(); - for (String intercepted : - new String[] {"preVisitDirectory", "visitFile", "postVisitDirectory"}) { - argumentsList.add(Arguments.of(true, intercepted)); - argumentsList.add(Arguments.of(false, intercepted)); + private static Stream timeoutTestArguments() { + List argumentsList = new ArrayList<>(); + for (String intercepted : new String[] {"preVisitDirectory", "visitFile", "postVisitDirectory"}) { + argumentsList.add(Arguments.of(true, intercepted)); + argumentsList.add(Arguments.of(false, intercepted)); + } + return argumentsList.stream(); } - return argumentsList.stream(); - } - private TempLocationManager instance( - Path baseDir, boolean withStartupCleanup, TempLocationManager.CleanupHook cleanupHook) { - return instance(baseDir, withStartupCleanup, cleanupHook, SystemTimeSource.INSTANCE); - } + private TempLocationManager instance( + Path baseDir, boolean withStartupCleanup, TempLocationManager.CleanupHook cleanupHook) { + return instance(baseDir, withStartupCleanup, cleanupHook, SystemTimeSource.INSTANCE); + } - private TempLocationManager instance( - Path baseDir, - boolean withStartupCleanup, - TempLocationManager.CleanupHook cleanupHook, - TimeSource timeSource) { - Properties props = new Properties(); - props.put(ProfilingConfig.PROFILING_TEMP_DIR, baseDir.toString()); - props.put( - ProfilingConfig.PROFILING_UPLOAD_PERIOD, - "0"); // to force immediate cleanup; must be a string value! + private TempLocationManager instance( + Path baseDir, + boolean withStartupCleanup, + TempLocationManager.CleanupHook cleanupHook, + TimeSource timeSource) { + Properties props = new Properties(); + props.put(ProfilingConfig.PROFILING_TEMP_DIR, baseDir.toString()); + props.put(ProfilingConfig.PROFILING_UPLOAD_PERIOD, "0"); // to force immediate cleanup; must be a string value! - return new TempLocationManager( - ConfigProvider.withPropertiesOverride(props), withStartupCleanup, cleanupHook, timeSource); - } + return new TempLocationManager( + ConfigProvider.withPropertiesOverride(props), withStartupCleanup, cleanupHook, timeSource); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/stacktrace/DefaultStackWalkerTest.java b/internal-api/src/test/java/datadog/trace/util/stacktrace/DefaultStackWalkerTest.java index f3e4944370e..e14aa3ab949 100644 --- a/internal-api/src/test/java/datadog/trace/util/stacktrace/DefaultStackWalkerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/stacktrace/DefaultStackWalkerTest.java @@ -17,46 +17,46 @@ public class DefaultStackWalkerTest { - private final StackWalker stackWalker = new DefaultStackWalker(); - - @BeforeEach - void setUp() { - assumeFalse( - JavaVirtualMachine.isOracleJDK8(), - "Oracle JDK 1.8 did not merge the fix in JDK-8058322, leading to the JVM failing to correctly " - + "extract method parameters without args, when the code is compiled on a later JDK (targeting 8). " - + "This can manifest when creating mocks."); - } - - @Test - public void defaultStackWalker_must_be_enabled() { - assertTrue(stackWalker.isEnabled()); - } - - @Test - public void walk_from_non_datadog_class() { - // When - final List stack = getStackWalkFrom(stackWalker, NOT_DD_CLASS_NAME); - // Then - assertFalse(stack.isEmpty()); - assertEquals(NOT_DD_CLASS_NAME, stack.get(0).getClassName()); - } - - @Test - public void walk_from_datadog_class() { - // When - final List stack = getStackWalkFrom(stackWalker, DD_CLASS_NAME); - // Then - assertFalse(stack.isEmpty()); - assertNotEquals(DD_CLASS_NAME, stack.get(0).getClassName()); - } - - @Test - public void walk_from_datadog_iast_class() { - // When - final List stack = getStackWalkFrom(stackWalker, DD_IAST_CLASS_NAME); - // Then - assertFalse(stack.isEmpty()); - assertNotEquals(DD_IAST_CLASS_NAME, stack.get(0).getClassName()); - } + private final StackWalker stackWalker = new DefaultStackWalker(); + + @BeforeEach + void setUp() { + assumeFalse( + JavaVirtualMachine.isOracleJDK8(), + "Oracle JDK 1.8 did not merge the fix in JDK-8058322, leading to the JVM failing to correctly " + + "extract method parameters without args, when the code is compiled on a later JDK (targeting 8). " + + "This can manifest when creating mocks."); + } + + @Test + public void defaultStackWalker_must_be_enabled() { + assertTrue(stackWalker.isEnabled()); + } + + @Test + public void walk_from_non_datadog_class() { + // When + final List stack = getStackWalkFrom(stackWalker, NOT_DD_CLASS_NAME); + // Then + assertFalse(stack.isEmpty()); + assertEquals(NOT_DD_CLASS_NAME, stack.get(0).getClassName()); + } + + @Test + public void walk_from_datadog_class() { + // When + final List stack = getStackWalkFrom(stackWalker, DD_CLASS_NAME); + // Then + assertFalse(stack.isEmpty()); + assertNotEquals(DD_CLASS_NAME, stack.get(0).getClassName()); + } + + @Test + public void walk_from_datadog_iast_class() { + // When + final List stack = getStackWalkFrom(stackWalker, DD_IAST_CLASS_NAME); + // Then + assertFalse(stack.isEmpty()); + assertNotEquals(DD_IAST_CLASS_NAME, stack.get(0).getClassName()); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackTraceIteratorTest.java b/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackTraceIteratorTest.java index 2fa440fd250..38b877c72a0 100644 --- a/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackTraceIteratorTest.java +++ b/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackTraceIteratorTest.java @@ -12,37 +12,36 @@ public class HotSpotStackTraceIteratorTest { - @BeforeAll - public static void setUp() { - assumeTrue(isRunningJDK8WithHotSpot()); - } - - @Test - public void hasNext() { - assertTrue(getTestIterator().hasNext()); - } - - @Test - public void next() { - assertNotNull(getTestIterator().next()); - } - - @Test - public void next_throws_NoSuchElementException_when_there_are_no_more_elements() { - HotSpotStackTraceIterator iterator = getTestIterator(); - while (iterator.hasNext()) { - iterator.next(); + @BeforeAll + public static void setUp() { + assumeTrue(isRunningJDK8WithHotSpot()); + } + + @Test + public void hasNext() { + assertTrue(getTestIterator().hasNext()); + } + + @Test + public void next() { + assertNotNull(getTestIterator().next()); + } + + @Test + public void next_throws_NoSuchElementException_when_there_are_no_more_elements() { + HotSpotStackTraceIterator iterator = getTestIterator(); + while (iterator.hasNext()) { + iterator.next(); + } + Assertions.assertThrows(NoSuchElementException.class, iterator::next); + } + + @Test + public void remove_throws_UnsupportedOperationException() { + Assertions.assertThrows(UnsupportedOperationException.class, getTestIterator()::remove); + } + + private HotSpotStackTraceIterator getTestIterator() { + return new HotSpotStackTraceIterator(new Throwable(), sun.misc.SharedSecrets.getJavaLangAccess()); } - Assertions.assertThrows(NoSuchElementException.class, iterator::next); - } - - @Test - public void remove_throws_UnsupportedOperationException() { - Assertions.assertThrows(UnsupportedOperationException.class, getTestIterator()::remove); - } - - private HotSpotStackTraceIterator getTestIterator() { - return new HotSpotStackTraceIterator( - new Throwable(), sun.misc.SharedSecrets.getJavaLangAccess()); - } } diff --git a/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackWalkerTest.java b/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackWalkerTest.java index 3488a320edb..8a94f681c52 100644 --- a/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackWalkerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/stacktrace/HotSpotStackWalkerTest.java @@ -24,82 +24,77 @@ public class HotSpotStackWalkerTest { - private final StackWalker stackWalker = new HotSpotStackWalker(); + private final StackWalker stackWalker = new HotSpotStackWalker(); - @BeforeAll - public static void setUp() { - assumeTrue(isRunningJDK8WithHotSpot()); - assumeFalse( - JavaVirtualMachine.isOracleJDK8(), - "Oracle JDK 1.8 did not merge the fix in JDK-8058322, leading to the JVM failing to correctly " - + "extract method parameters without args, when the code is compiled on a later JDK (targeting 8). " - + "This can manifest when creating mocks."); - } + @BeforeAll + public static void setUp() { + assumeTrue(isRunningJDK8WithHotSpot()); + assumeFalse( + JavaVirtualMachine.isOracleJDK8(), + "Oracle JDK 1.8 did not merge the fix in JDK-8058322, leading to the JVM failing to correctly " + + "extract method parameters without args, when the code is compiled on a later JDK (targeting 8). " + + "This can manifest when creating mocks."); + } - @Test - public void hotSpotStackWalker_must_be_enabled_for_JDK8_with_hotspot() { - assertTrue(stackWalker.isEnabled()); - } + @Test + public void hotSpotStackWalker_must_be_enabled_for_JDK8_with_hotspot() { + assertTrue(stackWalker.isEnabled()); + } - @Test - public void walk_from_non_datadog_class() { - // When - final List stack = getStackWalkFrom(stackWalker, NOT_DD_CLASS_NAME); - // Then - assertFalse(stack.isEmpty()); - assertEquals(NOT_DD_CLASS_NAME, stack.get(0).getClassName()); - } + @Test + public void walk_from_non_datadog_class() { + // When + final List stack = getStackWalkFrom(stackWalker, NOT_DD_CLASS_NAME); + // Then + assertFalse(stack.isEmpty()); + assertEquals(NOT_DD_CLASS_NAME, stack.get(0).getClassName()); + } - @Test - public void walk_from_datadog_class() { - // When - final List stack = getStackWalkFrom(stackWalker, DD_CLASS_NAME); - // Then - assertFalse(stack.isEmpty()); - assertNotEquals(DD_CLASS_NAME, stack.get(0).getClassName()); - } + @Test + public void walk_from_datadog_class() { + // When + final List stack = getStackWalkFrom(stackWalker, DD_CLASS_NAME); + // Then + assertFalse(stack.isEmpty()); + assertNotEquals(DD_CLASS_NAME, stack.get(0).getClassName()); + } - @Test - public void walk_from_datadog_iast_class() { - // When - final List stack = getStackWalkFrom(stackWalker, DD_IAST_CLASS_NAME); - // Then - assertFalse(stack.isEmpty()); - assertNotEquals(DD_IAST_CLASS_NAME, stack.get(0).getClassName()); - } + @Test + public void walk_from_datadog_iast_class() { + // When + final List stack = getStackWalkFrom(stackWalker, DD_IAST_CLASS_NAME); + // Then + assertFalse(stack.isEmpty()); + assertNotEquals(DD_IAST_CLASS_NAME, stack.get(0).getClassName()); + } - @Test - public void is_not_enabled_when_access_throws_exception() { - // When - sun.misc.JavaLangAccess mockedAccess = mock(sun.misc.JavaLangAccess.class); - when(mockedAccess.getStackTraceElement(any(Throwable.class), eq(0))) - .thenThrow(new RuntimeException()); - HotSpotStackWalker hotSpotStackWalker = new HotSpotStackWalker(); - hotSpotStackWalker.access = mockedAccess; - // Then - assertFalse(hotSpotStackWalker.isEnabled()); - } + @Test + public void is_not_enabled_when_access_throws_exception() { + // When + sun.misc.JavaLangAccess mockedAccess = mock(sun.misc.JavaLangAccess.class); + when(mockedAccess.getStackTraceElement(any(Throwable.class), eq(0))).thenThrow(new RuntimeException()); + HotSpotStackWalker hotSpotStackWalker = new HotSpotStackWalker(); + hotSpotStackWalker.access = mockedAccess; + // Then + assertFalse(hotSpotStackWalker.isEnabled()); + } - @Test - public void is_not_enabled_when_access_is_null() { - // When - HotSpotStackWalker hotSpotStackWalker = new HotSpotStackWalker(); - hotSpotStackWalker.access = null; - // Then - assertFalse(hotSpotStackWalker.isEnabled()); - } + @Test + public void is_not_enabled_when_access_is_null() { + // When + HotSpotStackWalker hotSpotStackWalker = new HotSpotStackWalker(); + hotSpotStackWalker.access = null; + // Then + assertFalse(hotSpotStackWalker.isEnabled()); + } - @Test - public void iterable_from_HotSpotStackWalker_doGetStack_is_not_in_the_filtered_stack() { - // When - List list = stackWalker.walk(s -> s.collect(Collectors.toList())); - // Then - assertFalse( - list.stream() - .anyMatch( - stackTraceElement -> - stackTraceElement - .toString() - .equals("java.lang.Iterable.spliterator(Iterable.java:101)"))); - } + @Test + public void iterable_from_HotSpotStackWalker_doGetStack_is_not_in_the_filtered_stack() { + // When + List list = stackWalker.walk(s -> s.collect(Collectors.toList())); + // Then + assertFalse(list.stream() + .anyMatch(stackTraceElement -> + stackTraceElement.toString().equals("java.lang.Iterable.spliterator(Iterable.java:101)"))); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/stacktrace/StackUtilsTest.java b/internal-api/src/test/java/datadog/trace/util/stacktrace/StackUtilsTest.java index 2de4d2d0996..3c6c612a753 100644 --- a/internal-api/src/test/java/datadog/trace/util/stacktrace/StackUtilsTest.java +++ b/internal-api/src/test/java/datadog/trace/util/stacktrace/StackUtilsTest.java @@ -26,159 +26,150 @@ public class StackUtilsTest { - @Test - public void test_identity_function() { - final Throwable source = new RuntimeException(); - final StackTraceElement[] expected = source.getStackTrace(); - - final Throwable updated = StackUtils.update(withStack(expected), Function.identity()); - assertArrayEquals(expected, updated.getStackTrace()); - } - - @Test - public void test_filter_all_datadog() { - final StackTraceElement[] stack = - new StackTraceElement[] { - stack().className("org.junit.jupiter.api.Test").build(), - stack().className("datadog.trace.util.stacktrace.StackUtilsTest").build(), - stack().className("java.util.function.Function").build(), - stack().className("datadog.trace.util.stacktrace.StackUtils").build(), - stack().className("org.junit.jupiter.api.Assertions").build() + @Test + public void test_identity_function() { + final Throwable source = new RuntimeException(); + final StackTraceElement[] expected = source.getStackTrace(); + + final Throwable updated = StackUtils.update(withStack(expected), Function.identity()); + assertArrayEquals(expected, updated.getStackTrace()); + } + + @Test + public void test_filter_all_datadog() { + final StackTraceElement[] stack = new StackTraceElement[] { + stack().className("org.junit.jupiter.api.Test").build(), + stack().className("datadog.trace.util.stacktrace.StackUtilsTest").build(), + stack().className("java.util.function.Function").build(), + stack().className("datadog.trace.util.stacktrace.StackUtils").build(), + stack().className("org.junit.jupiter.api.Assertions").build() }; - final StackTraceElement[] expected = new StackTraceElement[] {stack[0], stack[2], stack[4]}; - - final Throwable filtered = - StackUtils.filter( - withStack(stack), item -> !item.getClassName().startsWith("datadog.trace")); - assertArrayEquals(expected, filtered.getStackTrace()); - - final Throwable filtered2 = StackUtils.filterDatadog(withStack(stack)); - assertArrayEquals(expected, filtered2.getStackTrace()); - } - - @Test - public void test_stack_filters() { - final StackTraceElement[] stack = - new StackTraceElement[] { - stack().className("org.junit.jupiter.api.Test").build(), - stack().className("datadog.trace.util.stacktrace.StackUtilsTest").build(), - stack().className("java.util.function.Function").build(), - stack().className("datadog.trace.util.stacktrace.StackUtils").build(), - stack().className("org.junit.jupiter.api.Assertions").build() + final StackTraceElement[] expected = new StackTraceElement[] {stack[0], stack[2], stack[4]}; + + final Throwable filtered = + StackUtils.filter(withStack(stack), item -> !item.getClassName().startsWith("datadog.trace")); + assertArrayEquals(expected, filtered.getStackTrace()); + + final Throwable filtered2 = StackUtils.filterDatadog(withStack(stack)); + assertArrayEquals(expected, filtered2.getStackTrace()); + } + + @Test + public void test_stack_filters() { + final StackTraceElement[] stack = new StackTraceElement[] { + stack().className("org.junit.jupiter.api.Test").build(), + stack().className("datadog.trace.util.stacktrace.StackUtilsTest").build(), + stack().className("java.util.function.Function").build(), + stack().className("datadog.trace.util.stacktrace.StackUtils").build(), + stack().className("org.junit.jupiter.api.Assertions").build() }; - final StackTraceElement[] expected = - new StackTraceElement[] {stack[0], stack[2], stack[3], stack[4]}; - - final Throwable filtered = - StackUtils.filterFirst( - withStack(stack), item -> !item.getClassName().startsWith("datadog.trace")); - assertArrayEquals(expected, filtered.getStackTrace()); - - final Throwable filtered2 = StackUtils.filterFirstDatadog(withStack(stack)); - assertArrayEquals(expected, filtered2.getStackTrace()); - } - - @Test - public void test_filter_until() { - final StackTraceElement[] stack = - new StackTraceElement[] { - stack().className("org.junit.jupiter.api.Test").build(), - stack().className("datadog.trace.util.stacktrace.StackUtilsTest").build(), - stack().className("java.util.function.Function").build(), - stack().className("datadog.trace.util.stacktrace.StackUtils").build(), - stack().className("org.junit.jupiter.api.Assertions").build() + final StackTraceElement[] expected = new StackTraceElement[] {stack[0], stack[2], stack[3], stack[4]}; + + final Throwable filtered = StackUtils.filterFirst( + withStack(stack), item -> !item.getClassName().startsWith("datadog.trace")); + assertArrayEquals(expected, filtered.getStackTrace()); + + final Throwable filtered2 = StackUtils.filterFirstDatadog(withStack(stack)); + assertArrayEquals(expected, filtered2.getStackTrace()); + } + + @Test + public void test_filter_until() { + final StackTraceElement[] stack = new StackTraceElement[] { + stack().className("org.junit.jupiter.api.Test").build(), + stack().className("datadog.trace.util.stacktrace.StackUtilsTest").build(), + stack().className("java.util.function.Function").build(), + stack().className("datadog.trace.util.stacktrace.StackUtils").build(), + stack().className("org.junit.jupiter.api.Assertions").build() }; - final StackTraceElement[] expected = new StackTraceElement[] {stack[4]}; - final Throwable removed = - StackUtils.filterUntil( - withStack(stack), - entry -> entry.getClassName().equals("datadog.trace.util.stacktrace.StackUtils")); - assertArrayEquals(expected, removed.getStackTrace()); - - final Throwable noRemoval = StackUtils.filterUntil(withStack(stack), entry -> false); - assertArrayEquals(stack, noRemoval.getStackTrace()); - } - - private static Stream test_generateUserCodeStackTrace_Params() { - return Stream.of( - Arguments.of((Predicate) stack -> true, false), - Arguments.of( - (Predicate) stack -> !stack.getClassName().startsWith("org.junit"), - true)); - } - - @ParameterizedTest(name = "[{index}]") - @MethodSource("test_generateUserCodeStackTrace_Params") - public void test_generateUserCodeStackTrace( - final Predicate filter, final boolean expected) { - List userCodeStack = StackUtils.generateUserCodeStackTrace(filter); - assertNotNull(userCodeStack); - int junitFramesCounter = 0; - for (StackTraceFrame frame : userCodeStack) { - if (frame.getClass_name() != null && frame.getClass_name().startsWith("org.junit")) { - junitFramesCounter++; - } + final StackTraceElement[] expected = new StackTraceElement[] {stack[4]}; + final Throwable removed = StackUtils.filterUntil( + withStack(stack), entry -> entry.getClassName().equals("datadog.trace.util.stacktrace.StackUtils")); + assertArrayEquals(expected, removed.getStackTrace()); + + final Throwable noRemoval = StackUtils.filterUntil(withStack(stack), entry -> false); + assertArrayEquals(stack, noRemoval.getStackTrace()); } - if (expected) { - assertEquals(0, junitFramesCounter); - } else { - assertTrue(junitFramesCounter > 0); + + private static Stream test_generateUserCodeStackTrace_Params() { + return Stream.of( + Arguments.of((Predicate) stack -> true, false), + Arguments.of( + (Predicate) + stack -> !stack.getClassName().startsWith("org.junit"), + true)); } - } - - @Test - public void addStacktraceEventsToAvailableMetaStruct() { - final RequestContext reqCtx = mock(RequestContext.class); - final Map> batch = new HashMap<>(); - when(reqCtx.getOrCreateMetaStructTop(eq(META_STRUCT_KEY), any())).thenReturn(batch); - final String productTest = "test"; - final StackTraceEvent event = new StackTraceEvent(new ArrayList<>(0), "java", "id", "message"); - StackUtils.addStacktraceEventsToMetaStruct( - reqCtx, productTest, Collections.singletonList(event)); - assertTrue(batch.containsKey(productTest)); - assertTrue(batch.get(productTest).contains(event)); - } - - private static Throwable withStack(final StackTraceElement... stack) { - final Exception exception = new RuntimeException(); - exception.setStackTrace(stack); - return exception; - } - - private static StackTraceBuilder stack() { - return new StackTraceBuilder(); - } - - private static class StackTraceBuilder { - - private String className = "mock"; - private String methodName = "mock"; - private String fileName = "mock"; - private int lineNumber; - - public StackTraceBuilder className(final String className) { - this.className = className; - return this; + + @ParameterizedTest(name = "[{index}]") + @MethodSource("test_generateUserCodeStackTrace_Params") + public void test_generateUserCodeStackTrace(final Predicate filter, final boolean expected) { + List userCodeStack = StackUtils.generateUserCodeStackTrace(filter); + assertNotNull(userCodeStack); + int junitFramesCounter = 0; + for (StackTraceFrame frame : userCodeStack) { + if (frame.getClass_name() != null && frame.getClass_name().startsWith("org.junit")) { + junitFramesCounter++; + } + } + if (expected) { + assertEquals(0, junitFramesCounter); + } else { + assertTrue(junitFramesCounter > 0); + } } - public StackTraceBuilder methodName(final String methodName) { - this.methodName = methodName; - return this; + @Test + public void addStacktraceEventsToAvailableMetaStruct() { + final RequestContext reqCtx = mock(RequestContext.class); + final Map> batch = new HashMap<>(); + when(reqCtx.getOrCreateMetaStructTop(eq(META_STRUCT_KEY), any())).thenReturn(batch); + final String productTest = "test"; + final StackTraceEvent event = new StackTraceEvent(new ArrayList<>(0), "java", "id", "message"); + StackUtils.addStacktraceEventsToMetaStruct(reqCtx, productTest, Collections.singletonList(event)); + assertTrue(batch.containsKey(productTest)); + assertTrue(batch.get(productTest).contains(event)); } - public StackTraceBuilder fileName(final String fileName) { - this.fileName = fileName; - return this; + private static Throwable withStack(final StackTraceElement... stack) { + final Exception exception = new RuntimeException(); + exception.setStackTrace(stack); + return exception; } - public StackTraceBuilder lineNumber(final int lineNumber) { - this.lineNumber = lineNumber; - return this; + private static StackTraceBuilder stack() { + return new StackTraceBuilder(); } - public StackTraceElement build() { - return new StackTraceElement(className, methodName, fileName, lineNumber); + private static class StackTraceBuilder { + + private String className = "mock"; + private String methodName = "mock"; + private String fileName = "mock"; + private int lineNumber; + + public StackTraceBuilder className(final String className) { + this.className = className; + return this; + } + + public StackTraceBuilder methodName(final String methodName) { + this.methodName = methodName; + return this; + } + + public StackTraceBuilder fileName(final String fileName) { + this.fileName = fileName; + return this; + } + + public StackTraceBuilder lineNumber(final int lineNumber) { + this.lineNumber = lineNumber; + return this; + } + + public StackTraceElement build() { + return new StackTraceElement(className, methodName, fileName, lineNumber); + } } - } } diff --git a/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerFactoryTest.java b/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerFactoryTest.java index 87c7c3bbf2c..bb62f976d03 100644 --- a/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerFactoryTest.java +++ b/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerFactoryTest.java @@ -6,11 +6,11 @@ public class StackWalkerFactoryTest { - @Test - public void stackWalker_instance_must_be_enabled() { - // When - StackWalker stackWalker = StackWalkerFactory.INSTANCE; - // Then - assertTrue(stackWalker.isEnabled()); - } + @Test + public void stackWalker_instance_must_be_enabled() { + // When + StackWalker stackWalker = StackWalkerFactory.INSTANCE; + // Then + assertTrue(stackWalker.isEnabled()); + } } diff --git a/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerTestUtil.java b/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerTestUtil.java index 3ca823aa2d3..971085b108b 100644 --- a/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerTestUtil.java +++ b/internal-api/src/test/java/datadog/trace/util/stacktrace/StackWalkerTestUtil.java @@ -10,31 +10,28 @@ public class StackWalkerTestUtil { - public static final String DD_CLASS_NAME = "datadog.trace.SynthTestClass"; + public static final String DD_CLASS_NAME = "datadog.trace.SynthTestClass"; - public static final String DD_IAST_CLASS_NAME = "com.datadog.iast.SynthTestClass"; + public static final String DD_IAST_CLASS_NAME = "com.datadog.iast.SynthTestClass"; - public static final String NOT_DD_CLASS_NAME = "external.test.SynthTestClass"; + public static final String NOT_DD_CLASS_NAME = "external.test.SynthTestClass"; - private StackWalkerTestUtil() {} + private StackWalkerTestUtil() {} - public static boolean isRunningJDK8WithHotSpot() { - try { - JavaLangAccess access = SharedSecrets.getJavaLangAccess(); - return JavaVirtualMachine.isJavaVersion(8) && access != null; - } catch (Throwable throwable) { - return false; + public static boolean isRunningJDK8WithHotSpot() { + try { + JavaLangAccess access = SharedSecrets.getJavaLangAccess(); + return JavaVirtualMachine.isJavaVersion(8) && access != null; + } catch (Throwable throwable) { + return false; + } } - } - - public static List getStackWalkFrom( - final StackWalker walker, final String clazz) { - final AtomicReference> result = new AtomicReference<>(); - AnyStackRunner.callWithinStack( - clazz, - () -> { - result.set(walker.walk(s -> s.collect(Collectors.toList()))); + + public static List getStackWalkFrom(final StackWalker walker, final String clazz) { + final AtomicReference> result = new AtomicReference<>(); + AnyStackRunner.callWithinStack(clazz, () -> { + result.set(walker.walk(s -> s.collect(Collectors.toList()))); }); - return result.get(); - } + return result.get(); + } } diff --git a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java index 7f105d00213..92830b3bf60 100644 --- a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java +++ b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java @@ -14,200 +14,192 @@ public class FeatureFlaggingSystem { - @FunctionalInterface - interface SystemInitializer { - void initialize(SharedCommunicationObjects sco, Config config); - } - - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlaggingSystem.class); - - private static volatile ConfigurationSourceService CONFIG_SERVICE; - private static volatile ExposureWriter EXPOSURE_WRITER; - private static volatile FlagEvaluationWriter FLAG_EVAL_WRITER; - private static volatile SpanEnrichmentWriter SPAN_ENRICHMENT_WRITER; - private static volatile FeatureFlaggingGateway.ActivationListener ACTIVATION_LISTENER; - private static volatile boolean STARTED; - - private FeatureFlaggingSystem() {} - - public static void start(final SharedCommunicationObjects sco) { - start(sco, FeatureFlaggingSystem::initializeSystem); - } - - static synchronized void start( - final SharedCommunicationObjects sco, final SystemInitializer systemInitializer) { - if (STARTED) { - LOGGER.debug("Feature Flagging system already started"); - return; + @FunctionalInterface + interface SystemInitializer { + void initialize(SharedCommunicationObjects sco, Config config); } - LOGGER.debug("Feature Flagging system starting"); - final Config config = Config.get(); - STARTED = true; - if (!config.isFeatureFlaggingProviderEnabled()) { - LOGGER.debug("Feature Flagging system disabled"); - return; + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlaggingSystem.class); + + private static volatile ConfigurationSourceService CONFIG_SERVICE; + private static volatile ExposureWriter EXPOSURE_WRITER; + private static volatile FlagEvaluationWriter FLAG_EVAL_WRITER; + private static volatile SpanEnrichmentWriter SPAN_ENRICHMENT_WRITER; + private static volatile FeatureFlaggingGateway.ActivationListener ACTIVATION_LISTENER; + private static volatile boolean STARTED; + + private FeatureFlaggingSystem() {} + + public static void start(final SharedCommunicationObjects sco) { + start(sco, FeatureFlaggingSystem::initializeSystem); } - if (CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - final FeatureFlaggingGateway.ActivationListener activationListener = - () -> activateAgentless(sco, config, systemInitializer); - ACTIVATION_LISTENER = activationListener; - FeatureFlaggingGateway.addActivationListener(activationListener); - LOGGER.debug("Feature Flagging system awaiting application provider activation"); - return; + static synchronized void start(final SharedCommunicationObjects sco, final SystemInitializer systemInitializer) { + if (STARTED) { + LOGGER.debug("Feature Flagging system already started"); + return; + } + LOGGER.debug("Feature Flagging system starting"); + final Config config = Config.get(); + STARTED = true; + + if (!config.isFeatureFlaggingProviderEnabled()) { + LOGGER.debug("Feature Flagging system disabled"); + return; + } + + if (CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { + final FeatureFlaggingGateway.ActivationListener activationListener = + () -> activateAgentless(sco, config, systemInitializer); + ACTIVATION_LISTENER = activationListener; + FeatureFlaggingGateway.addActivationListener(activationListener); + LOGGER.debug("Feature Flagging system awaiting application provider activation"); + return; + } + + initializeOrRollBack(sco, config, systemInitializer); } - initializeOrRollBack(sco, config, systemInitializer); - } + private static synchronized void activateAgentless( + final SharedCommunicationObjects sco, final Config config, final SystemInitializer systemInitializer) { + final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; + if (!STARTED || activationListener == null) { + return; + } + ACTIVATION_LISTENER = null; + FeatureFlaggingGateway.removeActivationListener(activationListener); + initializeOrRollBack(sco, config, systemInitializer); + } - private static synchronized void activateAgentless( - final SharedCommunicationObjects sco, - final Config config, - final SystemInitializer systemInitializer) { - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; - if (!STARTED || activationListener == null) { - return; + // Any failure leaves the subsystem fully stopped: stop() releases whatever initializeSystem + // managed to publish before it threw, so a later start() begins from a clean state. + private static void initializeOrRollBack( + final SharedCommunicationObjects sco, final Config config, final SystemInitializer systemInitializer) { + try { + systemInitializer.initialize(sco, config); + } catch (final RuntimeException | Error e) { + stop(); + throw e; + } } - ACTIVATION_LISTENER = null; - FeatureFlaggingGateway.removeActivationListener(activationListener); - initializeOrRollBack(sco, config, systemInitializer); - } - - // Any failure leaves the subsystem fully stopped: stop() releases whatever initializeSystem - // managed to publish before it threw, so a later start() begins from a clean state. - private static void initializeOrRollBack( - final SharedCommunicationObjects sco, - final Config config, - final SystemInitializer systemInitializer) { - try { - systemInitializer.initialize(sco, config); - } catch (final RuntimeException | Error e) { - stop(); - throw e; + + private static void initializeSystem(final SharedCommunicationObjects sco, final Config config) { + final ConfigurationSourceService configService = createConfigurationSourceService(sco, config); + if (configService == null) { + LOGGER.debug("Feature Flagging system disabled by unsupported configuration source"); + return; + } + final ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config); + initialize(configService, exposureWriter); + + final boolean evalCountsEnabled = + config.configProvider().getBoolean(FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, true); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(evalCountsEnabled); + if (evalCountsEnabled) { + final FlagEvaluationWriterImpl evalWriter = new FlagEvaluationWriterImpl(sco, config); + // Publish before start() so a failed start is still reachable by the rollback in stop(). + FLAG_EVAL_WRITER = evalWriter; + evalWriter.start(); + LOGGER.debug("Flag evaluation EVP writer started"); + } else { + FeatureFlaggingGateway.setFlagEvalWriter(null); + LOGGER.debug( + "Flag evaluation EVP writer disabled ({}=false)", + FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED); + } + + // APM span enrichment: agent-side listener for flag-evaluation seam events. Uses the process- + // wide singleton so a subsystem restart reuses the one already-registered trace interceptor + // (which the tracer cannot remove) instead of registering a second, rejected one. Cheap: it + // only accumulates once the provider's gate-on capture hook dispatches events, and registers + // its interceptor lazily on the first such event. + SPAN_ENRICHMENT_WRITER = SpanEnrichmentWriter.getInstance(); + SPAN_ENRICHMENT_WRITER.init(); + + LOGGER.debug("Feature Flagging system started"); } - } - private static void initializeSystem(final SharedCommunicationObjects sco, final Config config) { - final ConfigurationSourceService configService = createConfigurationSourceService(sco, config); - if (configService == null) { - LOGGER.debug("Feature Flagging system disabled by unsupported configuration source"); - return; + static void initialize(final ConfigurationSourceService configService, final ExposureWriter exposureWriter) { + try { + if (configService != null) { + configService.init(); + } + exposureWriter.init(); + CONFIG_SERVICE = configService; + EXPOSURE_WRITER = exposureWriter; + } catch (final RuntimeException | Error e) { + try { + exposureWriter.close(); + } finally { + if (configService != null) { + configService.close(); + } + } + throw e; + } } - final ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config); - initialize(configService, exposureWriter); - - final boolean evalCountsEnabled = - config - .configProvider() - .getBoolean(FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, true); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(evalCountsEnabled); - if (evalCountsEnabled) { - final FlagEvaluationWriterImpl evalWriter = new FlagEvaluationWriterImpl(sco, config); - // Publish before start() so a failed start is still reachable by the rollback in stop(). - FLAG_EVAL_WRITER = evalWriter; - evalWriter.start(); - LOGGER.debug("Flag evaluation EVP writer started"); - } else { - FeatureFlaggingGateway.setFlagEvalWriter(null); - LOGGER.debug( - "Flag evaluation EVP writer disabled ({}=false)", - FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED); + + static ConfigurationSourceService createConfigurationSourceService( + final SharedCommunicationObjects sco, final Config config) { + final String configurationSource = config.getFeatureFlaggingConfigurationSource(); + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(configurationSource)) { + if (!config.isRemoteConfigEnabled()) { + throw new IllegalStateException("Feature Flagging system started without RC"); + } + return new RemoteConfigServiceImpl(sco, config); + } + if (CONFIGURATION_SOURCE_AGENTLESS.equals(configurationSource)) { + return new AgentlessConfigurationSource(config); + } + return null; } - // APM span enrichment: agent-side listener for flag-evaluation seam events. Uses the process- - // wide singleton so a subsystem restart reuses the one already-registered trace interceptor - // (which the tracer cannot remove) instead of registering a second, rejected one. Cheap: it - // only accumulates once the provider's gate-on capture hook dispatches events, and registers - // its interceptor lazily on the first such event. - SPAN_ENRICHMENT_WRITER = SpanEnrichmentWriter.getInstance(); - SPAN_ENRICHMENT_WRITER.init(); - - LOGGER.debug("Feature Flagging system started"); - } - - static void initialize( - final ConfigurationSourceService configService, final ExposureWriter exposureWriter) { - try { - if (configService != null) { - configService.init(); - } - exposureWriter.init(); - CONFIG_SERVICE = configService; - EXPOSURE_WRITER = exposureWriter; - } catch (final RuntimeException | Error e) { - try { - exposureWriter.close(); - } finally { - if (configService != null) { - configService.close(); + @SuppressFBWarnings( + value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", + justification = + "Agent-internal class; Class object does not escape to app code and lock only guards the subsystem lifecycle.") + public static synchronized void stop() { + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); + FeatureFlaggingGateway.setFlagEvalWriter(null); + final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; + final FlagEvaluationWriter flagEvalWriter = FLAG_EVAL_WRITER; + final SpanEnrichmentWriter spanEnrichmentWriter = SPAN_ENRICHMENT_WRITER; + final ExposureWriter exposureWriter = EXPOSURE_WRITER; + final ConfigurationSourceService configService = CONFIG_SERVICE; + STARTED = false; + ACTIVATION_LISTENER = null; + FLAG_EVAL_WRITER = null; + SPAN_ENRICHMENT_WRITER = null; + EXPOSURE_WRITER = null; + CONFIG_SERVICE = null; + if (activationListener != null) { + FeatureFlaggingGateway.removeActivationListener(activationListener); } - } - throw e; + closeQuietly(flagEvalWriter); + closeQuietly(spanEnrichmentWriter); + closeQuietly(exposureWriter); + closeQuietly(configService); + LOGGER.debug("Feature Flagging system stopped"); } - } - - static ConfigurationSourceService createConfigurationSourceService( - final SharedCommunicationObjects sco, final Config config) { - final String configurationSource = config.getFeatureFlaggingConfigurationSource(); - if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(configurationSource)) { - if (!config.isRemoteConfigEnabled()) { - throw new IllegalStateException("Feature Flagging system started without RC"); - } - return new RemoteConfigServiceImpl(sco, config); + + static boolean isAwaitingApplicationActivation() { + return ACTIVATION_LISTENER != null; } - if (CONFIGURATION_SOURCE_AGENTLESS.equals(configurationSource)) { - return new AgentlessConfigurationSource(config); + + static boolean isExposureWriterStarted() { + return EXPOSURE_WRITER != null; } - return null; - } - - @SuppressFBWarnings( - value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", - justification = - "Agent-internal class; Class object does not escape to app code and lock only guards the subsystem lifecycle.") - public static synchronized void stop() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; - final FlagEvaluationWriter flagEvalWriter = FLAG_EVAL_WRITER; - final SpanEnrichmentWriter spanEnrichmentWriter = SPAN_ENRICHMENT_WRITER; - final ExposureWriter exposureWriter = EXPOSURE_WRITER; - final ConfigurationSourceService configService = CONFIG_SERVICE; - STARTED = false; - ACTIVATION_LISTENER = null; - FLAG_EVAL_WRITER = null; - SPAN_ENRICHMENT_WRITER = null; - EXPOSURE_WRITER = null; - CONFIG_SERVICE = null; - if (activationListener != null) { - FeatureFlaggingGateway.removeActivationListener(activationListener); + + static boolean isConfigurationSourceStarted() { + return CONFIG_SERVICE != null; } - closeQuietly(flagEvalWriter); - closeQuietly(spanEnrichmentWriter); - closeQuietly(exposureWriter); - closeQuietly(configService); - LOGGER.debug("Feature Flagging system stopped"); - } - - static boolean isAwaitingApplicationActivation() { - return ACTIVATION_LISTENER != null; - } - - static boolean isExposureWriterStarted() { - return EXPOSURE_WRITER != null; - } - - static boolean isConfigurationSourceStarted() { - return CONFIG_SERVICE != null; - } - - private static void closeQuietly(final AutoCloseable resource) { - if (resource != null) { - try { - resource.close(); - } catch (Exception ignored) { - } + + private static void closeQuietly(final AutoCloseable resource) { + if (resource != null) { + try { + resource.close(); + } catch (Exception ignored) { + } + } } - } } diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index 5b88389aefc..77aafb52bee 100644 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java @@ -37,368 +37,355 @@ import org.junit.jupiter.api.Test; class FeatureFlaggingSystemTest { - @AfterEach - void resetFlagEvaluationGateway() { - FeatureFlaggingSystem.stop(); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://127.0.0.1:1") - void agentlessStartWaitsForApplicationProviderActivationWithoutPreparingDelivery() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); + @AfterEach + void resetFlagEvaluationGateway() { + FeatureFlaggingSystem.stop(); + FeatureFlaggingGateway.setFlagEvalWriter(null); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); } - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void agentlessActivationInitializesSystemOnce() { - final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - final FeatureFlaggingSystem.SystemInitializer systemInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - - FeatureFlaggingSystem.start(sharedCommunicationObjects, systemInitializer); - - verifyNoInteractions(systemInitializer); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.activate(); - - verify(systemInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void agentlessInitializationFailureCleansUpAndAllowsRetry() { - final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - final FeatureFlaggingSystem.SystemInitializer failedInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - final IllegalStateException initializationFailure = - new IllegalStateException("system initialization failed"); - doThrow(initializationFailure) - .when(failedInitializer) - .initialize(any(SharedCommunicationObjects.class), any(Config.class)); - - FeatureFlaggingSystem.start(sharedCommunicationObjects, failedInitializer); - - final IllegalStateException thrown = - assertThrows(IllegalStateException.class, FeatureFlaggingGateway::activate); - - assertSame(initializationFailure, thrown); - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - - final FeatureFlaggingSystem.SystemInitializer retryInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - FeatureFlaggingSystem.start(sharedCommunicationObjects, retryInitializer); - FeatureFlaggingGateway.activate(); - - verify(retryInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://127.0.0.1:1") - void agentlessStopRemovesPendingApplicationProviderActivation() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - - FeatureFlaggingSystem.stop(); - clearInvocations(sharedCommunicationObjects); - FeatureFlaggingGateway.activate(); - - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, value = "http://127.0.0.1:1") + void agentlessStartWaitsForApplicationProviderActivationWithoutPreparingDelivery() { + SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); + clearInvocations(sharedCommunicationObjects); + + try { + FeatureFlaggingSystem.start(sharedCommunicationObjects); + + assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); + assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); + verifyNoInteractions(sharedCommunicationObjects); + } finally { + FeatureFlaggingSystem.stop(); + } + + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); + assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); } - } - - @Test - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") - void testFeatureFlagSystemInitialization() { - ConfigurationPoller poller = mock(ConfigurationPoller.class); - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - when(discovery.supportsEvpProxy()).thenReturn(true); - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - when(sharedCommunicationObjects.configurationPoller(any(Config.class))).thenReturn(poller); - when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - - FeatureFlaggingSystem.start(sharedCommunicationObjects); - FeatureFlaggingSystem.start(sharedCommunicationObjects); - - verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationDeserializer.class), any()); - verify(poller).start(); - assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); - - FeatureFlaggingSystem.stop(); - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - // stop() is idempotent: a second call must be a safe no-op. - FeatureFlaggingSystem.stop(); - - verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).removeListeners(Product.FFE_FLAGS); - verify(poller).stop(); - } - - @Test - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "false") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - void testFlagEvaluationWriterCanBeDisabled() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - // Agentless defers initialization until the application provider activates. - FeatureFlaggingGateway.activate(); - - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } finally { - FeatureFlaggingSystem.stop(); + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + void agentlessActivationInitializesSystemOnce() { + final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); + final FeatureFlaggingSystem.SystemInitializer systemInitializer = + mock(FeatureFlaggingSystem.SystemInitializer.class); + + FeatureFlaggingSystem.start(sharedCommunicationObjects, systemInitializer); + + verifyNoInteractions(systemInitializer); + assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + + FeatureFlaggingGateway.activate(); + FeatureFlaggingGateway.activate(); + + verify(systemInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + void agentlessInitializationFailureCleansUpAndAllowsRetry() { + final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); + final FeatureFlaggingSystem.SystemInitializer failedInitializer = + mock(FeatureFlaggingSystem.SystemInitializer.class); + final IllegalStateException initializationFailure = new IllegalStateException("system initialization failed"); + doThrow(initializationFailure) + .when(failedInitializer) + .initialize(any(SharedCommunicationObjects.class), any(Config.class)); + + FeatureFlaggingSystem.start(sharedCommunicationObjects, failedInitializer); + + final IllegalStateException thrown = + assertThrows(IllegalStateException.class, FeatureFlaggingGateway::activate); + + assertSame(initializationFailure, thrown); + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); + assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); + + final FeatureFlaggingSystem.SystemInitializer retryInitializer = + mock(FeatureFlaggingSystem.SystemInitializer.class); + FeatureFlaggingSystem.start(sharedCommunicationObjects, retryInitializer); + FeatureFlaggingGateway.activate(); + + verify(retryInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, value = "http://127.0.0.1:1") + void agentlessStopRemovesPendingApplicationProviderActivation() { + SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); + clearInvocations(sharedCommunicationObjects); + + try { + FeatureFlaggingSystem.start(sharedCommunicationObjects); + assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + + FeatureFlaggingSystem.stop(); + clearInvocations(sharedCommunicationObjects); + FeatureFlaggingGateway.activate(); + + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + verifyNoInteractions(sharedCommunicationObjects); + } finally { + FeatureFlaggingSystem.stop(); + } + } + + @Test + @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") + @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") + void testFeatureFlagSystemInitialization() { + ConfigurationPoller poller = mock(ConfigurationPoller.class); + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); + when(discovery.supportsEvpProxy()).thenReturn(true); + when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); + when(sharedCommunicationObjects.configurationPoller(any(Config.class))).thenReturn(poller); + when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); + sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); + sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); + + FeatureFlaggingSystem.start(sharedCommunicationObjects); + FeatureFlaggingSystem.start(sharedCommunicationObjects); + + verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationDeserializer.class), any()); + verify(poller).start(); + assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); + assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); + + FeatureFlaggingSystem.stop(); + assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); + assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); + // stop() is idempotent: a second call must be a safe no-op. + FeatureFlaggingSystem.stop(); + + verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller).removeListeners(Product.FFE_FLAGS); + verify(poller).stop(); + } + + @Test + @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "false") + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + @WithConfig( + key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, + value = "http://localhost:1/config") + void testFlagEvaluationWriterCanBeDisabled() { + SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); + + try { + FeatureFlaggingSystem.start(sharedCommunicationObjects); + // Agentless defers initialization until the application provider activates. + FeatureFlaggingGateway.activate(); + + assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); + assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); + } finally { + FeatureFlaggingSystem.stop(); + } + } + + @Test + void testFeatureFlagSystemShutdownClearsGatewayState() { + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); + + FeatureFlaggingSystem.stop(); + + assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); + assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") + @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") + void failedStartRollsBackPartiallyInitializedState() { + SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); + + assertThrows(IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); + + // A failed start must leave nothing behind: no listener awaiting activation, no gateway + // writer, and STARTED cleared so a later start() is not swallowed as "already started". + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); + assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); + assertThrows(IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") + @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") + void testThatRemoteConfigIsRequired() { + SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); + + try { + assertThrows(IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); + } finally { + FeatureFlaggingSystem.stop(); + } + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + @WithConfig( + key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, + value = "http://localhost:1/config") + @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") + void agentlessConfigurationSourceUsesHttpServiceWithoutRemoteConfig() { + assertInstanceOf( + AgentlessConfigurationSource.class, + FeatureFlaggingSystem.createConfigurationSourceService(sharedCommunicationObjects(), Config.get())); } - } - - @Test - void testFeatureFlagSystemShutdownClearsGatewayState() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); - - FeatureFlaggingSystem.stop(); - - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void failedStartRollsBackPartiallyInitializedState() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - - // A failed start must leave nothing behind: no listener awaiting activation, no gateway - // writer, and STARTED cleared so a later start() is not swallowed as "already started". - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void testThatRemoteConfigIsRequired() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertThrows( - IllegalStateException.class, - () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - } finally { - FeatureFlaggingSystem.stop(); + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + @WithConfig( + key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, + value = "http://localhost:1/config") + @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") + @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") + void agentlessConfigurationSourceStartsTelemetryWritersWithoutRemoteConfig() { + try { + FeatureFlaggingSystem.start(sharedCommunicationObjects()); + // Agentless defers initialization until the application provider activates. + FeatureFlaggingGateway.activate(); + + assertTrue(FeatureFlaggingSystem.isExposureWriterStarted()); + assertTrue(FeatureFlaggingSystem.isConfigurationSourceStarted()); + assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); + assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); + } finally { + FeatureFlaggingSystem.stop(); + } } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void agentlessConfigurationSourceUsesHttpServiceWithoutRemoteConfig() { - assertInstanceOf( - AgentlessConfigurationSource.class, - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") - void agentlessConfigurationSourceStartsTelemetryWritersWithoutRemoteConfig() { - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects()); - // Agentless defers initialization until the application provider activates. - FeatureFlaggingGateway.activate(); - - assertTrue(FeatureFlaggingSystem.isExposureWriterStarted()); - assertTrue(FeatureFlaggingSystem.isConfigurationSourceStarted()); - assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } finally { - FeatureFlaggingSystem.stop(); + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") + @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") + void explicitRemoteConfigUsesRemoteConfigService() { + SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); + when(sharedCommunicationObjects.configurationPoller(any(Config.class))) + .thenReturn(mock(ConfigurationPoller.class)); + + assertInstanceOf( + RemoteConfigServiceImpl.class, + FeatureFlaggingSystem.createConfigurationSourceService(sharedCommunicationObjects, Config.get())); } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") - void explicitRemoteConfigUsesRemoteConfigService() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - when(sharedCommunicationObjects.configurationPoller(any(Config.class))) - .thenReturn(mock(ConfigurationPoller.class)); - - assertInstanceOf( - RemoteConfigServiceImpl.class, - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects, Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void offlineConfigurationSourceDoesNotStartNetworkSource() { - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void invalidConfigurationSourceDoesNotStartNetworkSource() { - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - void unsupportedNormalizedConfigurationSourceDoesNotStartNetworkSource() { - Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn("invalid"); - - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), config)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void startWithOfflineConfigurationSourceDisablesSystem() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") + void offlineConfigurationSourceDoesNotStartNetworkSource() { + assertNull(FeatureFlaggingSystem.createConfigurationSourceService(sharedCommunicationObjects(), Config.get())); } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void startWithInvalidConfigurationSourceDisablesSystem() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") + void invalidConfigurationSourceDoesNotStartNetworkSource() { + assertNull(FeatureFlaggingSystem.createConfigurationSourceService(sharedCommunicationObjects(), Config.get())); + } + + @Test + void unsupportedNormalizedConfigurationSourceDoesNotStartNetworkSource() { + Config config = mock(Config.class); + when(config.getFeatureFlaggingConfigurationSource()).thenReturn("invalid"); + + assertNull(FeatureFlaggingSystem.createConfigurationSourceService(sharedCommunicationObjects(), config)); + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") + void startWithOfflineConfigurationSourceDisablesSystem() { + SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); + + try { + assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); + verifyNoInteractions(sharedCommunicationObjects); + } finally { + FeatureFlaggingSystem.stop(); + } + } + + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") + void startWithInvalidConfigurationSourceDisablesSystem() { + SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); + + try { + assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); + verifyNoInteractions(sharedCommunicationObjects); + } finally { + FeatureFlaggingSystem.stop(); + } + } + + @Test + void initializationFailureClosesConfigurationSourceAndExposureWriter() { + ConfigurationSourceService configService = mock(ConfigurationSourceService.class); + ExposureWriter exposureWriter = mock(ExposureWriter.class); + doThrow(new IllegalStateException("exposure init failed")) + .when(exposureWriter) + .init(); + + assertThrows( + IllegalStateException.class, () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); + + verify(configService).init(); + verify(configService).close(); + verify(exposureWriter).close(); + } + + @Test + void initializationFailureWithoutConfigurationSourceClosesExposureWriter() { + ExposureWriter exposureWriter = mock(ExposureWriter.class); + doThrow(new IllegalStateException("exposure init failed")) + .when(exposureWriter) + .init(); + + assertThrows(IllegalStateException.class, () -> FeatureFlaggingSystem.initialize(null, exposureWriter)); + + verify(exposureWriter).close(); + } + + @Test + void initializationFailureClosesConfigurationSourceWhenExposureWriterCloseFails() { + ConfigurationSourceService configService = mock(ConfigurationSourceService.class); + ExposureWriter exposureWriter = mock(ExposureWriter.class); + doThrow(new IllegalStateException("exposure init failed")) + .when(exposureWriter) + .init(); + doThrow(new IllegalArgumentException("exposure close failed")) + .when(exposureWriter) + .close(); + + assertThrows( + IllegalArgumentException.class, () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); + + verify(configService).close(); + } + + private static SharedCommunicationObjects sharedCommunicationObjects() { + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + when(discovery.supportsEvpProxy()).thenReturn(true); + when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); + return sharedCommunicationObjects(discovery); + } + + private static SharedCommunicationObjects sharedCommunicationObjects(final DDAgentFeaturesDiscovery discovery) { + SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); + when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); + sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); + sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); + return sharedCommunicationObjects; } - } - - @Test - void initializationFailureClosesConfigurationSourceAndExposureWriter() { - ConfigurationSourceService configService = mock(ConfigurationSourceService.class); - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - - assertThrows( - IllegalStateException.class, - () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); - - verify(configService).init(); - verify(configService).close(); - verify(exposureWriter).close(); - } - - @Test - void initializationFailureWithoutConfigurationSourceClosesExposureWriter() { - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.initialize(null, exposureWriter)); - - verify(exposureWriter).close(); - } - - @Test - void initializationFailureClosesConfigurationSourceWhenExposureWriterCloseFails() { - ConfigurationSourceService configService = mock(ConfigurationSourceService.class); - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - doThrow(new IllegalArgumentException("exposure close failed")).when(exposureWriter).close(); - - assertThrows( - IllegalArgumentException.class, - () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); - - verify(configService).close(); - } - - private static SharedCommunicationObjects sharedCommunicationObjects() { - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxy()).thenReturn(true); - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - return sharedCommunicationObjects(discovery); - } - - private static SharedCommunicationObjects sharedCommunicationObjects( - final DDAgentFeaturesDiscovery discovery) { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - return sharedCommunicationObjects; - } } diff --git a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java b/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java index e292424e53e..3fecb1e943c 100644 --- a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java +++ b/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java @@ -60,144 +60,135 @@ @Fork(value = 1) public class FlagEvalHookHotPathBenchmark { - /** - * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, - * list/10lists_10items) carry the same leaf count under different structure, so the spread - * between them isolates shape cost from leaf count. - */ - @Param({ - "flat/0attrs", - "flat/10attrs", - "flat/100attrs", - "nested/10structs_10fields", - "list/10lists_10items" - }) - public String shape; - - private HookContext hookContext; - private FlagEvaluationDetails consentOnDetails; - private FlagEvaluationDetails consentOffDetails; - private FlagEvalLoggingHook hook; - - @Setup(Level.Trial) - public void setUp() { - final MutableContext ctx = buildContext(shape); - hookContext = - HookContext.builder() - .flagKey("bench-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - - consentOnDetails = details(true); - consentOffDetails = details(false); - - // Discarding writer: isolates hook-inline cost from queue mechanics. - hook = new FlagEvalLoggingHook<>(new NoOpWriter()); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @TearDown(Level.Trial) - public void tearDown() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - } - - /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ - @Benchmark - public void hookFinallyAfter() { - hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); - } - - /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ - @Benchmark - public void hookFinallyAfterConsentOff() { - hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); - } - - /** The bounded context copy alone - the component that scales with context shape. */ - @Benchmark - public void contextCopy(final Blackhole blackhole) { - blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); - } - - private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { - return FlagEvaluationDetails.builder() - .flagKey("bench-flag") - .value("on-value") - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "alloc-1") - .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean( - DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .build()) - .build(); - } - - private static MutableContext buildContext(final String shape) { - final MutableContext ctx = new MutableContext("bench-user"); - if ("flat/0attrs".equals(shape)) { - return ctx; + /** + * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, + * list/10lists_10items) carry the same leaf count under different structure, so the spread + * between them isolates shape cost from leaf count. + */ + @Param({"flat/0attrs", "flat/10attrs", "flat/100attrs", "nested/10structs_10fields", "list/10lists_10items"}) + public String shape; + + private HookContext hookContext; + private FlagEvaluationDetails consentOnDetails; + private FlagEvaluationDetails consentOffDetails; + private FlagEvalLoggingHook hook; + + @Setup(Level.Trial) + public void setUp() { + final MutableContext ctx = buildContext(shape); + hookContext = HookContext.builder() + .flagKey("bench-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + + consentOnDetails = details(true); + consentOffDetails = details(false); + + // Discarding writer: isolates hook-inline cost from queue mechanics. + hook = new FlagEvalLoggingHook<>(new NoOpWriter()); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); } - if ("flat/10attrs".equals(shape)) { - return addFlat(ctx, 10); + + @TearDown(Level.Trial) + public void tearDown() { + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); } - if ("flat/100attrs".equals(shape)) { - return addFlat(ctx, 100); + + /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ + @Benchmark + public void hookFinallyAfter() { + hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); } - if ("nested/10structs_10fields".equals(shape)) { - for (int i = 0; i < 10; i++) { - final Map inner = new HashMap<>(); - for (int j = 0; j < 10; j++) { - inner.put("field" + j, new Value("value" + j)); - } - ctx.add("struct" + i, new ImmutableStructure(inner)); - } - return ctx; + + /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ + @Benchmark + public void hookFinallyAfterConsentOff() { + hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); } - if ("list/10lists_10items".equals(shape)) { - for (int i = 0; i < 10; i++) { - final List items = new ArrayList<>(10); - for (int j = 0; j < 10; j++) { - items.add(new Value("value" + j)); - } - ctx.add("list" + i, items); - } - return ctx; + + /** The bounded context copy alone - the component that scales with context shape. */ + @Benchmark + public void contextCopy(final Blackhole blackhole) { + blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); } - throw new IllegalArgumentException("unknown benchmark shape: " + shape); - } - private static MutableContext addFlat(final MutableContext ctx, final int count) { - for (int i = 0; i < count; i++) { - ctx.add("field" + i, "value" + i); + private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { + return FlagEvaluationDetails.builder() + .flagKey("bench-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("allocationKey", "alloc-1") + .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build()) + .build(); } - return ctx; - } - /** Discards events so only hook-inline work is measured. */ - private static final class NoOpWriter implements FlagEvaluationWriter { - @Override - public void enqueue(final FlagEvalEvent event) {} + private static MutableContext buildContext(final String shape) { + final MutableContext ctx = new MutableContext("bench-user"); + if ("flat/0attrs".equals(shape)) { + return ctx; + } + if ("flat/10attrs".equals(shape)) { + return addFlat(ctx, 10); + } + if ("flat/100attrs".equals(shape)) { + return addFlat(ctx, 100); + } + if ("nested/10structs_10fields".equals(shape)) { + for (int i = 0; i < 10; i++) { + final Map inner = new HashMap<>(); + for (int j = 0; j < 10; j++) { + inner.put("field" + j, new Value("value" + j)); + } + ctx.add("struct" + i, new ImmutableStructure(inner)); + } + return ctx; + } + if ("list/10lists_10items".equals(shape)) { + for (int i = 0; i < 10; i++) { + final List items = new ArrayList<>(10); + for (int j = 0; j < 10; j++) { + items.add(new Value("value" + j)); + } + ctx.add("list" + i, items); + } + return ctx; + } + throw new IllegalArgumentException("unknown benchmark shape: " + shape); + } - @Override - public boolean hasCapacityForEnqueue() { - return true; + private static MutableContext addFlat(final MutableContext ctx, final int count) { + for (int i = 0; i < count; i++) { + ctx.add("field" + i, "value" + i); + } + return ctx; } - @Override - public void countPreQueueOverflow() {} + /** Discards events so only hook-inline work is measured. */ + private static final class NoOpWriter implements FlagEvaluationWriter { + @Override + public void enqueue(final FlagEvalEvent event) {} - @Override - public void countContextTruncated(final String reason) {} + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} - @Override - public void start() {} + @Override + public void countContextTruncated(final String reason) {} - @Override - public void close() {} - } + @Override + public void start() {} + + @Override + public void close() {} + } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index 9bc55fe63e9..daed720f2a2 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -52,989 +52,967 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { - private static final Logger log = LoggerFactory.getLogger(DDEvaluator.class); - private static final Set> SUPPORTED_RESOLUTION_TYPES = - new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); - - static final AtomicBoolean SPLIT_SERIAL_ID_SUPPORTED = - new AtomicBoolean(splitSerialIdSupported(Split.class)); - - static final AtomicBoolean USE_LEGACY_EXPOSURE_API = - new AtomicBoolean( - !(SPLIT_SERIAL_ID_SUPPORTED.get() && exposureSerialIdSupported(ExposureEvent.class))); - - static boolean splitSerialIdSupported(final Class splitClass) { - try { - return splitClass.getField("serialId").getType() == Integer.class; - } catch (final NoSuchFieldException | LinkageError | RuntimeException e) { - log.warn( - "Feature flag serial ID reporting is unavailable with the installed Datadog Java " - + "agent, which does not carry a serial id on the flag configuration. Upgrade " - + "dd-java-agent to enable holdout attribution."); - log.debug("Unable to access the flag configuration serial ID", e); - return false; - } - } - - static boolean exposureSerialIdSupported(final Class eventClass) { - try { - eventClass.getConstructor( - long.class, - datadog.trace.api.featureflag.exposure.Allocation.class, - datadog.trace.api.featureflag.exposure.Flag.class, - datadog.trace.api.featureflag.exposure.Variant.class, - Subject.class, - Integer.class); - return true; - } catch (final NoSuchMethodException | LinkageError | RuntimeException e) { - log.warn( - "Feature flag exposure serial ID reporting is unavailable with the installed " - + "Datadog Java agent. Exposures are still reported, without the serial id, and " - + "span enrichment is unaffected. Upgrade dd-java-agent to enable holdout " - + "attribution on exposures."); - log.debug("Unable to access the exposure serial ID constructor", e); - return false; - } - } - - /** - * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the - * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep - * list/structure would overflow that thread's stack - and a StackOverflowError is not caught by - * the LinkageError | Exception guards that keep telemetry from breaking an evaluation. Values - * below the limit are truncated to null, the same way the cycle guard truncates. Kept aligned - * with the cross-SDK RFC target (4). - */ - static final int MAX_SNAPSHOT_DEPTH = 4; - - /** - * Maximum number of top-level context fields retained by copyPrunedContext. Bounds the width of - * the caller-supplied context and, transitively, the size of every FlagEvalEvent sitting in the - * async hand-off queue. Kept aligned with the cross-SDK RFC. - */ - static final int MAX_CONTEXT_FIELDS = 256; - - /** - * Maximum character length for a single context KEY retained by copyPrunedContext. Keys are - * stored verbatim in every full-tier bucket, so an unbounded key size would let a single caller - * inflate steady-state heap use. Longer keys cause the field to be skipped. - */ - static final int MAX_KEY_LENGTH = 256; - - /** - * Maximum character length for a single context string VALUE retained by copyPrunedContext. - * Longer values cause the field to be skipped (matches previous pruneContext behavior). - * Non-string scalars are not length-bounded. - */ - static final int MAX_VALUE_LENGTH = 256; - - /** - * Maximum number of elements walked per list encountered during copyPrunedContext. Bounds the - * fan-out of a single wide list at capture time so one caller cannot inflate the hot path with a - * huge but shallow structure. Elements past the limit are skipped. - */ - static final int MAX_LIST_ELEMENTS = 256; - - /** - * Maximum number of properties walked per structure encountered during copyPrunedContext. Same - * intent as MAX_LIST_ELEMENTS for structures. Properties past the limit are skipped. - */ - static final int MAX_STRUCTURE_PROPERTIES = 256; - - // Evaluation-metadata keys consumed by the span-enrichment capture hook (see - // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. - static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; - static final String METADATA_DO_LOG = "__dd_do_log"; - - // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing - // key = non-DD provider; the hook falls back to false (fail-closed). - static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; - - // Read once: when off, the __dd_* span-enrichment metadata is not attached to evaluations, so an - // enabled provider pays nothing extra unless span enrichment is also enabled. The gate does not - // change at runtime, and this class is loaded lazily (well after startup) so config is ready. - private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); - - private final Runnable configCallback; - private final AtomicReference configuration = new AtomicReference<>(); - private final CountDownLatch initializationLatch = new CountDownLatch(1); - - public DDEvaluator(final Runnable configCallback) { - this.configCallback = configCallback; - } - - @Override - public boolean initialize( - final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.addConfigListener(this); - return initializationLatch.await(timeout, unit) || hasConfiguration(); - } - - @Override - public boolean hasConfiguration() { - return configuration.get() != null; - } - - @Override - public void shutdown() { - FeatureFlaggingGateway.removeConfigListener(this); - } - - @Override - public void accept(final ServerConfiguration config) { - configuration.set(config); - if (config != null) { - initializationLatch.countDown(); - configCallback.run(); - } else if (initializationLatch.getCount() == 0) { - configCallback.run(); - } - } - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - // Snapshot the config once and thread observeFullEvaluationData through every - // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's - // config and cannot drift on a concurrent Remote Config swap. - final ServerConfiguration config = configuration.get(); - // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without - // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. - final boolean observeFullEvaluationData = - config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); - try { - if (config == null) { - return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); - } - - if (context == null) { - return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); - } - - final Flag flag = config.flags.get(key); - if (flag == null) { - if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "invalid configuration for flag " + key, - observeFullEvaluationData); + private static final Logger log = LoggerFactory.getLogger(DDEvaluator.class); + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); + + static final AtomicBoolean SPLIT_SERIAL_ID_SUPPORTED = new AtomicBoolean(splitSerialIdSupported(Split.class)); + + static final AtomicBoolean USE_LEGACY_EXPOSURE_API = + new AtomicBoolean(!(SPLIT_SERIAL_ID_SUPPORTED.get() && exposureSerialIdSupported(ExposureEvent.class))); + + static boolean splitSerialIdSupported(final Class splitClass) { + try { + return splitClass.getField("serialId").getType() == Integer.class; + } catch (final NoSuchFieldException | LinkageError | RuntimeException e) { + log.warn("Feature flag serial ID reporting is unavailable with the installed Datadog Java " + + "agent, which does not carry a serial id on the flag configuration. Upgrade " + + "dd-java-agent to enable holdout attribution."); + log.debug("Unable to access the flag configuration serial ID", e); + return false; } - return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); - } + } - if (!flag.enabled) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DISABLED.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } - - if (flag.allocations == null) { - return error( - defaultValue, - ErrorCode.GENERAL, - "Missing allocations for flag " + key, - observeFullEvaluationData); - } - - final Instant now = Instant.now(); - final long evalTimestampMs = now.toEpochMilli(); - final String targetingKey = context.getTargetingKey(); - - for (final Allocation allocation : flag.allocations) { - if (!isAllocationActive(allocation, now)) { - continue; + static boolean exposureSerialIdSupported(final Class eventClass) { + try { + eventClass.getConstructor( + long.class, + datadog.trace.api.featureflag.exposure.Allocation.class, + datadog.trace.api.featureflag.exposure.Flag.class, + datadog.trace.api.featureflag.exposure.Variant.class, + Subject.class, + Integer.class); + return true; + } catch (final NoSuchMethodException | LinkageError | RuntimeException e) { + log.warn("Feature flag exposure serial ID reporting is unavailable with the installed " + + "Datadog Java agent. Exposures are still reported, without the serial id, and " + + "span enrichment is unaffected. Upgrade dd-java-agent to enable holdout " + + "attribution on exposures."); + log.debug("Unable to access the exposure serial ID constructor", e); + return false; } + } - if (!isEmpty(allocation.rules)) { - if (!evaluateRules(allocation.rules, context)) { - continue; - } + /** + * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the + * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep + * list/structure would overflow that thread's stack - and a StackOverflowError is not caught by + * the LinkageError | Exception guards that keep telemetry from breaking an evaluation. Values + * below the limit are truncated to null, the same way the cycle guard truncates. Kept aligned + * with the cross-SDK RFC target (4). + */ + static final int MAX_SNAPSHOT_DEPTH = 4; + + /** + * Maximum number of top-level context fields retained by copyPrunedContext. Bounds the width of + * the caller-supplied context and, transitively, the size of every FlagEvalEvent sitting in the + * async hand-off queue. Kept aligned with the cross-SDK RFC. + */ + static final int MAX_CONTEXT_FIELDS = 256; + + /** + * Maximum character length for a single context KEY retained by copyPrunedContext. Keys are + * stored verbatim in every full-tier bucket, so an unbounded key size would let a single caller + * inflate steady-state heap use. Longer keys cause the field to be skipped. + */ + static final int MAX_KEY_LENGTH = 256; + + /** + * Maximum character length for a single context string VALUE retained by copyPrunedContext. + * Longer values cause the field to be skipped (matches previous pruneContext behavior). + * Non-string scalars are not length-bounded. + */ + static final int MAX_VALUE_LENGTH = 256; + + /** + * Maximum number of elements walked per list encountered during copyPrunedContext. Bounds the + * fan-out of a single wide list at capture time so one caller cannot inflate the hot path with a + * huge but shallow structure. Elements past the limit are skipped. + */ + static final int MAX_LIST_ELEMENTS = 256; + + /** + * Maximum number of properties walked per structure encountered during copyPrunedContext. Same + * intent as MAX_LIST_ELEMENTS for structures. Properties past the limit are skipped. + */ + static final int MAX_STRUCTURE_PROPERTIES = 256; + + // Evaluation-metadata keys consumed by the span-enrichment capture hook (see + // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. + static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; + static final String METADATA_DO_LOG = "__dd_do_log"; + + // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing + // key = non-DD provider; the hook falls back to false (fail-closed). + static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; + + // Read once: when off, the __dd_* span-enrichment metadata is not attached to evaluations, so an + // enabled provider pays nothing extra unless span enrichment is also enabled. The gate does not + // change at runtime, and this class is loaded lazily (well after startup) so config is ready. + private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); + + private final Runnable configCallback; + private final AtomicReference configuration = new AtomicReference<>(); + private final CountDownLatch initializationLatch = new CountDownLatch(1); + + public DDEvaluator(final Runnable configCallback) { + this.configCallback = configCallback; + } + + @Override + public boolean initialize(final long timeout, final TimeUnit unit, final EvaluationContext context) + throws Exception { + FeatureFlaggingGateway.activate(); + FeatureFlaggingGateway.addConfigListener(this); + return initializationLatch.await(timeout, unit) || hasConfiguration(); + } + + @Override + public boolean hasConfiguration() { + return configuration.get() != null; + } + + @Override + public void shutdown() { + FeatureFlaggingGateway.removeConfigListener(this); + } + + @Override + public void accept(final ServerConfiguration config) { + configuration.set(config); + if (config != null) { + initializationLatch.countDown(); + configCallback.run(); + } else if (initializationLatch.getCount() == 0) { + configCallback.run(); } + } + + @Override + public ProviderEvaluation evaluate( + final Class target, final String key, final T defaultValue, final EvaluationContext context) { + // Snapshot the config once and thread observeFullEvaluationData through every + // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's + // config and cannot drift on a concurrent Remote Config swap. + final ServerConfiguration config = configuration.get(); + // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without + // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. + final boolean observeFullEvaluationData = + config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); + try { + if (config == null) { + return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); + } + + if (context == null) { + return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); + } - if (!isEmpty(allocation.splits)) { - for (final Split split : allocation.splits) { - if (isEmpty(split.shards)) { - return resolveVariant( - target, - key, - defaultValue, - flag, - split.variationKey, - allocation, - split, - context, - evalTimestampMs, - observeFullEvaluationData); - } else { - if (targetingKey == null) { + final Flag flag = config.flags.get(key); + if (flag == null) { + if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "invalid configuration for flag " + key, + observeFullEvaluationData); + } + return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); + } + + if (!flag.enabled) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DISABLED.name()) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } + + if (flag.allocations == null) { return error( - defaultValue, ErrorCode.TARGETING_KEY_MISSING, null, observeFullEvaluationData); - } - // To match a split, subject must match ALL underlying shards - boolean allShardsMatch = true; - for (final Shard shard : split.shards) { - if (!matchesShard(shard, targetingKey)) { - allShardsMatch = false; - break; + defaultValue, + ErrorCode.GENERAL, + "Missing allocations for flag " + key, + observeFullEvaluationData); + } + + final Instant now = Instant.now(); + final long evalTimestampMs = now.toEpochMilli(); + final String targetingKey = context.getTargetingKey(); + + for (final Allocation allocation : flag.allocations) { + if (!isAllocationActive(allocation, now)) { + continue; + } + + if (!isEmpty(allocation.rules)) { + if (!evaluateRules(allocation.rules, context)) { + continue; + } + } + + if (!isEmpty(allocation.splits)) { + for (final Split split : allocation.splits) { + if (isEmpty(split.shards)) { + return resolveVariant( + target, + key, + defaultValue, + flag, + split.variationKey, + allocation, + split, + context, + evalTimestampMs, + observeFullEvaluationData); + } else { + if (targetingKey == null) { + return error( + defaultValue, ErrorCode.TARGETING_KEY_MISSING, null, observeFullEvaluationData); + } + // To match a split, subject must match ALL underlying shards + boolean allShardsMatch = true; + for (final Shard shard : split.shards) { + if (!matchesShard(shard, targetingKey)) { + allShardsMatch = false; + break; + } + } + if (allShardsMatch) { + return resolveVariant( + target, + key, + defaultValue, + flag, + split.variationKey, + allocation, + split, + context, + evalTimestampMs, + observeFullEvaluationData); + } + } + } } - } - if (allShardsMatch) { - return resolveVariant( - target, - key, - defaultValue, - flag, - split.variationKey, - allocation, - split, - context, - evalTimestampMs, - observeFullEvaluationData); - } } - } + + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } catch (final PatternSyntaxException e) { + return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); + } catch (final NumberFormatException e) { + return error(defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); + } catch (final Exception e) { + return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); } - } - - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } catch (final PatternSyntaxException e) { - return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); - } catch (final NumberFormatException e) { - return error( - defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); - } catch (final Exception e) { - return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); } - } - - private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { - return ImmutableMetadata.builder() - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .build(); - } - - private static ProviderEvaluation error( - final T defaultValue, - final ErrorCode code, - final String errorMessage, - final boolean observeFullEvaluationData) { - // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks - // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they - // must never reach any consumer of ProviderEvaluation.getErrorMessage() — not just our own - // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators - // still get a stable signal like "TYPE_MISMATCH". - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(code) - .errorMessage(observeFullEvaluationData ? errorMessage : null) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } - - private static boolean isEmpty(final List list) { - return list == null || list.isEmpty(); - } - - static boolean isAllocationActive(final Allocation allocation, final Instant now) { - final Instant startDate = allocation.startAtInstant(); - if (startDate != null && now.isBefore(startDate)) { - return false; + + private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { + return ImmutableMetadata.builder() + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build(); } - final Instant endDate = allocation.endAtInstant(); - if (endDate != null && now.isAfter(endDate)) { - return false; + private static ProviderEvaluation error( + final T defaultValue, + final ErrorCode code, + final String errorMessage, + final boolean observeFullEvaluationData) { + // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks + // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they + // must never reach any consumer of ProviderEvaluation.getErrorMessage() — not just our own + // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators + // still get a stable signal like "TYPE_MISMATCH". + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(code) + .errorMessage(observeFullEvaluationData ? errorMessage : null) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); } - return true; - } + private static boolean isEmpty(final List list) { + return list == null || list.isEmpty(); + } - private static boolean evaluateRules(final List rules, final EvaluationContext context) { - for (final Rule rule : rules) { - if (isEmpty(rule.conditions)) { - continue; - } + static boolean isAllocationActive(final Allocation allocation, final Instant now) { + final Instant startDate = allocation.startAtInstant(); + if (startDate != null && now.isBefore(startDate)) { + return false; + } - boolean allConditionsMatch = true; - for (final ConditionConfiguration condition : rule.conditions) { - if (!evaluateCondition(condition, context)) { - allConditionsMatch = false; - break; + final Instant endDate = allocation.endAtInstant(); + if (endDate != null && now.isAfter(endDate)) { + return false; } - } - if (allConditionsMatch) { return true; - } - } - return false; - } - - private static boolean evaluateCondition( - final ConditionConfiguration condition, final EvaluationContext context) { - if (condition.operator == ConditionOperator.IS_NULL) { - final Object value = resolveAttribute(condition.attribute, context); - boolean isNull = value == null; - // condition.value determines if we're checking for null (true) or not null (false) - boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; - return isNull == expectedNull; } - final Object attributeValue = resolveAttribute(condition.attribute, context); - if (attributeValue == null) { - return false; - } + private static boolean evaluateRules(final List rules, final EvaluationContext context) { + for (final Rule rule : rules) { + if (isEmpty(rule.conditions)) { + continue; + } + + boolean allConditionsMatch = true; + for (final ConditionConfiguration condition : rule.conditions) { + if (!evaluateCondition(condition, context)) { + allConditionsMatch = false; + break; + } + } - switch (condition.operator) { - case MATCHES: - return matchesRegex(attributeValue, condition.value); - case NOT_MATCHES: - return !matchesRegex(attributeValue, condition.value); - case ONE_OF: - return isOneOf(attributeValue, condition.value); - case NOT_ONE_OF: - return !isOneOf(attributeValue, condition.value); - case GTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); - case GT: - return compareNumber(attributeValue, condition.value, (a, b) -> a > b); - case LTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); - case LT: - return compareNumber(attributeValue, condition.value, (a, b) -> a < b); - case SEMVER_EQ: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o == 0); - case SEMVER_NEQ: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o != 0); - case SEMVER_LT: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o < 0); - case SEMVER_LTE: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o <= 0); - case SEMVER_GT: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o > 0); - case SEMVER_GTE: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o >= 0); - default: + if (allConditionsMatch) { + return true; + } + } return false; } - } - - private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { - // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), - // which maps it to ErrorCode.PARSE_ERROR. - final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue))); - return pattern.matcher(String.valueOf(attributeValue)).find(); - } - - private static String normalizeRegex(final String regex) { - return regex - .replace("[:alnum:]", "\\p{Alnum}") - .replace("[:alpha:]", "\\p{Alpha}") - .replace("[:digit:]", "\\p{Digit}") - .replace("[:lower:]", "\\p{Lower}") - .replace("[:upper:]", "\\p{Upper}") - .replace("[:space:]", "\\p{Space}"); - } - - private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { - if (!(conditionValue instanceof Iterable)) { - return false; - } - for (final Object value : (Iterable) conditionValue) { - if (valuesEqual(attributeValue, value)) { - return true; - } - } - return false; - } - private static boolean valuesEqual(final Object a, final Object b) { - if (Objects.equals(a, b)) { - return true; - } + private static boolean evaluateCondition(final ConditionConfiguration condition, final EvaluationContext context) { + if (condition.operator == ConditionOperator.IS_NULL) { + final Object value = resolveAttribute(condition.attribute, context); + boolean isNull = value == null; + // condition.value determines if we're checking for null (true) or not null (false) + boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; + return isNull == expectedNull; + } - if (a instanceof Number || b instanceof Number) { - return compareNumber(a, b, (first, second) -> first == second); - } + final Object attributeValue = resolveAttribute(condition.attribute, context); + if (attributeValue == null) { + return false; + } - return String.valueOf(a).equals(String.valueOf(b)); - } - - private static boolean compareNumber( - final Object attributeValue, final Object conditionValue, NumberComparator comparator) { - final double a = mapValue(Double.class, attributeValue); - final double b = mapValue(Double.class, conditionValue); - return comparator.compare(a, b); - } - - /** - * Evaluates a semantic version comparison operator. The attribute value must be a string that is - * a valid semantic version, and the comparand must have been pre-parsed during configuration - * validation. If either is missing or invalid, the condition does not match. - */ - private static boolean evaluateSemverCondition( - final Object attributeValue, - final ParsedSemver comparand, - final SemverComparator comparator) { - if (!(attributeValue instanceof String) || comparand == null) { - return false; - } - final ParsedSemver parsedAttribute = ParsedSemver.parse((String) attributeValue); - if (parsedAttribute == null) { - return false; - } - return comparator.compare(ParsedSemver.compare(parsedAttribute, comparand)); - } - - private static boolean matchesShard(final Shard shard, final String targetingKey) { - // The bootstrap model preserves its int ABI, so UFC uint32 values are stored as raw bits. - // Convert before arithmetic and comparison to retain their unsigned wire semantics. - final long totalShards = Integer.toUnsignedLong(shard.totalShards); - final long assignedShard = getShard(shard.salt, targetingKey, totalShards); - for (final ShardRange range : shard.ranges) { - final long rangeStart = Integer.toUnsignedLong(range.start); - final long rangeEnd = Integer.toUnsignedLong(range.end); - if (assignedShard >= rangeStart && assignedShard < rangeEnd) { - return true; - } - } - return false; - } - - private static long getShard( - final String salt, final String targetingKey, final long totalShards) { - final String hashKey = salt + "-" + targetingKey; - final String md5Hash = getMD5Hash(hashKey); - final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); - final long intFromHash = Long.parseLong(first8Chars, 16); - return intFromHash % totalShards; - } - - private static String getMD5Hash(final String input) { - try { - final MessageDigest md = MessageDigest.getInstance("MD5"); - final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); - final StringBuilder hexString = new StringBuilder(); - for (byte b : hashBytes) { - final String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) { - hexString.append('0'); + switch (condition.operator) { + case MATCHES: + return matchesRegex(attributeValue, condition.value); + case NOT_MATCHES: + return !matchesRegex(attributeValue, condition.value); + case ONE_OF: + return isOneOf(attributeValue, condition.value); + case NOT_ONE_OF: + return !isOneOf(attributeValue, condition.value); + case GTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); + case GT: + return compareNumber(attributeValue, condition.value, (a, b) -> a > b); + case LTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); + case LT: + return compareNumber(attributeValue, condition.value, (a, b) -> a < b); + case SEMVER_EQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o == 0); + case SEMVER_NEQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o != 0); + case SEMVER_LT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o < 0); + case SEMVER_LTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o <= 0); + case SEMVER_GT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o > 0); + case SEMVER_GTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o >= 0); + default: + return false; } - hexString.append(hex); - } - return hexString.toString(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("MD5 algorithm not available", e); - } - } - - private static ProviderEvaluation resolveVariant( - final Class target, - final String key, - final T defaultValue, - final Flag flag, - final String variationKey, - final Allocation allocation, - final Split split, - final EvaluationContext context, - final long evalTimestampMs, - final boolean observeFullEvaluationData) { - final Variant variant = flag.variations.get(variationKey); - if (variant == null) { - return error( - defaultValue, - ErrorCode.GENERAL, - "Variant not found for: " + variationKey, - observeFullEvaluationData); } - if (!isTypeCompatible(target, flag.variationType)) { - return error( - defaultValue, - ErrorCode.TYPE_MISMATCH, - "Requested type " - + target.getSimpleName() - + " does not match flag variationType " - + flag.variationType.name(), - observeFullEvaluationData); + private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { + // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), + // which maps it to ErrorCode.PARSE_ERROR. + final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue))); + return pattern.matcher(String.valueOf(attributeValue)).find(); } - final T mappedValue; - try { - mappedValue = mapValue(target, variant.value); - } catch (final NumberFormatException e) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "Variant '" - + variant.key - + "' value does not match declared type " - + flag.variationType.name() - + ": " - + e.getMessage(), - observeFullEvaluationData); + private static String normalizeRegex(final String regex) { + return regex.replace("[:alnum:]", "\\p{Alnum}") + .replace("[:alpha:]", "\\p{Alpha}") + .replace("[:digit:]", "\\p{Digit}") + .replace("[:lower:]", "\\p{Lower}") + .replace("[:upper:]", "\\p{Upper}") + .replace("[:space:]", "\\p{Space}"); } - // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, - // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". - final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = - ImmutableMetadata.builder() - .addString("flagKey", flag.key) - .addString("variationType", flag.variationType.name()) - .addString("allocationKey", allocation.key) - .addLong("__dd_eval_timestamp_ms", evalTimestampMs) - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); - // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — - // only when span enrichment is on, so a provider without enrichment pays nothing extra. - // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always - // present (when enrichment is on) so the span-enrichment hook can decide whether to record the - // subject. - if (SPAN_ENRICHMENT_ENABLED) { - if (SPLIT_SERIAL_ID_SUPPORTED.get() && split.serialId != null) { - metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); - } - metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); - } - final ProviderEvaluation result = - ProviderEvaluation.builder() - .value(mappedValue) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : allocation.startAt != null || allocation.endAt != null - ? Reason.DEFAULT.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) - .variant(variant.key) - .flagMetadata(metadataBuilder.build()) - .build(); - final boolean doLog = allocation.doLog != null && allocation.doLog; - if (doLog) { - dispatchExposure(key, result, context, split); + private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { + if (!(conditionValue instanceof Iterable)) { + return false; + } + for (final Object value : (Iterable) conditionValue) { + if (valuesEqual(attributeValue, value)) { + return true; + } + } + return false; } - return result; - } - private static Object resolveAttribute(final String name, final EvaluationContext context) { - // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.keySet().contains(name)) { - return context.getTargetingKey(); - } - final Value resolved = context.getValue(name); - return context.convertValue(resolved); - } + private static boolean valuesEqual(final Object a, final Object b) { + if (Objects.equals(a, b)) { + return true; + } + + if (a instanceof Number || b instanceof Number) { + return compareNumber(a, b, (first, second) -> first == second); + } - private static boolean isTypeCompatible(final Class target, final ValueType variationType) { - if (variationType == null) { - return true; // No type info — allow any + return String.valueOf(a).equals(String.valueOf(b)); } - switch (variationType) { - case BOOLEAN: - return target == Boolean.class; - case STRING: - return target == String.class; - case INTEGER: - return target == Integer.class; - case NUMERIC: - return target == Double.class; - case JSON: - return target == Value.class; - default: - return true; // Unknown types pass through — mapValue errors caught as GENERAL + + private static boolean compareNumber( + final Object attributeValue, final Object conditionValue, NumberComparator comparator) { + final double a = mapValue(Double.class, attributeValue); + final double b = mapValue(Double.class, conditionValue); + return comparator.compare(a, b); } - } - @SuppressWarnings("unchecked") - static T mapValue(final Class target, final Object value) { - if (value == null) { - return null; + /** + * Evaluates a semantic version comparison operator. The attribute value must be a string that is + * a valid semantic version, and the comparand must have been pre-parsed during configuration + * validation. If either is missing or invalid, the condition does not match. + */ + private static boolean evaluateSemverCondition( + final Object attributeValue, final ParsedSemver comparand, final SemverComparator comparator) { + if (!(attributeValue instanceof String) || comparand == null) { + return false; + } + final ParsedSemver parsedAttribute = ParsedSemver.parse((String) attributeValue); + if (parsedAttribute == null) { + return false; + } + return comparator.compare(ParsedSemver.compare(parsedAttribute, comparand)); + } + + private static boolean matchesShard(final Shard shard, final String targetingKey) { + // The bootstrap model preserves its int ABI, so UFC uint32 values are stored as raw bits. + // Convert before arithmetic and comparison to retain their unsigned wire semantics. + final long totalShards = Integer.toUnsignedLong(shard.totalShards); + final long assignedShard = getShard(shard.salt, targetingKey, totalShards); + for (final ShardRange range : shard.ranges) { + final long rangeStart = Integer.toUnsignedLong(range.start); + final long rangeEnd = Integer.toUnsignedLong(range.end); + if (assignedShard >= rangeStart && assignedShard < rangeEnd) { + return true; + } + } + return false; } - if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { - throw new IllegalArgumentException("Type not supported: " + target); + + private static long getShard(final String salt, final String targetingKey, final long totalShards) { + final String hashKey = salt + "-" + targetingKey; + final String md5Hash = getMD5Hash(hashKey); + final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); + final long intFromHash = Long.parseLong(first8Chars, 16); + return intFromHash % totalShards; } - if (target.isInstance(value)) { - return target.cast(value); + + private static String getMD5Hash(final String input) { + try { + final MessageDigest md = MessageDigest.getInstance("MD5"); + final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not available", e); + } } - if (target == String.class) { - return (T) String.valueOf(value); + + private static ProviderEvaluation resolveVariant( + final Class target, + final String key, + final T defaultValue, + final Flag flag, + final String variationKey, + final Allocation allocation, + final Split split, + final EvaluationContext context, + final long evalTimestampMs, + final boolean observeFullEvaluationData) { + final Variant variant = flag.variations.get(variationKey); + if (variant == null) { + return error( + defaultValue, + ErrorCode.GENERAL, + "Variant not found for: " + variationKey, + observeFullEvaluationData); + } + + if (!isTypeCompatible(target, flag.variationType)) { + return error( + defaultValue, + ErrorCode.TYPE_MISMATCH, + "Requested type " + + target.getSimpleName() + + " does not match flag variationType " + + flag.variationType.name(), + observeFullEvaluationData); + } + + final T mappedValue; + try { + mappedValue = mapValue(target, variant.value); + } catch (final NumberFormatException e) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "Variant '" + + variant.key + + "' value does not match declared type " + + flag.variationType.name() + + ": " + + e.getMessage(), + observeFullEvaluationData); + } + + // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, + // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". + final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder() + .addString("flagKey", flag.key) + .addString("variationType", flag.variationType.name()) + .addString("allocationKey", allocation.key) + .addLong("__dd_eval_timestamp_ms", evalTimestampMs) + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); + // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — + // only when span enrichment is on, so a provider without enrichment pays nothing extra. + // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always + // present (when enrichment is on) so the span-enrichment hook can decide whether to record the + // subject. + if (SPAN_ENRICHMENT_ENABLED) { + if (SPLIT_SERIAL_ID_SUPPORTED.get() && split.serialId != null) { + metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); + } + metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); + } + final ProviderEvaluation result = ProviderEvaluation.builder() + .value(mappedValue) + .reason( + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH.name() + : allocation.startAt != null || allocation.endAt != null + ? Reason.DEFAULT.name() + : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) + .variant(variant.key) + .flagMetadata(metadataBuilder.build()) + .build(); + final boolean doLog = allocation.doLog != null && allocation.doLog; + if (doLog) { + dispatchExposure(key, result, context, split); + } + return result; } - if (target == Boolean.class) { - if (value instanceof Number) { - return (T) (Boolean) (parseDouble(value) != 0); - } - return (T) Boolean.valueOf(value.toString()); + + private static Object resolveAttribute(final String name, final EvaluationContext context) { + // Special handling for "id" attribute: if not explicitly provided, use targeting key + if ("id".equals(name) && !context.keySet().contains(name)) { + return context.getTargetingKey(); + } + final Value resolved = context.getValue(name); + return context.convertValue(resolved); } - if (target == Integer.class) { - final Double number = parseDouble(value); - return (T) (Integer) number.intValue(); + + private static boolean isTypeCompatible(final Class target, final ValueType variationType) { + if (variationType == null) { + return true; // No type info — allow any + } + switch (variationType) { + case BOOLEAN: + return target == Boolean.class; + case STRING: + return target == String.class; + case INTEGER: + return target == Integer.class; + case NUMERIC: + return target == Double.class; + case JSON: + return target == Value.class; + default: + return true; // Unknown types pass through — mapValue errors caught as GENERAL + } } - if (target == Double.class) { - final Double number = parseDouble(value); - return (T) number; + + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + if (value == null) { + return null; + } + if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { + throw new IllegalArgumentException("Type not supported: " + target); + } + if (target.isInstance(value)) { + return target.cast(value); + } + if (target == String.class) { + return (T) String.valueOf(value); + } + if (target == Boolean.class) { + if (value instanceof Number) { + return (T) (Boolean) (parseDouble(value) != 0); + } + return (T) Boolean.valueOf(value.toString()); + } + if (target == Integer.class) { + final Double number = parseDouble(value); + return (T) (Integer) number.intValue(); + } + if (target == Double.class) { + final Double number = parseDouble(value); + return (T) number; + } + return (T) Value.objectToValue(value); } - return (T) Value.objectToValue(value); - } - private static Double parseDouble(final Object value) { - if (value instanceof Number) { - return ((Number) value).doubleValue(); + private static Double parseDouble(final Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return Double.parseDouble(String.valueOf(value)); + } + + private static void dispatchExposure( + final String flag, + final ProviderEvaluation evaluation, + final EvaluationContext context, + final Split split) { + final String allocationKey = allocationKey(evaluation); + final String variantKey = evaluation.getVariant(); + if (allocationKey == null || variantKey == null) { + return; + } + final long timestamp = System.currentTimeMillis(); + // Exposure types share names with the imported UFC Allocation, Flag, and Variant types. + final datadog.trace.api.featureflag.exposure.Allocation allocation = + new datadog.trace.api.featureflag.exposure.Allocation(allocationKey); + final datadog.trace.api.featureflag.exposure.Flag exposureFlag = + new datadog.trace.api.featureflag.exposure.Flag(flag); + final datadog.trace.api.featureflag.exposure.Variant variant = + new datadog.trace.api.featureflag.exposure.Variant(variantKey); + final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context)); + + final ExposureEvent event = USE_LEGACY_EXPOSURE_API.get() + ? new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject) + : new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject, split.serialId); + FeatureFlaggingGateway.dispatch(event); } - return Double.parseDouble(String.valueOf(value)); - } - - private static void dispatchExposure( - final String flag, - final ProviderEvaluation evaluation, - final EvaluationContext context, - final Split split) { - final String allocationKey = allocationKey(evaluation); - final String variantKey = evaluation.getVariant(); - if (allocationKey == null || variantKey == null) { - return; + + private static String allocationKey(final ProviderEvaluation resolution) { + final ImmutableMetadata meta = resolution.getFlagMetadata(); + return meta == null ? null : meta.getString("allocationKey"); } - final long timestamp = System.currentTimeMillis(); - // Exposure types share names with the imported UFC Allocation, Flag, and Variant types. - final datadog.trace.api.featureflag.exposure.Allocation allocation = - new datadog.trace.api.featureflag.exposure.Allocation(allocationKey); - final datadog.trace.api.featureflag.exposure.Flag exposureFlag = - new datadog.trace.api.featureflag.exposure.Flag(flag); - final datadog.trace.api.featureflag.exposure.Variant variant = - new datadog.trace.api.featureflag.exposure.Variant(variantKey); - final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context)); - - final ExposureEvent event = - USE_LEGACY_EXPOSURE_API.get() - ? new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject) - : new ExposureEvent( - timestamp, allocation, exposureFlag, variant, subject, split.serialId); - FeatureFlaggingGateway.dispatch(event); - } - - private static String allocationKey(final ProviderEvaluation resolution) { - final ImmutableMetadata meta = resolution.getFlagMetadata(); - return meta == null ? null : meta.getString("allocationKey"); - } - - static AbstractMap flattenContext(final EvaluationContext context) { - return flattenValues(snapshotValues(context)); - } - - static Map snapshotValues(final EvaluationContext context) { - final HashMap values = new HashMap<>(); - final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); - for (final String key : context.keySet()) { - values.put(key, snapshotValue(context.getValue(key), seenContainers, 0)); + + static AbstractMap flattenContext(final EvaluationContext context) { + return flattenValues(snapshotValues(context)); } - return values; - } - - private static Value snapshotValue( - final Value value, final Set seenContainers, final int depth) { - if (value == null) { - return null; - } else if (value.isNull()) { - return new Value(); - } else if (value.isBoolean()) { - return new Value(value.asBoolean()); - } else if (value.isNumber()) { - final Object number = value.asObject(); - return number instanceof Integer - ? new Value((Integer) number) - : new Value(((Number) number).doubleValue()); - } else if (value.isString()) { - return new Value(value.asString()); - } else if (value.isInstant()) { - return new Value(value.asInstant()); - } else if (value.isList()) { - final List list = value.asList(); - if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(list)) { - return new Value(); - } - final List snapshot = new ArrayList<>(list.size()); - for (final Value item : list) { - snapshot.add(snapshotValue(item, seenContainers, depth + 1)); - } - seenContainers.remove(list); - return new Value(Collections.unmodifiableList(snapshot)); - } else if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(structure)) { - return new Value(); - } - final Map snapshot = new HashMap<>(); - for (final String key : structure.keySet()) { - snapshot.put(key, snapshotValue(structure.getValue(key), seenContainers, depth + 1)); - } - seenContainers.remove(structure); - return new Value(new ImmutableStructure(snapshot)); + + static Map snapshotValues(final EvaluationContext context) { + final HashMap values = new HashMap<>(); + final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final String key : context.keySet()) { + values.put(key, snapshotValue(context.getValue(key), seenContainers, 0)); + } + return values; } - throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); - } - - static AbstractMap flattenValues(final Map values) { - final HashMap result = new HashMap<>(); - final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); - for (final Map.Entry root : values.entrySet()) { - final Deque deque = new LinkedList<>(); - deque.push(new FlattenEntry(root.getKey(), root.getValue())); - while (!deque.isEmpty()) { - final FlattenEntry entry = deque.pop(); - final Value value = entry.value; + + private static Value snapshotValue(final Value value, final Set seenContainers, final int depth) { if (value == null) { - result.put(entry.key, null); + return null; + } else if (value.isNull()) { + return new Value(); + } else if (value.isBoolean()) { + return new Value(value.asBoolean()); + } else if (value.isNumber()) { + final Object number = value.asObject(); + return number instanceof Integer ? new Value((Integer) number) : new Value(((Number) number).doubleValue()); + } else if (value.isString()) { + return new Value(value.asString()); + } else if (value.isInstant()) { + return new Value(value.asInstant()); } else if (value.isList()) { - final List list = value.asList(); - if (seenContainers.add(list)) { - for (int i = 0; i < list.size(); i++) { - deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i))); + final List list = value.asList(); + if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(list)) { + return new Value(); } - } + final List snapshot = new ArrayList<>(list.size()); + for (final Value item : list) { + snapshot.add(snapshotValue(item, seenContainers, depth + 1)); + } + seenContainers.remove(list); + return new Value(Collections.unmodifiableList(snapshot)); } else if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (seenContainers.add(structure)) { - for (final String property : structure.keySet()) { - deque.push( - new FlattenEntry(entry.key + "." + property, structure.getValue(property))); + final Structure structure = value.asStructure(); + if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(structure)) { + return new Value(); } - } - } else { - result.put(entry.key, convertValue(value)); + final Map snapshot = new HashMap<>(); + for (final String key : structure.keySet()) { + snapshot.put(key, snapshotValue(structure.getValue(key), seenContainers, depth + 1)); + } + seenContainers.remove(structure); + return new Value(new ImmutableStructure(snapshot)); } - } - } - return result; - } - - private static Object convertValue(final Value value) { - if (value == null || value.isNull()) { - return null; - } else if (value.isBoolean()) { - return value.asBoolean(); - } else if (value.isNumber()) { - return value.asObject(); - } else if (value.isString()) { - return value.asString(); - } else if (value.isInstant()) { - return value.asInstant().toString(); - } - throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); - } - - // Reason-code bitmask constants used by copyPrunedContext to track which caps fired. - static final int REASON_MAX_CONTEXT_FIELDS = 1; - static final int REASON_MAX_KEY_LENGTH = 1 << 1; - static final int REASON_MAX_VALUE_LENGTH = 1 << 2; - static final int REASON_MAX_LIST_ELEMENTS = 1 << 3; - static final int REASON_MAX_STRUCTURE_PROPERTIES = 1 << 4; - static final int REASON_MAX_SNAPSHOT_DEPTH = 1 << 5; - static final int REASON_CYCLE = 1 << 6; - - /** Sorted reason-code strings, indexed by their bit position in the bitmask. */ - private static final String[] REASON_NAMES = { - "max_context_fields", - "max_key_length", - "max_value_length", - "max_list_elements", - "max_structure_properties", - "max_snapshot_depth", - "cycle", - }; - - /** - * Builds the sorted, comma-separated reason tag string from a bitmask of fired reason codes. - * Returns null when no reason bit is set (no truncation occurred). The returned string is ready - * to use directly as the "reason:..." tag value. - */ - static String truncationReasonTag(final int reasonMask) { - if (reasonMask == 0) { - return null; - } - final StringBuilder sb = new StringBuilder(); - for (int bit = 0; bit < REASON_NAMES.length; bit++) { - if ((reasonMask & (1 << bit)) != 0) { - if (sb.length() > 0) { - sb.append(','); + throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); + } + + static AbstractMap flattenValues(final Map values) { + final HashMap result = new HashMap<>(); + final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final Map.Entry root : values.entrySet()) { + final Deque deque = new LinkedList<>(); + deque.push(new FlattenEntry(root.getKey(), root.getValue())); + while (!deque.isEmpty()) { + final FlattenEntry entry = deque.pop(); + final Value value = entry.value; + if (value == null) { + result.put(entry.key, null); + } else if (value.isList()) { + final List list = value.asList(); + if (seenContainers.add(list)) { + for (int i = 0; i < list.size(); i++) { + deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i))); + } + } + } else if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (seenContainers.add(structure)) { + for (final String property : structure.keySet()) { + deque.push(new FlattenEntry(entry.key + "." + property, structure.getValue(property))); + } + } + } else { + result.put(entry.key, convertValue(value)); + } + } } - sb.append(REASON_NAMES[bit]); - } - } - return sb.toString(); - } - - /** - * Result of copyPrunedContext: the pruned attribute map plus an optional reason tag describing - * which caps fired during the walk. truncatedReason is null when no truncation occurred, so - * callers can skip the telemetry path with a single null check. - */ - static final class CopyResult { - final Map attrs; - - /** Non-null when at least one cap fired; ready to use as the "reason:..." tag value. */ - final String truncatedReason; - - CopyResult(final Map attrs, final String truncatedReason) { - this.attrs = attrs; - this.truncatedReason = truncatedReason; - } - } - - /** - * Single-pass bounded copy of a caller-owned EvaluationContext into the flattened, pruned map - * stored on a FlagEvalEvent and later canonicalized by the aggregator. - * - *

Every retained-size dimension is capped inline so the hot path performs work proportional to - * what is kept, never to what the caller supplied: MAX_CONTEXT_FIELDS - stop iterating the - * top-level context past this many retained fields MAX_KEY_LENGTH - skip fields whose flattened - * key exceeds this length (also enforced on every path segment produced by descending into - * lists/structures) MAX_VALUE_LENGTH - skip string values exceeding this length MAX_LIST_ELEMENTS - * - stop iterating a list past this many elements MAX_STRUCTURE_PROPERTIES - stop iterating a - * structure past this many properties MAX_SNAPSHOT_DEPTH - stop descending into lists/structures - * past this depth Cycle guard - identity-tracked containers currently on the recursion stack are - * treated as leaves - * - *

All numeric limits are named constants so they can be tuned independently. - * - *

Returns a CopyResult whose attrs is an empty map for null/empty input and whose - * truncatedReason is non-null when at least one cap fired. The returned map is a plain HashMap; - * canonical-key sorting happens once in the aggregator, off the hot path. - */ - static CopyResult copyPrunedContext(final EvaluationContext context) { - if (context == null) { - return new CopyResult(Collections.emptyMap(), null); - } - final Set keys = context.keySet(); - if (keys.isEmpty()) { - return new CopyResult(Collections.emptyMap(), null); - } - final HashMap out = new HashMap<>(); - final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); - final int[] reasonMask = {0}; - for (final String key : keys) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; - } - if (EvaluationContext.TARGETING_KEY.equals(key)) { - continue; - } - copyPrunedValue(out, key, context.getValue(key), seen, 0, reasonMask); - } - final Map attrs = out.isEmpty() ? Collections.emptyMap() : out; - return new CopyResult(attrs, truncationReasonTag(reasonMask[0])); - } - - private static void copyPrunedValue( - final Map out, - final String key, - final Value value, - final Set seen, - final int depth, - final int[] reasonMask) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - return; - } - if (key.length() > MAX_KEY_LENGTH) { - reasonMask[0] |= REASON_MAX_KEY_LENGTH; - return; - } - if (value == null || value.isNull()) { - out.put(key, null); - return; + return result; + } + + private static Object convertValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } else if (value.isBoolean()) { + return value.asBoolean(); + } else if (value.isNumber()) { + return value.asObject(); + } else if (value.isString()) { + return value.asString(); + } else if (value.isInstant()) { + return value.asInstant().toString(); + } + throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); + } + + // Reason-code bitmask constants used by copyPrunedContext to track which caps fired. + static final int REASON_MAX_CONTEXT_FIELDS = 1; + static final int REASON_MAX_KEY_LENGTH = 1 << 1; + static final int REASON_MAX_VALUE_LENGTH = 1 << 2; + static final int REASON_MAX_LIST_ELEMENTS = 1 << 3; + static final int REASON_MAX_STRUCTURE_PROPERTIES = 1 << 4; + static final int REASON_MAX_SNAPSHOT_DEPTH = 1 << 5; + static final int REASON_CYCLE = 1 << 6; + + /** Sorted reason-code strings, indexed by their bit position in the bitmask. */ + private static final String[] REASON_NAMES = { + "max_context_fields", + "max_key_length", + "max_value_length", + "max_list_elements", + "max_structure_properties", + "max_snapshot_depth", + "cycle", + }; + + /** + * Builds the sorted, comma-separated reason tag string from a bitmask of fired reason codes. + * Returns null when no reason bit is set (no truncation occurred). The returned string is ready + * to use directly as the "reason:..." tag value. + */ + static String truncationReasonTag(final int reasonMask) { + if (reasonMask == 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (int bit = 0; bit < REASON_NAMES.length; bit++) { + if ((reasonMask & (1 << bit)) != 0) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(REASON_NAMES[bit]); + } + } + return sb.toString(); } - if (value.isString()) { - final String s = value.asString(); - if (s.length() > MAX_VALUE_LENGTH) { - reasonMask[0] |= REASON_MAX_VALUE_LENGTH; - return; - } - out.put(key, s); - return; + + /** + * Result of copyPrunedContext: the pruned attribute map plus an optional reason tag describing + * which caps fired during the walk. truncatedReason is null when no truncation occurred, so + * callers can skip the telemetry path with a single null check. + */ + static final class CopyResult { + final Map attrs; + + /** Non-null when at least one cap fired; ready to use as the "reason:..." tag value. */ + final String truncatedReason; + + CopyResult(final Map attrs, final String truncatedReason) { + this.attrs = attrs; + this.truncatedReason = truncatedReason; + } } - if (value.isBoolean() || value.isNumber() || value.isInstant()) { - out.put(key, convertValue(value)); - return; + + /** + * Single-pass bounded copy of a caller-owned EvaluationContext into the flattened, pruned map + * stored on a FlagEvalEvent and later canonicalized by the aggregator. + * + *

Every retained-size dimension is capped inline so the hot path performs work proportional to + * what is kept, never to what the caller supplied: MAX_CONTEXT_FIELDS - stop iterating the + * top-level context past this many retained fields MAX_KEY_LENGTH - skip fields whose flattened + * key exceeds this length (also enforced on every path segment produced by descending into + * lists/structures) MAX_VALUE_LENGTH - skip string values exceeding this length MAX_LIST_ELEMENTS + * - stop iterating a list past this many elements MAX_STRUCTURE_PROPERTIES - stop iterating a + * structure past this many properties MAX_SNAPSHOT_DEPTH - stop descending into lists/structures + * past this depth Cycle guard - identity-tracked containers currently on the recursion stack are + * treated as leaves + * + *

All numeric limits are named constants so they can be tuned independently. + * + *

Returns a CopyResult whose attrs is an empty map for null/empty input and whose + * truncatedReason is non-null when at least one cap fired. The returned map is a plain HashMap; + * canonical-key sorting happens once in the aggregator, off the hot path. + */ + static CopyResult copyPrunedContext(final EvaluationContext context) { + if (context == null) { + return new CopyResult(Collections.emptyMap(), null); + } + final Set keys = context.keySet(); + if (keys.isEmpty()) { + return new CopyResult(Collections.emptyMap(), null); + } + final HashMap out = new HashMap<>(); + final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + final int[] reasonMask = {0}; + for (final String key : keys) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + if (EvaluationContext.TARGETING_KEY.equals(key)) { + continue; + } + copyPrunedValue(out, key, context.getValue(key), seen, 0, reasonMask); + } + final Map attrs = out.isEmpty() ? Collections.emptyMap() : out; + return new CopyResult(attrs, truncationReasonTag(reasonMask[0])); } - if (value.isList()) { - final List list = value.asList(); - if (depth >= MAX_SNAPSHOT_DEPTH) { - reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; - return; - } - if (!seen.add(list)) { - reasonMask[0] |= REASON_CYCLE; - return; - } - if (list.size() > MAX_LIST_ELEMENTS) { - reasonMask[0] |= REASON_MAX_LIST_ELEMENTS; - } - final int limit = Math.min(list.size(), MAX_LIST_ELEMENTS); - for (int i = 0; i < limit; i++) { + + private static void copyPrunedValue( + final Map out, + final String key, + final Value value, + final Set seen, + final int depth, + final int[] reasonMask) { if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + return; } - copyPrunedValue(out, key + "[" + i + "]", list.get(i), seen, depth + 1, reasonMask); - } - seen.remove(list); - return; - } - if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (depth >= MAX_SNAPSHOT_DEPTH) { - reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; - return; - } - if (!seen.add(structure)) { - reasonMask[0] |= REASON_CYCLE; - return; - } - int walked = 0; - for (final String property : structure.keySet()) { - if (walked >= MAX_STRUCTURE_PROPERTIES) { - reasonMask[0] |= REASON_MAX_STRUCTURE_PROPERTIES; - break; + if (key.length() > MAX_KEY_LENGTH) { + reasonMask[0] |= REASON_MAX_KEY_LENGTH; + return; } - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; + if (value == null || value.isNull()) { + out.put(key, null); + return; + } + if (value.isString()) { + final String s = value.asString(); + if (s.length() > MAX_VALUE_LENGTH) { + reasonMask[0] |= REASON_MAX_VALUE_LENGTH; + return; + } + out.put(key, s); + return; + } + if (value.isBoolean() || value.isNumber() || value.isInstant()) { + out.put(key, convertValue(value)); + return; + } + if (value.isList()) { + final List list = value.asList(); + if (depth >= MAX_SNAPSHOT_DEPTH) { + reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; + return; + } + if (!seen.add(list)) { + reasonMask[0] |= REASON_CYCLE; + return; + } + if (list.size() > MAX_LIST_ELEMENTS) { + reasonMask[0] |= REASON_MAX_LIST_ELEMENTS; + } + final int limit = Math.min(list.size(), MAX_LIST_ELEMENTS); + for (int i = 0; i < limit; i++) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + copyPrunedValue(out, key + "[" + i + "]", list.get(i), seen, depth + 1, reasonMask); + } + seen.remove(list); + return; + } + if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (depth >= MAX_SNAPSHOT_DEPTH) { + reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; + return; + } + if (!seen.add(structure)) { + reasonMask[0] |= REASON_CYCLE; + return; + } + int walked = 0; + for (final String property : structure.keySet()) { + if (walked >= MAX_STRUCTURE_PROPERTIES) { + reasonMask[0] |= REASON_MAX_STRUCTURE_PROPERTIES; + break; + } + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + walked++; + copyPrunedValue(out, key + "." + property, structure.getValue(property), seen, depth + 1, reasonMask); + } + seen.remove(structure); } - walked++; - copyPrunedValue( - out, key + "." + property, structure.getValue(property), seen, depth + 1, reasonMask); - } - seen.remove(structure); } - } - @FunctionalInterface - private interface NumberComparator { - boolean compare(double a, double b); - } + @FunctionalInterface + private interface NumberComparator { + boolean compare(double a, double b); + } - @FunctionalInterface - private interface SemverComparator { - boolean compare(int ordering); - } + @FunctionalInterface + private interface SemverComparator { + boolean compare(int ordering); + } - private static class FlattenEntry { - private final String key; - private final Value value; + private static class FlattenEntry { + private final String key; + private final Value value; - private FlattenEntry(final String key, final Value value) { - this.key = key; - this.value = value; + private FlattenEntry(final String key, final Value value) { + this.key = key; + this.value = value; + } } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java index f4c9cacffdb..e4ea53059ca 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java @@ -6,12 +6,11 @@ interface Evaluator { - boolean initialize(long timeout, TimeUnit timeUnit, EvaluationContext context) throws Exception; + boolean initialize(long timeout, TimeUnit timeUnit, EvaluationContext context) throws Exception; - boolean hasConfiguration(); + boolean hasConfiguration(); - void shutdown(); + void shutdown(); - ProviderEvaluation evaluate( - Class target, String key, T defaultValue, EvaluationContext context); + ProviderEvaluation evaluate(Class target, String key, T defaultValue, EvaluationContext context); } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java index 322f11ac9e1..d7ea4fc19e7 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java @@ -33,136 +33,131 @@ */ class FlagEvalLoggingHook implements Hook { - /** - * Singleton instance: always registered when the provider is created; harmless when writer=null - * (killswitch off or not yet started). - */ - static final FlagEvalLoggingHook INSTANCE = new FlagEvalLoggingHook<>(); - - /** - * Writer resolver. Production instances resolve through FeatureFlaggingGateway; tests can inject - * a direct writer or a resolver that simulates old-bootstrap linkage failures. - */ - private final Supplier writerSupplier; - - /** Production constructor - resolves writer from gateway. */ - FlagEvalLoggingHook() { - this(FeatureFlaggingGateway::getFlagEvalWriter); - } - - /** Test-only constructor - injects a writer directly, bypassing the gateway. */ - FlagEvalLoggingHook(final FlagEvaluationWriter writer) { - this(() -> writer); - } - - /** Test-only constructor - injects a writer resolver directly, bypassing the gateway. */ - FlagEvalLoggingHook(final Supplier writerSupplier) { - this.writerSupplier = writerSupplier; - } - - /** - * Capture + non-blocking enqueue only; no flattening, aggregation, or I/O. Runs at the finally - * stage so it covers success, error, and default-value paths. - * - *

The context snapshot taken here scales with context size/nesting - see the class javadoc for - * why it cannot be deferred. - */ - @Override - public void finallyAfter( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - try { - if (details == null) { - return; - } - if (!FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()) { - return; - } - - final FlagEvaluationWriter w = writerSupplier.get(); - if (w == null) { - return; - } - - // Pre-queue guard: if the queue is already saturated, avoid the context-copy work whose - // result would be discarded on offer(). Best-effort — the queue can still be full at - // offer() time — but flips a full snapshot into an O(1) check on the drop path. - if (!w.hasCapacityForEnqueue()) { - w.countPreQueueOverflow(); - return; - } - - // Scalar extraction - individual typed metadata reads, no JSON, no flattening - final String flagKey = details.getFlagKey(); - final ImmutableMetadata metadata = details.getFlagMetadata(); - - // allocationKey: "allocationKey" (camelCase) - consistent with FlagEvalMetricsHook.java - final String allocationKey = metadata != null ? metadata.getString("allocationKey") : null; - - // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. - // ImmutableMetadata.getLong available since sdk 1.4+. - final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null; - final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); - - // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the - // evaluated value. A null variant means no variant was selected (runtime default). - final String variant = details.getVariant(); - - // targetingKey from evaluation context - final String targetingKey = - ctx != null && ctx.getCtx() != null ? ctx.getCtx().getTargetingKey() : null; - - // Consent is read from metadata stamped by DDEvaluator (pinned to its ServerConfiguration). - // Missing key = non-DD provider → false, the privacy-preserving default. - final Boolean consentFromMetadata = - metadata != null - ? metadata.getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA) - : null; - final boolean observeFullEvaluationData = consentFromMetadata != null && consentFromMetadata; - - // Error message: prefer the human-readable message under consent-on; under consent-off the - // provider's raw message can echo evaluation-context values (e.g. NumberFormatException: - // "For input string: \"jane.doe@...\""), so replace it with the ErrorCode name — a stable, - // PII-free signal. Same substitution path is used when the message is absent regardless of - // consent (some providers populate only the code). Null on success. - String errorMessage = observeFullEvaluationData ? details.getErrorMessage() : null; - if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) { - errorMessage = details.getErrorCode().name(); - } - if (errorMessage != null && errorMessage.isEmpty()) { - errorMessage = null; - } - - // On the protected path (consent-off) the evaluation context is dropped on emit and never - // consulted by the aggregator, so skip the bounded copy entirely — the copy cost only - // applies under consent-on. - final Map attrs; - if (observeFullEvaluationData && ctx != null && ctx.getCtx() != null) { - // Bounded copy of the caller's mutable context (see DDEvaluator.copyPrunedContext for - // every retained-size cap). Runs inline because the event is consumed asynchronously - // and the source context is caller-owned; work is proportional to what is retained. - final DDEvaluator.CopyResult copy = DDEvaluator.copyPrunedContext(ctx.getCtx()); - if (copy.truncatedReason != null) { - w.countContextTruncated(copy.truncatedReason); + /** + * Singleton instance: always registered when the provider is created; harmless when writer=null + * (killswitch off or not yet started). + */ + static final FlagEvalLoggingHook INSTANCE = new FlagEvalLoggingHook<>(); + + /** + * Writer resolver. Production instances resolve through FeatureFlaggingGateway; tests can inject + * a direct writer or a resolver that simulates old-bootstrap linkage failures. + */ + private final Supplier writerSupplier; + + /** Production constructor - resolves writer from gateway. */ + FlagEvalLoggingHook() { + this(FeatureFlaggingGateway::getFlagEvalWriter); + } + + /** Test-only constructor - injects a writer directly, bypassing the gateway. */ + FlagEvalLoggingHook(final FlagEvaluationWriter writer) { + this(() -> writer); + } + + /** Test-only constructor - injects a writer resolver directly, bypassing the gateway. */ + FlagEvalLoggingHook(final Supplier writerSupplier) { + this.writerSupplier = writerSupplier; + } + + /** + * Capture + non-blocking enqueue only; no flattening, aggregation, or I/O. Runs at the finally + * stage so it covers success, error, and default-value paths. + * + *

The context snapshot taken here scales with context size/nesting - see the class javadoc for + * why it cannot be deferred. + */ + @Override + public void finallyAfter( + final HookContext ctx, final FlagEvaluationDetails details, final Map hints) { + try { + if (details == null) { + return; + } + if (!FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()) { + return; + } + + final FlagEvaluationWriter w = writerSupplier.get(); + if (w == null) { + return; + } + + // Pre-queue guard: if the queue is already saturated, avoid the context-copy work whose + // result would be discarded on offer(). Best-effort — the queue can still be full at + // offer() time — but flips a full snapshot into an O(1) check on the drop path. + if (!w.hasCapacityForEnqueue()) { + w.countPreQueueOverflow(); + return; + } + + // Scalar extraction - individual typed metadata reads, no JSON, no flattening + final String flagKey = details.getFlagKey(); + final ImmutableMetadata metadata = details.getFlagMetadata(); + + // allocationKey: "allocationKey" (camelCase) - consistent with FlagEvalMetricsHook.java + final String allocationKey = metadata != null ? metadata.getString("allocationKey") : null; + + // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. + // ImmutableMetadata.getLong available since sdk 1.4+. + final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null; + final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); + + // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the + // evaluated value. A null variant means no variant was selected (runtime default). + final String variant = details.getVariant(); + + // targetingKey from evaluation context + final String targetingKey = + ctx != null && ctx.getCtx() != null ? ctx.getCtx().getTargetingKey() : null; + + // Consent is read from metadata stamped by DDEvaluator (pinned to its ServerConfiguration). + // Missing key = non-DD provider → false, the privacy-preserving default. + final Boolean consentFromMetadata = + metadata != null ? metadata.getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA) : null; + final boolean observeFullEvaluationData = consentFromMetadata != null && consentFromMetadata; + + // Error message: prefer the human-readable message under consent-on; under consent-off the + // provider's raw message can echo evaluation-context values (e.g. NumberFormatException: + // "For input string: \"jane.doe@...\""), so replace it with the ErrorCode name — a stable, + // PII-free signal. Same substitution path is used when the message is absent regardless of + // consent (some providers populate only the code). Null on success. + String errorMessage = observeFullEvaluationData ? details.getErrorMessage() : null; + if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) { + errorMessage = details.getErrorCode().name(); + } + if (errorMessage != null && errorMessage.isEmpty()) { + errorMessage = null; + } + + // On the protected path (consent-off) the evaluation context is dropped on emit and never + // consulted by the aggregator, so skip the bounded copy entirely — the copy cost only + // applies under consent-on. + final Map attrs; + if (observeFullEvaluationData && ctx != null && ctx.getCtx() != null) { + // Bounded copy of the caller's mutable context (see DDEvaluator.copyPrunedContext for + // every retained-size cap). Runs inline because the event is consumed asynchronously + // and the source context is caller-owned; work is proportional to what is retained. + final DDEvaluator.CopyResult copy = DDEvaluator.copyPrunedContext(ctx.getCtx()); + if (copy.truncatedReason != null) { + w.countContextTruncated(copy.truncatedReason); + } + attrs = copy.attrs; + } else { + attrs = Collections.emptyMap(); + } + + w.enqueue(new FlagEvalEvent( + flagKey, + variant, + allocationKey, + targetingKey, + errorMessage, + evalTimeMs, + observeFullEvaluationData, + attrs)); + } catch (LinkageError e) { + // Never let EVP recording break flag evaluation } - attrs = copy.attrs; - } else { - attrs = Collections.emptyMap(); - } - - w.enqueue( - new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - errorMessage, - evalTimeMs, - observeFullEvaluationData, - attrs)); - } catch (LinkageError e) { - // Never let EVP recording break flag evaluation } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java index eba3b7ba4b5..ba5c7c53a48 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java @@ -13,87 +13,83 @@ class FlagEvalMetrics implements Closeable { - private static final Logger log = LoggerFactory.getLogger(FlagEvalMetrics.class); + private static final Logger log = LoggerFactory.getLogger(FlagEvalMetrics.class); - private static final String METER_NAME = "ddtrace.openfeature"; - private static final String METRIC_NAME = "feature_flag.evaluations"; - private static final String METRIC_UNIT = "{evaluation}"; - private static final String METRIC_DESC = "Number of feature flag evaluations"; + private static final String METER_NAME = "ddtrace.openfeature"; + private static final String METRIC_NAME = "feature_flag.evaluations"; + private static final String METRIC_UNIT = "{evaluation}"; + private static final String METRIC_DESC = "Number of feature flag evaluations"; - private static final AttributeKey ATTR_FLAG_KEY = - AttributeKey.stringKey("feature_flag.key"); - private static final AttributeKey ATTR_VARIANT = - AttributeKey.stringKey("feature_flag.result.variant"); - private static final AttributeKey ATTR_REASON = - AttributeKey.stringKey("feature_flag.result.reason"); - private static final AttributeKey ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type"); - private static final AttributeKey ATTR_ALLOCATION_KEY = - AttributeKey.stringKey("feature_flag.result.allocation_key"); + private static final AttributeKey ATTR_FLAG_KEY = AttributeKey.stringKey("feature_flag.key"); + private static final AttributeKey ATTR_VARIANT = AttributeKey.stringKey("feature_flag.result.variant"); + private static final AttributeKey ATTR_REASON = AttributeKey.stringKey("feature_flag.result.reason"); + private static final AttributeKey ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type"); + private static final AttributeKey ATTR_ALLOCATION_KEY = + AttributeKey.stringKey("feature_flag.result.allocation_key"); - private volatile LongCounter counter; + private volatile LongCounter counter; - FlagEvalMetrics() { - try { - Meter meter = GlobalOpenTelemetry.get().getMeterProvider().meterBuilder(METER_NAME).build(); - counter = - meter - .counterBuilder(METRIC_NAME) - .setUnit(METRIC_UNIT) - .setDescription(METRIC_DESC) - .build(); + FlagEvalMetrics() { + try { + Meter meter = GlobalOpenTelemetry.get() + .getMeterProvider() + .meterBuilder(METER_NAME) + .build(); + counter = meter.counterBuilder(METRIC_NAME) + .setUnit(METRIC_UNIT) + .setDescription(METRIC_DESC) + .build(); - log.debug("Flag evaluation metrics initialized"); - } catch (NoClassDefFoundError e) { - log.error( - "OpenTelemetry API is not on the classpath — evaluation metrics disabled. Add" - + " opentelemetry-api to your dependencies and enable DD_METRICS_OTEL_ENABLED to" - + " export flag evaluation metrics through the Datadog Java agent.", - e); - counter = null; - } catch (Exception e) { - log.error("Failed to initialize flag evaluation metrics", e); - counter = null; + log.debug("Flag evaluation metrics initialized"); + } catch (NoClassDefFoundError e) { + log.error( + "OpenTelemetry API is not on the classpath — evaluation metrics disabled. Add" + + " opentelemetry-api to your dependencies and enable DD_METRICS_OTEL_ENABLED to" + + " export flag evaluation metrics through the Datadog Java agent.", + e); + counter = null; + } catch (Exception e) { + log.error("Failed to initialize flag evaluation metrics", e); + counter = null; + } } - } - /** Package-private constructor for testing with a mock counter. */ - FlagEvalMetrics(LongCounter counter) { - this.counter = counter; - } - - void record( - String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) { - LongCounter c = counter; - if (c == null) { - return; + /** Package-private constructor for testing with a mock counter. */ + FlagEvalMetrics(LongCounter counter) { + this.counter = counter; } - try { - AttributesBuilder builder = - Attributes.builder() - .put(ATTR_FLAG_KEY, flagKey) - .put(ATTR_VARIANT, variant != null ? variant : "") - .put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown"); - if (errorCode != null) { - builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase()); - } + void record(String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) { + LongCounter c = counter; + if (c == null) { + return; + } + try { + AttributesBuilder builder = Attributes.builder() + .put(ATTR_FLAG_KEY, flagKey) + .put(ATTR_VARIANT, variant != null ? variant : "") + .put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown"); + + if (errorCode != null) { + builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase()); + } - if (allocationKey != null && !allocationKey.isEmpty()) { - builder.put(ATTR_ALLOCATION_KEY, allocationKey); - } + if (allocationKey != null && !allocationKey.isEmpty()) { + builder.put(ATTR_ALLOCATION_KEY, allocationKey); + } - c.add(1, builder.build()); - } catch (Exception e) { - log.debug("Failed to record flag evaluation metric for {}", flagKey, e); + c.add(1, builder.build()); + } catch (Exception e) { + log.debug("Failed to record flag evaluation metric for {}", flagKey, e); + } } - } - @Override - public void close() { - shutdown(); - } + @Override + public void close() { + shutdown(); + } - void shutdown() { - counter = null; - } + void shutdown() { + counter = null; + } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java index a3e0dbfc83c..cb84f01b1a8 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java @@ -9,33 +9,33 @@ class FlagEvalMetricsHook implements Hook { - private final FlagEvalMetrics metrics; + private final FlagEvalMetrics metrics; - FlagEvalMetricsHook(FlagEvalMetrics metrics) { - this.metrics = metrics; - } - - @Override - public void finallyAfter( - HookContext ctx, FlagEvaluationDetails details, Map hints) { - if (metrics == null || details == null) { - return; + FlagEvalMetricsHook(FlagEvalMetrics metrics) { + this.metrics = metrics; } - try { - String flagKey = details.getFlagKey(); - String variant = details.getVariant(); - String reason = details.getReason(); - ErrorCode errorCode = details.getErrorCode(); - String allocationKey = null; - ImmutableMetadata metadata = details.getFlagMetadata(); - if (metadata != null) { - allocationKey = metadata.getString("allocationKey"); - } + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + if (metrics == null || details == null) { + return; + } + try { + String flagKey = details.getFlagKey(); + String variant = details.getVariant(); + String reason = details.getReason(); + ErrorCode errorCode = details.getErrorCode(); + + String allocationKey = null; + ImmutableMetadata metadata = details.getFlagMetadata(); + if (metadata != null) { + allocationKey = metadata.getString("allocationKey"); + } - metrics.record(flagKey, variant, reason, errorCode, allocationKey); - } catch (Exception e) { - // Never let metrics recording break flag evaluation + metrics.record(flagKey, variant, reason, errorCode, allocationKey); + } catch (Exception e) { + // Never let metrics recording break flag evaluation + } } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 17009d9933a..d02bf51b64d 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -26,298 +26,289 @@ public class Provider extends EventProvider implements Metadata { - private static final Logger log = LoggerFactory.getLogger(Provider.class); - static final String METADATA = "datadog-openfeature-provider"; - private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; - - private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); - private volatile Evaluator evaluator; - private final Options options; - private final AtomicReference initializationState = - new AtomicReference<>(InitializationState.NOT_STARTED); - private final FlagEvalMetrics flagEvalMetrics; - private final FlagEvalMetricsHook flagEvalMetricsHook; - // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. - private final SpanEnrichmentHook spanEnrichmentHook; - // Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built - // once so gate-off evaluation allocates nothing on this hot path. - private final List providerHooks; - - public Provider() { - this(DEFAULT_OPTIONS, null); - } - - public Provider(final Options options) { - this(options, null); - } - - Provider(final Options options, final Evaluator evaluator) { - this(options, evaluator, null); - } - - /** - * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test - * seam); when null, the gate is read via {@link SpanEnrichmentGate}. - */ - Provider( - final Options options, - final Evaluator evaluator, - final Boolean spanEnrichmentEnabledOverride) { - this.options = options; - this.evaluator = evaluator; - FlagEvalMetrics metrics = null; - FlagEvalMetricsHook hook = null; - try { - metrics = new FlagEvalMetrics(); - hook = new FlagEvalMetricsHook(metrics); - } catch (LinkageError | Exception e) { - // This outer catch fires when the metrics helper itself can't load (OTel API absent). - log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); + private static final Logger log = LoggerFactory.getLogger(Provider.class); + static final String METADATA = "datadog-openfeature-provider"; + private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; + + private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); + private volatile Evaluator evaluator; + private final Options options; + private final AtomicReference initializationState = + new AtomicReference<>(InitializationState.NOT_STARTED); + private final FlagEvalMetrics flagEvalMetrics; + private final FlagEvalMetricsHook flagEvalMetricsHook; + // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. + private final SpanEnrichmentHook spanEnrichmentHook; + // Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built + // once so gate-off evaluation allocates nothing on this hot path. + private final List providerHooks; + + public Provider() { + this(DEFAULT_OPTIONS, null); } - this.flagEvalMetrics = metrics; - this.flagEvalMetricsHook = hook; - - // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle - // per-evaluation overhead. - final boolean spanEnrichmentEnabled = - spanEnrichmentEnabledOverride != null - ? spanEnrichmentEnabledOverride - : SpanEnrichmentGate.isEnabled(); - this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; - - // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) - // allocates nothing, including when the gate is off. - final List hooks = new ArrayList<>(3); - if (flagEvalMetricsHook != null) { - hooks.add(flagEvalMetricsHook); + + public Provider(final Options options) { + this(options, null); + } + + Provider(final Options options, final Evaluator evaluator) { + this(options, evaluator, null); + } + + /** + * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test + * seam); when null, the gate is read via {@link SpanEnrichmentGate}. + */ + Provider(final Options options, final Evaluator evaluator, final Boolean spanEnrichmentEnabledOverride) { + this.options = options; + this.evaluator = evaluator; + FlagEvalMetrics metrics = null; + FlagEvalMetricsHook hook = null; + try { + metrics = new FlagEvalMetrics(); + hook = new FlagEvalMetricsHook(metrics); + } catch (LinkageError | Exception e) { + // This outer catch fires when the metrics helper itself can't load (OTel API absent). + log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); + } + this.flagEvalMetrics = metrics; + this.flagEvalMetricsHook = hook; + + // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle + // per-evaluation overhead. + final boolean spanEnrichmentEnabled = + spanEnrichmentEnabledOverride != null ? spanEnrichmentEnabledOverride : SpanEnrichmentGate.isEnabled(); + this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; + + // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) + // allocates nothing, including when the gate is off. + final List hooks = new ArrayList<>(3); + if (flagEvalMetricsHook != null) { + hooks.add(flagEvalMetricsHook); + } + // EVP flagevaluation hook: always registered; no-op when writer is absent (killswitch off). + // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. + try { + final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); + if (flagEvalLoggingHook != null) { + hooks.add(flagEvalLoggingHook); + } + } catch (LinkageError | Exception e) { + // Keep older bootstrap/API combinations working: EVP recording is best-effort. + } + if (spanEnrichmentHook != null) { + hooks.add(spanEnrichmentHook); + } + this.providerHooks = hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); + + // Announce the span-enrichment state at startup (matches the reference implementation). + // "enabled" only when the gate is on (the capture hook was constructed), otherwise "disabled". + if (spanEnrichmentHook != null) { + log.info("{} span enrichment enabled", METADATA); + } else { + log.info("{} span enrichment disabled", METADATA); + } + } + + @Override + public void initialize(final EvaluationContext context) throws Exception { + initializationState.set(InitializationState.INITIALIZING); + try { + evaluator = buildEvaluator(); + if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { + if (markInitialConfigReceivedReady()) { + return; + } + markInitializationError(); + throw new ProviderNotReadyError("Provider timed-out while waiting for initial configuration"); + } + if (!evaluator.hasConfiguration() || !markSuccessfulInitializationReady()) { + markInitializationError(); + throw new ProviderNotReadyError("Provider timed-out while waiting for initial configuration"); + } + } catch (final OpenFeatureError e) { + markInitializationError(); + throw e; + } catch (final Throwable e) { + markInitializationError(); + throw new FatalError("Failed to initialize provider, is the tracer configured?", e); + } + } + + void onConfigurationChange() { + if (evaluator == null || !evaluator.hasConfiguration()) { + onConfigurationUnavailable(); + return; + } + + final InitializationState state = initializationState.get(); + if (state == InitializationState.INITIALIZING) { + initializationState.compareAndSet( + InitializationState.INITIALIZING, InitializationState.INITIAL_CONFIG_RECEIVED); + return; + } + if (state == InitializationState.INITIAL_CONFIG_RECEIVED) { + return; + } + if (state == InitializationState.ERROR + && initializationState.compareAndSet(InitializationState.ERROR, InitializationState.READY)) { + emit( + ProviderEvent.PROVIDER_READY, + ProviderEventDetails.builder().message("Provider ready").build()); + return; + } + if (initializationState.get() != InitializationState.READY) { + return; + } + emit( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + ProviderEventDetails.builder() + .message("New configuration received") + .build()); + } + + private void onConfigurationUnavailable() { + if (initializationState.compareAndSet(InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.ERROR)) { + return; + } + if (!initializationState.compareAndSet(InitializationState.READY, InitializationState.ERROR)) { + return; + } + emit( + ProviderEvent.PROVIDER_ERROR, + ProviderEventDetails.builder() + .message("Configuration unavailable") + .errorCode(ErrorCode.PROVIDER_NOT_READY) + .build()); } - // EVP flagevaluation hook: always registered; no-op when writer is absent (killswitch off). - // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. - try { - final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); - if (flagEvalLoggingHook != null) { - hooks.add(flagEvalLoggingHook); - } - } catch (LinkageError | Exception e) { - // Keep older bootstrap/API combinations working: EVP recording is best-effort. + + private boolean markInitialConfigReceivedReady() { + return initializationState.get() == InitializationState.READY + || initializationState.compareAndSet( + InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.READY); } - if (spanEnrichmentHook != null) { - hooks.add(spanEnrichmentHook); + + private boolean markSuccessfulInitializationReady() { + return markInitialConfigReceivedReady() + || initializationState.compareAndSet(InitializationState.INITIALIZING, InitializationState.READY); } - this.providerHooks = - hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); - - // Announce the span-enrichment state at startup (matches the reference implementation). - // "enabled" only when the gate is on (the capture hook was constructed), otherwise "disabled". - if (spanEnrichmentHook != null) { - log.info("{} span enrichment enabled", METADATA); - } else { - log.info("{} span enrichment disabled", METADATA); + + private void markInitializationError() { + InitializationState state = initializationState.get(); + while (state != InitializationState.READY && state != InitializationState.ERROR) { + if (initializationState.compareAndSet(state, InitializationState.ERROR)) { + return; + } + state = initializationState.get(); + } } - } - - @Override - public void initialize(final EvaluationContext context) throws Exception { - initializationState.set(InitializationState.INITIALIZING); - try { - evaluator = buildEvaluator(); - if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { - if (markInitialConfigReceivedReady()) { - return; + + private Evaluator buildEvaluator() throws Exception { + if (evaluator != null) { + return evaluator; } - markInitializationError(); - throw new ProviderNotReadyError( - "Provider timed-out while waiting for initial configuration"); - } - if (!evaluator.hasConfiguration() || !markSuccessfulInitializationReady()) { - markInitializationError(); - throw new ProviderNotReadyError( - "Provider timed-out while waiting for initial configuration"); - } - } catch (final OpenFeatureError e) { - markInitializationError(); - throw e; - } catch (final Throwable e) { - markInitializationError(); - throw new FatalError("Failed to initialize provider, is the tracer configured?", e); + final Class evaluatorClass = loadEvaluatorClass(); + final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); + return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); } - } - void onConfigurationChange() { - if (evaluator == null || !evaluator.hasConfiguration()) { - onConfigurationUnavailable(); - return; + @Override + public List getProviderHooks() { + return providerHooks; } - final InitializationState state = initializationState.get(); - if (state == InitializationState.INITIALIZING) { - initializationState.compareAndSet( - InitializationState.INITIALIZING, InitializationState.INITIAL_CONFIG_RECEIVED); - return; + Hook buildFlagEvalLoggingHook() { + return FlagEvalLoggingHook.INSTANCE; } - if (state == InitializationState.INITIAL_CONFIG_RECEIVED) { - return; + + @Override + public void shutdown() { + if (flagEvalMetrics != null) { + flagEvalMetrics.shutdown(); + } + // Span enrichment needs no provider-close cleanup here: the capture hook holds no tracer state. + // The agent-side write tier owns the interceptor and per-trace state and is torn down with the + // feature-flagging subsystem, not per provider. + if (evaluator != null) { + evaluator.shutdown(); + } } - if (state == InitializationState.ERROR - && initializationState.compareAndSet( - InitializationState.ERROR, InitializationState.READY)) { - emit( - ProviderEvent.PROVIDER_READY, - ProviderEventDetails.builder().message("Provider ready").build()); - return; + + // Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impl. + SpanEnrichmentHook spanEnrichmentHook() { + return spanEnrichmentHook; } - if (initializationState.get() != InitializationState.READY) { - return; + + @Override + public Metadata getMetadata() { + return this; } - emit( - ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, - ProviderEventDetails.builder().message("New configuration received").build()); - } - - private void onConfigurationUnavailable() { - if (initializationState.compareAndSet( - InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.ERROR)) { - return; + + @Override + public String getName() { + return METADATA; } - if (!initializationState.compareAndSet(InitializationState.READY, InitializationState.ERROR)) { - return; + + @Override + public ProviderEvaluation getBooleanEvaluation( + final String key, final Boolean defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Boolean.class, key, defaultValue, ctx); } - emit( - ProviderEvent.PROVIDER_ERROR, - ProviderEventDetails.builder() - .message("Configuration unavailable") - .errorCode(ErrorCode.PROVIDER_NOT_READY) - .build()); - } - - private boolean markInitialConfigReceivedReady() { - return initializationState.get() == InitializationState.READY - || initializationState.compareAndSet( - InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.READY); - } - - private boolean markSuccessfulInitializationReady() { - return markInitialConfigReceivedReady() - || initializationState.compareAndSet( - InitializationState.INITIALIZING, InitializationState.READY); - } - - private void markInitializationError() { - InitializationState state = initializationState.get(); - while (state != InitializationState.READY && state != InitializationState.ERROR) { - if (initializationState.compareAndSet(state, InitializationState.ERROR)) { - return; - } - state = initializationState.get(); + + @Override + public ProviderEvaluation getStringEvaluation( + final String key, final String defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(String.class, key, defaultValue, ctx); } - } - private Evaluator buildEvaluator() throws Exception { - if (evaluator != null) { - return evaluator; + @Override + public ProviderEvaluation getIntegerEvaluation( + final String key, final Integer defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Integer.class, key, defaultValue, ctx); } - final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); - } - - @Override - public List getProviderHooks() { - return providerHooks; - } - - Hook buildFlagEvalLoggingHook() { - return FlagEvalLoggingHook.INSTANCE; - } - - @Override - public void shutdown() { - if (flagEvalMetrics != null) { - flagEvalMetrics.shutdown(); + + @Override + public ProviderEvaluation getDoubleEvaluation( + final String key, final Double defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Double.class, key, defaultValue, ctx); } - // Span enrichment needs no provider-close cleanup here: the capture hook holds no tracer state. - // The agent-side write tier owns the interceptor and per-trace state and is torn down with the - // feature-flagging subsystem, not per provider. - if (evaluator != null) { - evaluator.shutdown(); + + @Override + public ProviderEvaluation getObjectEvaluation( + final String key, final Value defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Value.class, key, defaultValue, ctx); } - } - - // Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impl. - SpanEnrichmentHook spanEnrichmentHook() { - return spanEnrichmentHook; - } - - @Override - public Metadata getMetadata() { - return this; - } - - @Override - public String getName() { - return METADATA; - } - - @Override - public ProviderEvaluation getBooleanEvaluation( - final String key, final Boolean defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(Boolean.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getStringEvaluation( - final String key, final String defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(String.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getIntegerEvaluation( - final String key, final Integer defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(Integer.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getDoubleEvaluation( - final String key, final Double defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(Double.class, key, defaultValue, ctx); - } - - @Override - public ProviderEvaluation getObjectEvaluation( - final String key, final Value defaultValue, final EvaluationContext ctx) { - return evaluator.evaluate(Value.class, key, defaultValue, ctx); - } - - @SuppressForbidden // Class#forName(String) used to lazy-load the evaluator implementation - protected Class loadEvaluatorClass() throws ClassNotFoundException { - return Class.forName(EVALUATOR_IMPL); - } - - private enum InitializationState { - NOT_STARTED, - INITIALIZING, - INITIAL_CONFIG_RECEIVED, - READY, - ERROR - } - - public static class Options { - - private long timeout; - private TimeUnit unit; - - public Options initTimeout(final long timeout, final TimeUnit unit) { - this.timeout = timeout; - this.unit = unit; - return this; + + @SuppressForbidden // Class#forName(String) used to lazy-load the evaluator implementation + protected Class loadEvaluatorClass() throws ClassNotFoundException { + return Class.forName(EVALUATOR_IMPL); } - public long getTimeout() { - return timeout; + private enum InitializationState { + NOT_STARTED, + INITIALIZING, + INITIAL_CONFIG_RECEIVED, + READY, + ERROR } - public TimeUnit getUnit() { - return unit; + public static class Options { + + private long timeout; + private TimeUnit unit; + + public Options initTimeout(final long timeout, final TimeUnit unit) { + this.timeout = timeout; + this.unit = unit; + return this; + } + + public long getTimeout() { + return timeout; + } + + public TimeUnit getUnit() { + return unit; + } } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java index 64739f0ad39..409eeedcea3 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java @@ -12,14 +12,14 @@ */ final class SpanEnrichmentGate { - private SpanEnrichmentGate() {} + private SpanEnrichmentGate() {} - static boolean isEnabled() { - try { - return ConfigProvider.getInstance() - .getBoolean(FeatureFlaggingConfig.EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED, false); - } catch (final Throwable t) { - return false; // never let config reading break construction + static boolean isEnabled() { + try { + return ConfigProvider.getInstance() + .getBoolean(FeatureFlaggingConfig.EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED, false); + } catch (final Throwable t) { + return false; // never let config reading break construction + } } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java index 40e998a6546..5e72027214d 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java @@ -37,113 +37,111 @@ */ class SpanEnrichmentHook implements Hook { - private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentHook.class); + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentHook.class); - // The metadata keys the DDEvaluator attaches for span enrichment (single source of truth there). - static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; - static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; + // The metadata keys the DDEvaluator attaches for span enrichment (single source of truth there). + static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; + static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; - @Override - public void finallyAfter( - final HookContext ctx, - final FlagEvaluationDetails details, - final Map hints) { - if (details == null) { - return; - } - try { - final ImmutableMetadata metadata = details.getFlagMetadata(); - final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; - if (serialId != null) { - final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); - FeatureFlaggingGateway.dispatch( - SpanEnrichmentEvent.serialId(serialId, doLog, targetingKey(ctx))); - } else if (details.getVariant() == null) { - // Runtime-default detection = MISSING VARIANT (never a reason enum). Unwrap any OpenFeature - // Value to a native Java type here so the seam carries only JDK types. - FeatureFlaggingGateway.dispatch( - SpanEnrichmentEvent.runtimeDefault( - details.getFlagKey(), unwrapDefaultValue(details.getValue()))); - } - } catch (final Throwable t) { - // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. - log.debug("Span-enrichment capture failed for flag {}", details.getFlagKey(), t); + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + if (details == null) { + return; + } + try { + final ImmutableMetadata metadata = details.getFlagMetadata(); + final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; + if (serialId != null) { + final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); + FeatureFlaggingGateway.dispatch(SpanEnrichmentEvent.serialId(serialId, doLog, targetingKey(ctx))); + } else if (details.getVariant() == null) { + // Runtime-default detection = MISSING VARIANT (never a reason enum). Unwrap any OpenFeature + // Value to a native Java type here so the seam carries only JDK types. + FeatureFlaggingGateway.dispatch(SpanEnrichmentEvent.runtimeDefault( + details.getFlagKey(), unwrapDefaultValue(details.getValue()))); + } + } catch (final Throwable t) { + // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. + log.debug("Span-enrichment capture failed for flag {}", details.getFlagKey(), t); + } } - } - private static String targetingKey(final HookContext ctx) { - if (ctx == null) { - return null; - } - final EvaluationContext evaluationContext = ctx.getCtx(); - if (evaluationContext == null) { - return null; + private static String targetingKey(final HookContext ctx) { + if (ctx == null) { + return null; + } + final EvaluationContext evaluationContext = ctx.getCtx(); + if (evaluationContext == null) { + return null; + } + final String key = evaluationContext.getTargetingKey(); + return (key == null || key.isEmpty()) ? null : key; } - final String key = evaluationContext.getTargetingKey(); - return (key == null || key.isEmpty()) ? null : key; - } - - /** - * Unwraps an OpenFeature {@link Value} to a native Java representation so the runtime default can - * cross the (JDK-types-only) seam and be JSON-serialized on the write side exactly like Node's - * {@code JSON.stringify}. Non-{@code Value} inputs (already native) are returned as-is. - */ - static Object unwrapDefaultValue(final Object value) { - return value instanceof Value ? unwrapValue((Value) value) : value; - } - /** - * Recursively unwraps an OpenFeature {@link Value} into its native Java representation: - * structures become {@code Map}, lists become {@code List}, and scalars - * become their boxed value (or {@code null}). Nested {@link Value}s are unwrapped at every level - * so a structure containing further structures/lists serializes correctly. - */ - private static Object unwrapValue(final Value value) { - if (value == null || value.isNull()) { - return null; + /** + * Unwraps an OpenFeature {@link Value} to a native Java representation so the runtime default can + * cross the (JDK-types-only) seam and be JSON-serialized on the write side exactly like Node's + * {@code JSON.stringify}. Non-{@code Value} inputs (already native) are returned as-is. + */ + static Object unwrapDefaultValue(final Object value) { + return value instanceof Value ? unwrapValue((Value) value) : value; } - if (value.isStructure()) { - final Structure structure = value.asStructure(); - final Map map = new LinkedHashMap<>(); - if (structure != null) { - for (final String key : structure.keySet()) { - map.put(key, unwrapValue(structure.getValue(key))); + + /** + * Recursively unwraps an OpenFeature {@link Value} into its native Java representation: + * structures become {@code Map}, lists become {@code List}, and scalars + * become their boxed value (or {@code null}). Nested {@link Value}s are unwrapped at every level + * so a structure containing further structures/lists serializes correctly. + */ + private static Object unwrapValue(final Value value) { + if (value == null || value.isNull()) { + return null; } - } - return map; - } - if (value.isList()) { - final List list = value.asList(); - final List out = new ArrayList<>(list == null ? 0 : list.size()); - if (list != null) { - for (final Value element : list) { - out.add(unwrapValue(element)); + if (value.isStructure()) { + final Structure structure = value.asStructure(); + final Map map = new LinkedHashMap<>(); + if (structure != null) { + for (final String key : structure.keySet()) { + map.put(key, unwrapValue(structure.getValue(key))); + } + } + return map; } - } - return out; - } - if (value.isBoolean()) { - return value.asBoolean(); - } - if (value.isString()) { - return value.asString(); - } - if (value.isNumber()) { - // Preserve integral vs fractional so the rendered JSON number matches Node. - final Double d = value.asDouble(); - if (d != null && d == Math.rint(d) && !Double.isInfinite(d)) { - final Integer i = value.asInteger(); - if (i != null) { - return i; + if (value.isList()) { + final List list = value.asList(); + final List out = new ArrayList<>(list == null ? 0 : list.size()); + if (list != null) { + for (final Value element : list) { + out.add(unwrapValue(element)); + } + } + return out; } - } - return d; - } - final Instant instant = value.asInstant(); - if (instant != null) { - return instant.toString(); + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isString()) { + return value.asString(); + } + if (value.isNumber()) { + // Preserve integral vs fractional so the rendered JSON number matches Node. + final Double d = value.asDouble(); + if (d != null && d == Math.rint(d) && !Double.isInfinite(d)) { + final Integer i = value.asInteger(); + if (i != null) { + return i; + } + } + return d; + } + final Instant instant = value.asInstant(); + if (instant != null) { + return instant.toString(); + } + // Unknown shape: fall back to the wrapped object's own representation. + return value.asObject(); } - // Unknown shape: fall back to the wrapped object's own representation. - return value.asObject(); - } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java index 2e5a4c483bf..1e473447acf 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java @@ -30,69 +30,64 @@ */ class DDEvaluatorSpanEnrichmentForkedTest { - static { - System.setProperty("dd.experimental.flagging.provider.span.enrichment.enabled", "true"); - } + static { + System.setProperty("dd.experimental.flagging.provider.span.enrichment.enabled", "true"); + } - @Test - void enrichmentMetadataCarriesTheSplitSerialId() { - final ProviderEvaluation result = evaluate(340132); + @Test + void enrichmentMetadataCarriesTheSplitSerialId() { + final ProviderEvaluation result = evaluate(340132); - assertNotNull( - result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), - "span enrichment must be on, or the assertions below pass vacuously"); - assertEquals( - Integer.valueOf(340132), - result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } + assertNotNull( + result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), + "span enrichment must be on, or the assertions below pass vacuously"); + assertEquals( + Integer.valueOf(340132), result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + } - /** - * Simulates legacy exposure support while Split.serialId remains available. Falling back to the - * five-argument exposure constructor must not suppress the serial id in enrichment metadata. - */ - @Test - void enrichmentMetadataSurvivesAnAgentWithoutTheExposureConstructor() { - final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true); - try { - assertEquals( - Integer.valueOf(340132), - evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } finally { - DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous); + /** + * Simulates legacy exposure support while Split.serialId remains available. Falling back to the + * five-argument exposure constructor must not suppress the serial id in enrichment metadata. + */ + @Test + void enrichmentMetadataSurvivesAnAgentWithoutTheExposureConstructor() { + final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true); + try { + assertEquals( + Integer.valueOf(340132), + evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + } finally { + DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous); + } } - } - @Test - void enrichmentMetadataOmitsTheSerialIdWhenTheAgentSplitHasNoField() { - final boolean previous = DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.getAndSet(false); - try { - assertNull( - evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } finally { - DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.set(previous); + @Test + void enrichmentMetadataOmitsTheSerialIdWhenTheAgentSplitHasNoField() { + final boolean previous = DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.getAndSet(false); + try { + assertNull(evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + } finally { + DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.set(previous); + } } - } - @Test - void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() { - assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } + @Test + void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() { + assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + } - private static ProviderEvaluation evaluate(final Integer serialId) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), serialId); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - final Map flags = new HashMap<>(); - flags.put( - "target", - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + private static ProviderEvaluation evaluate(final Integer serialId) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), serialId); + final Allocation allocation = new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + final Map flags = new HashMap<>(); + flags.put("target", new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, flags)); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", true, null, flags)); - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); - return evaluator.evaluate(Integer.class, "target", 23, ctx); - } + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); + return evaluator.evaluate(Integer.class, "target", 23, ctx); + } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index 940c84998d4..db82b8b37ba 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -72,1373 +72,1314 @@ public class DDEvaluatorTest { - private static final String CANONICAL_FIXTURE_PATH = - "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Date.class, new DateAdapter()) - .add(ShardAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .build(); - private static final JsonAdapter CONFIG_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - private static final Type FIXTURE_LIST_TYPE = - Types.newParameterizedType(List.class, FixtureCase.class); - private static final JsonAdapter> FIXTURE_LIST_ADAPTER = - MOSHI.adapter(FIXTURE_LIST_TYPE); - private static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; - - @Test - public void testInitializeSignalsApplicationProviderActivation() throws Exception { - final FeatureFlaggingGateway.ActivationListener listener = - mock(FeatureFlaggingGateway.ActivationListener.class); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - FeatureFlaggingGateway.addActivationListener(listener); - try { - evaluator.initialize(1, MILLISECONDS, mock(EvaluationContext.class)); - - verify(listener).activate(); - } finally { - evaluator.shutdown(); - FeatureFlaggingGateway.removeActivationListener(listener); - } - } - - private static Arguments[] valueMappingTestCases() { - return new Arguments[] { - // String mappings - Arguments.of(String.class, "hello", "hello"), - Arguments.of(String.class, 123, "123"), - Arguments.of(String.class, true, "true"), - Arguments.of(String.class, 3.14, "3.14"), - Arguments.of(String.class, null, null), - - // Boolean mappings - Arguments.of(Boolean.class, true, true), - Arguments.of(Boolean.class, false, false), - Arguments.of(Boolean.class, "true", true), - Arguments.of(Boolean.class, "false", false), - Arguments.of(Boolean.class, "TRUE", true), - Arguments.of(Boolean.class, "FALSE", false), - Arguments.of(Boolean.class, 1, true), - Arguments.of(Boolean.class, 0, false), - Arguments.of(Boolean.class, null, null), - - // Integer mappings - Arguments.of(Integer.class, 42, 42), - Arguments.of(Integer.class, "42", 42), - Arguments.of(Integer.class, 3.14, 3), - Arguments.of(Integer.class, "3.14", 3), - Arguments.of(Integer.class, null, null), - - // Double mappings - Arguments.of(Double.class, 3.14, 3.14), - Arguments.of(Double.class, "3.14", 3.14), - Arguments.of(Double.class, 42, 42.0), - Arguments.of(Double.class, "42", 42.0), - Arguments.of(Double.class, null, null), - - // Value mappings (OpenFeature Value objects) - Arguments.of(Value.class, "hello", Value.objectToValue("hello")), - Arguments.of(Value.class, 42, Value.objectToValue(42)), - Arguments.of(Value.class, 3.14, Value.objectToValue(3.14)), - Arguments.of(Value.class, true, Value.objectToValue(true)), - Arguments.of(Value.class, null, null), - - // Unsupported - Arguments.of(Long.class, 42L, IllegalArgumentException.class), - }; - } - - @ParameterizedTest - @MethodSource("valueMappingTestCases") - public void testValueMapping(final Class target, final Object value, final Object expected) { - if (expected == IllegalArgumentException.class) { - assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(target, value)); - } else { - final Object result = DDEvaluator.mapValue(target, value); - assertThat(result, equalTo(expected)); - } - } - - @Test - public void testEvaluateNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - } - - @Test - public void testInitializeTimesOutWithoutConfig() throws Exception { - final Runnable configCallback = mock(Runnable.class); - final DDEvaluator evaluator = new DDEvaluator(configCallback); - evaluator.accept(null); - try { - assertThat( - evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); - verify(configCallback, times(0)).run(); - } finally { - evaluator.shutdown(); - } - } - - @Test - public void testInitializeWaitsForNonNullConfig() throws Exception { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - final Future initialized = - executor.submit(() -> evaluator.initialize(1, SECONDS, mock(EvaluationContext.class))); - - evaluator.accept(null); - assertThat(initialized.isDone(), equalTo(false)); - - evaluator.accept(mock(ServerConfiguration.class)); - assertThat(initialized.get(1, SECONDS), equalTo(true)); - } finally { - executor.shutdownNow(); - evaluator.shutdown(); - } - } - - @Test - public void testEvaluateNoContext() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(mock(ServerConfiguration.class)); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.INVALID_CONTEXT)); - } - - @Test - public void testNoAllocations() { - final Map flags = new HashMap<>(); - flags.put("null-allocation", new Flag("target", true, null, null, null)); - flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); - - ProviderEvaluation details = evaluator.evaluate(Integer.class, "null-allocation", 23, ctx); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); - - details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); - assertThat(details.getValue(), equalTo(23)); - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat(details.getErrorCode(), nullValue()); - } - - @Test - public void testEvaluateUnsignedShardRange() { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - // The selected shard is above Integer.MAX_VALUE, so this test proves that evaluation uses - // unsigned semantics after binary-compatible int storage. - final Shard shard = - new Shard( - "salt", - singletonList(new ShardRange((int) 3_699_531_192L, (int) 3_699_531_193L)), - (int) MAX_UNSIGNED_INT); - final Split split = new Split(singletonList(shard), "on", emptyMap(), null); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - final Map flags = new HashMap<>(); - flags.put( - "target", - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); - - final EvaluationContext context = - new MutableContext("target").setTargetingKey("high-shard-user"); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, context); - - assertThat(details.getValue(), equalTo(1)); - assertThat(details.getReason(), equalTo("SPLIT")); - assertThat(details.getVariant(), equalTo("on")); - } - - // ---- observeFullEvaluationData metadata is stamped from the evaluator's ServerConfiguration - // ---- - // - // Every code path that returns a ProviderEvaluation must stamp the consent boolean so downstream - // hooks can honour it. These tests exercise each stamp site with both consent values (on/off) so - // a mutation to any stamp — deleting the line, hardcoding the value — flips at least one - // assertion. - - // -- success path: resolveVariant (variant metadata builder) -- - - @Test - public void observeFullEvaluationDataStampedTrueOnResolvedVariant() { - final ProviderEvaluation details = evaluateMatchingFlag(true); - - assertThat(details.getReason(), equalTo("STATIC")); - assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { - // Symmetric consent-off assertion. Paired with the consent-on test above this pins the - // resolveVariant metadata line (DDEvaluator.java: METADATA_OBSERVE_FULL_EVALUATION_DATA) so - // deleting it or hardcoding either value would fail at least one assertion. - final ProviderEvaluation details = evaluateMatchingFlag(false); - - assertThat(details.getReason(), equalTo("STATIC")); - assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- DISABLED path: flag.enabled=false -- - - @Test - public void observeFullEvaluationDataStampedTrueOnDisabledFlag() { - final ProviderEvaluation details = evaluateDisabledFlag(true); - - assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnDisabledFlag() { - final ProviderEvaluation details = evaluateDisabledFlag(false); - - assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- DEFAULT path: no allocation matches -- - - @Test - public void observeFullEvaluationDataStampedTrueOnDefault() { - // Allocation exists but has empty splits, so the loop finishes without returning and we fall - // through to the DEFAULT branch. - final ProviderEvaluation details = evaluateWithEmptySplits(true); - - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataStampedFalseOnDefault() { - final ProviderEvaluation details = evaluateWithEmptySplits(false); - - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // -- error paths: FLAG_NOT_FOUND / PROVIDER_NOT_READY (via consentMetadata in error()) -- - - @Test - public void observeFullEvaluationDataStampedOnFlagNotFoundError() { - // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via - // FLAG_NOT_FOUND — kept for that stamp site, correctly named. - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "unknown-flag", 23, ctx); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.FLAG_NOT_FOUND)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); - } - - @Test - public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ProviderEvaluation details = - evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - @Test - public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { - // The field is boxed so Moshi tolerates a malformed consent value in the UFC JSON without - // aborting the whole parse. The evaluator must then interpret null as the privacy-preserving - // default. An auto-unbox at the read site (config.observeFullEvaluationData) would NPE here. - final Map flags = new HashMap<>(); - flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); - final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); - - // Flags still evaluate — availability preserved despite the malformed consent field. - assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); - } - - // ---- exposure events carry the split's serial id ---- - - @Test - public void exposureCarriesTheSplitSerialId() { - assertEquals(Integer.valueOf(340132), exposureFor(340132).serial_id); - } - - @Test - public void exposureCarriesSerialIdZero() { - assertEquals(Integer.valueOf(0), exposureFor(0).serial_id); - } - - @Test - public void exposureOmitsSerialIdWhenTheSplitHasNone() { - assertNull(exposureFor(null).serial_id); - } - - @Test - public void legacyExposureApiDispatchesAnExposureWithoutSerialId() { - final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true); - try { - assertNull(exposureFor(7).serial_id); - } finally { - DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous); - } - } - - // ---- old-agent bootstrap probe ---- - - /** A Split from an agent that predates the serial id: the field does not exist. */ - static final class SplitWithoutSerialId {} - - /** A Split whose serialId is not the Integer the dispatch site reads. */ - static final class SplitWithWrongSerialIdType { - public long serialId; - } - - /** An ExposureEvent from an agent that predates the serial id: only the five-arg constructor. */ - static final class LegacyExposureEvent { - // Qualify exposure types that share names with the imported UFC types. - LegacyExposureEvent( - final long timestamp, - final datadog.trace.api.featureflag.exposure.Allocation allocation, - final datadog.trace.api.featureflag.exposure.Flag flag, - final datadog.trace.api.featureflag.exposure.Variant variant, - final Subject subject) {} - } - - /** - * Positive control. The probe must agree with the bootstrap actually on the classpath, or the - * negative cases below would pass for the wrong reason and the feature would ship switched off. - */ - @Test - public void probeAcceptsTheBootstrapOnTheClasspath() { - assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); - assertTrue(DDEvaluator.exposureSerialIdSupported(ExposureEvent.class)); - assertTrue(DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.get()); - assertFalse(DDEvaluator.USE_LEGACY_EXPOSURE_API.get()); - } - - @Test - public void probeRejectsAnAgentWhoseSplitHasNoSerialId() { - assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithoutSerialId.class)); - } - - @Test - public void probeRejectsAnAgentWhoseSerialIdIsNotAnInteger() { - assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithWrongSerialIdType.class)); - } - - @Test - public void probeRejectsAnAgentWithoutTheSerialIdConstructor() { - assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); - } - - /** - * Tests legacy bootstrap behavior when Split.serialId exists but the exposure event has only the - * five-argument constructor. Split support must remain independent of exposure constructor - * support. - */ - @Test - public void probeKeepsSplitSupportWhenOnlyTheEventConstructorIsMissing() { - assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); - assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); - } - - /** - * Evaluates a logging allocation whose split carries the given serial id and returns the single - * dispatched exposure. Span enrichment is off here, as it is by default, so this also pins that - * the serial id does not travel via the enrichment-gated evaluation metadata. - */ - private static ExposureEvent exposureFor(final Integer serialId) { - final List dispatched = new ArrayList<>(); - final FeatureFlaggingGateway.ExposureListener listener = dispatched::add; - FeatureFlaggingGateway.addExposureListener(listener); - try { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), serialId); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE); - evaluateFlag( - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), true); - } finally { - FeatureFlaggingGateway.removeExposureListener(listener); - } - assertEquals(1, dispatched.size()); - return dispatched.get(0); - } - - // Builds a flag that reaches resolveVariant: enabled, one allocation with no rules, one split - // with empty shards (so the shard-match branch is skipped and the split is picked immediately), - // and a single "on" variant whose value maps to the requested Integer type. - private static ProviderEvaluation evaluateMatchingFlag( - final boolean observeFullEvaluationData) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), null); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - return evaluateFlag( - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), - observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateDisabledFlag( - final boolean observeFullEvaluationData) { - return evaluateFlag( - new Flag("target", false, ValueType.INTEGER, emptyMap(), null), observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateWithEmptySplits( - final boolean observeFullEvaluationData) { - // Enabled, allocations present, allocation active, no rules, empty splits → falls through the - // for-loop to the DEFAULT return. - final Allocation allocation = - new Allocation("alloc-1", null, null, null, emptyList(), Boolean.FALSE); - return evaluateFlag( - new Flag("target", true, ValueType.INTEGER, emptyMap(), singletonList(allocation)), - observeFullEvaluationData); - } - - private static ProviderEvaluation evaluateFlag( - final Flag flag, final boolean observeFullEvaluationData) { - final Map flags = new HashMap<>(); - flags.put("target", flag); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); - return evaluator.evaluate(Integer.class, "target", 23, ctx); - } - - // ---- error message redaction respects observeFullEvaluationData ---- - - @Test - public void numericConditionOnTargetingKeyDropsExceptionMessageUnderConsentOff() { - // Rule {attribute:"id", operator:GT, value:0} + "id" not in context → - // DDEvaluator.resolveAttribute - // falls back to the targeting key, so Double.parseDouble("jane.doe@datadoghq.com") throws - // NumberFormatException. The exception message echoes the raw context value verbatim, so it - // must be dropped when observeFullEvaluationData=false. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); - assertNull(details.getErrorMessage(), "consent-off must not surface the raw exception message"); - } - - @Test - public void numericConditionOnTargetingKeyPreservesExceptionMessageUnderConsentOn() { - // Symmetric case: with consent on, the raw exception message flows through unchanged so - // operators keep the diagnostic detail they opted in to. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", true); - - assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); - assertThat(details.getErrorMessage(), equalTo("For input string: \"jane.doe@datadoghq.com\"")); - } - - @Test - public void numericConditionOnTargetingKeyErrorMessageNeverContainsPiiUnderConsentOff() { - // Belt-and-suspenders: independent of the exact null/empty form, the raw PII value must never - // appear in the message under consent-off. Guards against future changes that might replace - // null with a redacted string or a code-name suffix. - final ProviderEvaluation details = - evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); - - final String message = details.getErrorMessage(); - assertFalse( - message != null && message.contains("jane.doe@datadoghq.com"), - "consent-off errorMessage must not contain raw context values"); - } - - private static ProviderEvaluation evaluateWithNumericRuleOnId( - final String targetingKey, final boolean observeFullEvaluationData) { - final Map flags = new HashMap<>(); - final List rules = - singletonList( - new Rule(singletonList(new ConditionConfiguration(ConditionOperator.GT, "id", 0)))); - // Split must be non-empty so the allocation is considered a match target; its contents don't - // matter because the rule throws before a split is picked. - final Allocation allocation = - new Allocation("alloc", rules, null, null, emptyList(), Boolean.FALSE); - flags.put( - "num-rule", - new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); - - final EvaluationContext ctx = new MutableContext(targetingKey); - return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); - } - - @Test - public void testAllocationDateAbiAndInstantAccessors() throws Exception { - final Date startAt = Date.from(Instant.parse("2024-01-01T00:00:00Z")); - final Date endAt = Date.from(Instant.parse("2024-12-31T23:59:59Z")); - final Allocation allocation = - new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); - - assertThat(Allocation.class.getField("startAt").getType(), equalTo(Date.class)); - assertThat(Allocation.class.getField("endAt").getType(), equalTo(Date.class)); - assertThat(allocation.startAtInstant(), equalTo(startAt.toInstant())); - assertThat(allocation.endAtInstant(), equalTo(endAt.toInstant())); - } - - @Test - public void testAllocationWindowHonorsMicrosecondPrecision() { - final Instant startAt = Instant.parse("2024-01-01T00:00:00.123456Z"); - final Instant endAt = Instant.parse("2024-01-01T00:00:00.987654Z"); - final Allocation allocation = - Allocation.fromInstants("allocation", emptyList(), startAt, endAt, emptyList(), true); - - assertThat( - DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); - assertThat(DDEvaluator.isAllocationActive(allocation, startAt), equalTo(true)); - assertThat(DDEvaluator.isAllocationActive(allocation, endAt), equalTo(true)); - assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); - } - - // --- SemVer condition evaluation tests (ported from Go evaluator_test.go) --- - - private static Flag semverFlag(final ConditionOperator operator, final String comparand) { - final ParsedSemver parsed = ParsedSemver.parse(comparand); - final ConditionConfiguration condition = - new ConditionConfiguration(operator, "version", comparand); - condition.semverComparand = parsed; - final Rule rule = new Rule(singletonList(condition)); - final Split split = new Split(emptyList(), "on", null, null); - final Allocation allocation = - Allocation.fromInstants( - "targeted", singletonList(rule), null, null, singletonList(split), false); - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", true)); - return new Flag("test-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation)); - } - - private static EvaluationContext semverContext(final Object version) { - final Map attributes = new HashMap<>(); - if (version != null) { - attributes.put("version", version); - } - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - context.setTargetingKey("subject"); - return context; - } - - static Arguments[] semverConditionTestCases() { - return new Arguments[] { - // Equal - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3", "1.2.3", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.4", "1.2.3", false), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3.4.5.6", "1.2.3.4.5.6", true), - Arguments.of(ConditionOperator.SEMVER_GT, "1.2.3.4.5.7", "1.2.3.4.5.6", true), - // Not equal - Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.4", "1.2.3", true), - Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.3", "1.2.3", false), - // Less than - Arguments.of(ConditionOperator.SEMVER_LT, "1.9.9", "2.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LT, "2.0.0", "2.0.0", false), - // Less than or equal - Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.0", "2.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.1", "2.0.0", false), - // Greater than - Arguments.of(ConditionOperator.SEMVER_GT, "1.0.1", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GT, "1.0.0", "1.0.0", false), - // Greater than or equal - Arguments.of(ConditionOperator.SEMVER_GTE, "1.0.0", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GTE, "0.9.9", "1.0.0", false), - // Prerelease ordering - Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.1", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.2", "1.0.0-beta.11", true), - // Build metadata is ignored - Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+exp.sha.5114f85", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_NEQ, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_LT, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_LTE, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GT, "4.0.0+build.42", "4.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "4.0.0+build.42", "4.0.0", true), - Arguments.of(ConditionOperator.SEMVER_EQ, "1.0.0+linux", "1.0.0+darwin", true), - // Invalid attribute does not match - Arguments.of(ConditionOperator.SEMVER_NEQ, "not-a-version", "1.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "1.2", "1.0.0", true), - Arguments.of(ConditionOperator.SEMVER_GTE, "v1.2.3", "1.0.0", false), - Arguments.of(ConditionOperator.SEMVER_GTE, "18446744073709551616.0.0", "1.0.0", false), - // Non-string attribute does not match - Arguments.of(ConditionOperator.SEMVER_EQ, 1.2, "1.2.0", false), - }; - } - - @ParameterizedTest(name = "{0} attr={1} comparand={2} -> {3}") - @MethodSource("semverConditionTestCases") - public void testEvaluateSemverCondition( - final ConditionOperator operator, - final Object attribute, - final String comparand, - final boolean wantMatch) { - final Map flags = new HashMap<>(); - flags.put("test-flag", semverFlag(operator, comparand)); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); - - if (wantMatch) { - assertThat(details.getValue(), equalTo(true)); - assertThat(details.getReason(), equalTo("TARGETING_MATCH")); - } else { - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo("DEFAULT")); - } - } - - @Test - public void testEvaluateSemverConditionMissingAttribute() { - final Map flags = new HashMap<>(); - flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo("DEFAULT")); - } - - @Test - public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { - // A flag with an invalid semver comparand is dropped during parsing. - // The evaluator should return PARSE_ERROR when the flag is queried. - final Map flags = new HashMap<>(); - final Map invalidFlags = new HashMap<>(); - invalidFlags.put("invalid-semver", "invalid_semver_comparand"); - final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); - config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); - } - - @Test - public void testEvaluateMalformedFlagReturnsParseError() { - final Map flags = new HashMap<>(); - final Map invalidFlags = new HashMap<>(); - invalidFlags.put("malformed-flag", "invalid_flag"); - final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); - config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); - - final ProviderEvaluation details = - evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); - - assertThat(details.getValue(), equalTo(false)); - assertThat(details.getReason(), equalTo(ERROR.name())); - assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); - } - - private static Arguments[] flatteningTestCases() { - final List arguments = new ArrayList<>(); - arguments.add(Arguments.of(emptyMap(), emptyMap())); - arguments.add( - Arguments.of( - mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null), - mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null))); - arguments.add( - Arguments.of( - mapOf("list", asList(1, 2, singletonList(4))), - mapOf("list[0]", 1, "list[1]", 2, "list[2][0]", 4))); - arguments.add( - Arguments.of( - mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), - mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); - arguments.add( - Arguments.of( - mapOf("plan", "gold", "cohort", "gold"), mapOf("plan", "gold", "cohort", "gold"))); - final Instant instant = Instant.parse("2026-07-10T12:34:56Z"); - arguments.add(Arguments.of(mapOf("instant", instant), mapOf("instant", instant.toString()))); - return arguments.toArray(new Arguments[0]); - } - - @MethodSource("flatteningTestCases") - @ParameterizedTest - public void testFlattening( - final Map attributes, final Map expected) { - final EvaluationContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - final Map result = DDEvaluator.flattenContext(context); - - assertThat(result.size(), equalTo(expected.size())); - for (final Map.Entry entry : expected.entrySet()) { - assertThat(result, hasEntry(entry.getKey(), entry.getValue())); - } - } - - @Test - public void testDeeplyNestedContextIsTruncatedRatherThanOverflowingTheStack() { - Value nested = new Value("leaf"); - for (int i = 0; i < 10_000; i++) { - nested = new Value(singletonList(nested)); - } - final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); - - final Map result = DDEvaluator.flattenContext(context); - - final StringBuilder truncatedKey = new StringBuilder("deep"); - for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { - truncatedKey.append("[0]"); - } - assertThat(result.size(), equalTo(1)); - assertThat(result, hasEntry(truncatedKey.toString(), null)); - } - - @Test - public void testCopyPrunedContextCapsTopLevelFieldCount() { - final MutableContext context = new MutableContext(); - for (int i = 0; i < DDEvaluator.MAX_CONTEXT_FIELDS + 100; i++) { - context.add(String.format("k%04d", i), "v"); - } - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs.size(), equalTo(DDEvaluator.MAX_CONTEXT_FIELDS)); - assertThat(result.truncatedReason, equalTo("max_context_fields")); - } - - @Test - public void testCopyPrunedContextSkipsOversizedStringValues() { - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add("drop", new String(longChars)); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("keep", "ok")); - assertThat(result.attrs.containsKey("drop"), equalTo(false)); - assertThat(result.truncatedReason, equalTo("max_value_length")); - } - - @Test - public void testCopyPrunedContextSkipsOversizedKeys() { - final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; - java.util.Arrays.fill(longKeyChars, 'k'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add(new String(longKeyChars), "drop"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("keep", "ok")); - assertThat(result.attrs.size(), equalTo(1)); - assertThat(result.truncatedReason, equalTo("max_key_length")); - } - - @Test - public void testCopyPrunedContextCapsListWidth() { - final List wide = new java.util.ArrayList<>(); - for (int i = 0; i < DDEvaluator.MAX_LIST_ELEMENTS + 50; i++) { - wide.add(Value.objectToValue("v" + i)); - } - final EvaluationContext context = new MutableContext().add("list", wide); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs.containsKey("list[0]"), equalTo(true)); - assertThat( - result.attrs.containsKey("list[" + (DDEvaluator.MAX_LIST_ELEMENTS - 1) + "]"), - equalTo(true)); - assertThat( - result.attrs.containsKey("list[" + DDEvaluator.MAX_LIST_ELEMENTS + "]"), equalTo(false)); - assertThat(result.truncatedReason, equalTo("max_list_elements")); - } - - @Test - public void testCopyPrunedContextCapsStructureWidth() { - final dev.openfeature.sdk.MutableStructure wide = new dev.openfeature.sdk.MutableStructure(); - for (int i = 0; i < DDEvaluator.MAX_STRUCTURE_PROPERTIES + 50; i++) { - wide.add(String.format("p%04d", i), "v"); - } - final EvaluationContext context = new MutableContext().add("struct", wide); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - long structKeys = result.attrs.keySet().stream().filter(k -> k.startsWith("struct.")).count(); - assertThat(structKeys, equalTo((long) DDEvaluator.MAX_STRUCTURE_PROPERTIES)); - assertThat(result.truncatedReason, equalTo("max_structure_properties")); - } - - @Test - public void testCopyPrunedContextTruncatesDeepNesting() { - Value nested = new Value("leaf"); - for (int i = 0; i < 10_000; i++) { - nested = new Value(singletonList(nested)); - } - final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - final StringBuilder truncatedKey = new StringBuilder("deep"); - for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { - truncatedKey.append("[0]"); - } - // The recursion stops on the first list element at MAX_SNAPSHOT_DEPTH; deeper elements are - // never walked and no entry is emitted for them. - assertThat(result.attrs.containsKey(truncatedKey.toString()), equalTo(false)); - assertThat(result.attrs.size(), equalTo(0)); - assertThat(result.truncatedReason, equalTo("max_snapshot_depth")); - } - - @Test - public void testCopyPrunedContextExcludesTargetingKey() { - final MutableContext context = new MutableContext("user-42").add("region", "us-east-1"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("region", "us-east-1")); - assertThat(result.attrs.containsKey("targetingKey"), equalTo(false)); - assertThat(result.truncatedReason, equalTo(null)); - } - - @Test - public void testCopyPrunedContextNoTruncationReturnsNullReason() { - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - context.add("tier", "gold"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - assertThat(result.attrs, hasEntry("region", "us-east-1")); - assertThat(result.truncatedReason, equalTo(null)); - } - - @Test - public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; - java.util.Arrays.fill(longKeyChars, 'k'); - final MutableContext context = new MutableContext(); - context.add("keep", "ok"); - context.add("dropValue", new String(longChars)); - context.add(new String(longKeyChars), "dropKey"); - - final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); - - // Both max_key_length and max_value_length fired; sorted alphabetically, no duplicates. - assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); - } - - @Test - public void testCanonicalFixturesArePresent() throws IOException { - assertThat(canonicalTestCases().size(), greaterThan(0)); - } - - @MethodSource("canonicalTestCases") - @ParameterizedTest(name = "{0}") - public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(loadCanonicalConfiguration()); - - final Class targetType = targetType(testCase.variationType); - final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); - final Object expectedValue = mapFixtureValue(targetType, testCase.result.value); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); - - assertThat(details.getValue(), equalTo(expectedValue)); - assertThat(details.getReason(), equalTo(testCase.result.reason)); - if (testCase.result.variant != null) { - assertThat(details.getVariant(), equalTo(testCase.result.variant)); - } - if (testCase.result.errorCode != null) { - assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.result.errorCode))); - } - if (testCase.result.flagMetadata != null - && testCase.result.flagMetadata.get("allocationKey") != null) { - assertThat( - details.getFlagMetadata().getString("allocationKey"), - equalTo(String.valueOf(testCase.result.flagMetadata.get("allocationKey")))); - } - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static ProviderEvaluation evaluate( - final DDEvaluator evaluator, - final Class targetType, - final String flag, - final Object defaultValue, - final EvaluationContext context) { - return evaluator.evaluate((Class) targetType, flag, defaultValue, context); - } - - private static ServerConfiguration loadCanonicalConfiguration() throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - try { - final ServerConfiguration config = - CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); - final Map invalidFlags = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalidFlags.isEmpty()) { + private static final String CANONICAL_FIXTURE_PATH = + "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; + private static final Moshi MOSHI = new Moshi.Builder() + .add(Date.class, new DateAdapter()) + .add(ShardAdapter.FACTORY) + .add(FlagMapAdapter.FACTORY) + .build(); + private static final JsonAdapter CONFIG_ADAPTER = MOSHI.adapter(ServerConfiguration.class); + private static final Type FIXTURE_LIST_TYPE = Types.newParameterizedType(List.class, FixtureCase.class); + private static final JsonAdapter> FIXTURE_LIST_ADAPTER = MOSHI.adapter(FIXTURE_LIST_TYPE); + private static final ThreadLocal> INVALID_FLAGS_HOLDER = ThreadLocal.withInitial(HashMap::new); + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + + @Test + public void testInitializeSignalsApplicationProviderActivation() throws Exception { + final FeatureFlaggingGateway.ActivationListener listener = + mock(FeatureFlaggingGateway.ActivationListener.class); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + FeatureFlaggingGateway.addActivationListener(listener); + try { + evaluator.initialize(1, MILLISECONDS, mock(EvaluationContext.class)); + + verify(listener).activate(); + } finally { + evaluator.shutdown(); + FeatureFlaggingGateway.removeActivationListener(listener); + } + } + + private static Arguments[] valueMappingTestCases() { + return new Arguments[] { + // String mappings + Arguments.of(String.class, "hello", "hello"), + Arguments.of(String.class, 123, "123"), + Arguments.of(String.class, true, "true"), + Arguments.of(String.class, 3.14, "3.14"), + Arguments.of(String.class, null, null), + + // Boolean mappings + Arguments.of(Boolean.class, true, true), + Arguments.of(Boolean.class, false, false), + Arguments.of(Boolean.class, "true", true), + Arguments.of(Boolean.class, "false", false), + Arguments.of(Boolean.class, "TRUE", true), + Arguments.of(Boolean.class, "FALSE", false), + Arguments.of(Boolean.class, 1, true), + Arguments.of(Boolean.class, 0, false), + Arguments.of(Boolean.class, null, null), + + // Integer mappings + Arguments.of(Integer.class, 42, 42), + Arguments.of(Integer.class, "42", 42), + Arguments.of(Integer.class, 3.14, 3), + Arguments.of(Integer.class, "3.14", 3), + Arguments.of(Integer.class, null, null), + + // Double mappings + Arguments.of(Double.class, 3.14, 3.14), + Arguments.of(Double.class, "3.14", 3.14), + Arguments.of(Double.class, 42, 42.0), + Arguments.of(Double.class, "42", 42.0), + Arguments.of(Double.class, null, null), + + // Value mappings (OpenFeature Value objects) + Arguments.of(Value.class, "hello", Value.objectToValue("hello")), + Arguments.of(Value.class, 42, Value.objectToValue(42)), + Arguments.of(Value.class, 3.14, Value.objectToValue(3.14)), + Arguments.of(Value.class, true, Value.objectToValue(true)), + Arguments.of(Value.class, null, null), + + // Unsupported + Arguments.of(Long.class, 42L, IllegalArgumentException.class), + }; + } + + @ParameterizedTest + @MethodSource("valueMappingTestCases") + public void testValueMapping(final Class target, final Object value, final Object expected) { + if (expected == IllegalArgumentException.class) { + assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(target, value)); + } else { + final Object result = DDEvaluator.mapValue(target, value); + assertThat(result, equalTo(expected)); + } + } + + @Test + public void testEvaluateNoConfig() { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + } + + @Test + public void testInitializeTimesOutWithoutConfig() throws Exception { + final Runnable configCallback = mock(Runnable.class); + final DDEvaluator evaluator = new DDEvaluator(configCallback); + evaluator.accept(null); + try { + assertThat(evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); + verify(configCallback, times(0)).run(); + } finally { + evaluator.shutdown(); + } + } + + @Test + public void testInitializeWaitsForNonNullConfig() throws Exception { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final Future initialized = + executor.submit(() -> evaluator.initialize(1, SECONDS, mock(EvaluationContext.class))); + + evaluator.accept(null); + assertThat(initialized.isDone(), equalTo(false)); + + evaluator.accept(mock(ServerConfiguration.class)); + assertThat(initialized.get(1, SECONDS), equalTo(true)); + } finally { + executor.shutdownNow(); + evaluator.shutdown(); + } + } + + @Test + public void testEvaluateNoContext() { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(mock(ServerConfiguration.class)); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.INVALID_CONTEXT)); + } + + @Test + public void testNoAllocations() { + final Map flags = new HashMap<>(); + flags.put("null-allocation", new Flag("target", true, null, null, null)); + flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); + + ProviderEvaluation details = evaluator.evaluate(Integer.class, "null-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); + + details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat(details.getErrorCode(), nullValue()); + } + + @Test + public void testEvaluateUnsignedShardRange() { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + // The selected shard is above Integer.MAX_VALUE, so this test proves that evaluation uses + // unsigned semantics after binary-compatible int storage. + final Shard shard = + new Shard("salt", singletonList(new ShardRange((int) 3_699_531_192L, (int) 3_699_531_193L)), (int) + MAX_UNSIGNED_INT); + final Split split = new Split(singletonList(shard), "on", emptyMap(), null); + final Allocation allocation = new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + final Map flags = new HashMap<>(); + flags.put("target", new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + + final EvaluationContext context = new MutableContext("target").setTargetingKey("high-shard-user"); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, context); + + assertThat(details.getValue(), equalTo(1)); + assertThat(details.getReason(), equalTo("SPLIT")); + assertThat(details.getVariant(), equalTo("on")); + } + + // ---- observeFullEvaluationData metadata is stamped from the evaluator's ServerConfiguration + // ---- + // + // Every code path that returns a ProviderEvaluation must stamp the consent boolean so downstream + // hooks can honour it. These tests exercise each stamp site with both consent values (on/off) so + // a mutation to any stamp — deleting the line, hardcoding the value — flips at least one + // assertion. + + // -- success path: resolveVariant (variant metadata builder) -- + + @Test + public void observeFullEvaluationDataStampedTrueOnResolvedVariant() { + final ProviderEvaluation details = evaluateMatchingFlag(true); + + assertThat(details.getReason(), equalTo("STATIC")); + assertThat(details.getVariant(), equalTo("on")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { + // Symmetric consent-off assertion. Paired with the consent-on test above this pins the + // resolveVariant metadata line (DDEvaluator.java: METADATA_OBSERVE_FULL_EVALUATION_DATA) so + // deleting it or hardcoding either value would fail at least one assertion. + final ProviderEvaluation details = evaluateMatchingFlag(false); + + assertThat(details.getReason(), equalTo("STATIC")); + assertThat(details.getVariant(), equalTo("on")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- DISABLED path: flag.enabled=false -- + + @Test + public void observeFullEvaluationDataStampedTrueOnDisabledFlag() { + final ProviderEvaluation details = evaluateDisabledFlag(true); + + assertThat(details.getReason(), equalTo("DISABLED")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnDisabledFlag() { + final ProviderEvaluation details = evaluateDisabledFlag(false); + + assertThat(details.getReason(), equalTo("DISABLED")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- DEFAULT path: no allocation matches -- + + @Test + public void observeFullEvaluationDataStampedTrueOnDefault() { + // Allocation exists but has empty splits, so the loop finishes without returning and we fall + // through to the DEFAULT branch. + final ProviderEvaluation details = evaluateWithEmptySplits(true); + + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnDefault() { + final ProviderEvaluation details = evaluateWithEmptySplits(false); + + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- error paths: FLAG_NOT_FOUND / PROVIDER_NOT_READY (via consentMetadata in error()) -- + + @Test + public void observeFullEvaluationDataStampedOnFlagNotFoundError() { + // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via + // FLAG_NOT_FOUND — kept for that stamp site, correctly named. + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "unknown-flag", 23, ctx); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.FLAG_NOT_FOUND)); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); + } + + @Test + public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + @Test + public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { + // The field is boxed so Moshi tolerates a malformed consent value in the UFC JSON without + // aborting the whole parse. The evaluator must then interpret null as the privacy-preserving + // default. An auto-unbox at the read site (config.observeFullEvaluationData) would NPE here. + final Map flags = new HashMap<>(); + flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); + + // Flags still evaluate — availability preserved despite the malformed consent field. + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // ---- exposure events carry the split's serial id ---- + + @Test + public void exposureCarriesTheSplitSerialId() { + assertEquals(Integer.valueOf(340132), exposureFor(340132).serial_id); + } + + @Test + public void exposureCarriesSerialIdZero() { + assertEquals(Integer.valueOf(0), exposureFor(0).serial_id); + } + + @Test + public void exposureOmitsSerialIdWhenTheSplitHasNone() { + assertNull(exposureFor(null).serial_id); + } + + @Test + public void legacyExposureApiDispatchesAnExposureWithoutSerialId() { + final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true); + try { + assertNull(exposureFor(7).serial_id); + } finally { + DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous); + } + } + + // ---- old-agent bootstrap probe ---- + + /** A Split from an agent that predates the serial id: the field does not exist. */ + static final class SplitWithoutSerialId {} + + /** A Split whose serialId is not the Integer the dispatch site reads. */ + static final class SplitWithWrongSerialIdType { + public long serialId; + } + + /** An ExposureEvent from an agent that predates the serial id: only the five-arg constructor. */ + static final class LegacyExposureEvent { + // Qualify exposure types that share names with the imported UFC types. + LegacyExposureEvent( + final long timestamp, + final datadog.trace.api.featureflag.exposure.Allocation allocation, + final datadog.trace.api.featureflag.exposure.Flag flag, + final datadog.trace.api.featureflag.exposure.Variant variant, + final Subject subject) {} + } + + /** + * Positive control. The probe must agree with the bootstrap actually on the classpath, or the + * negative cases below would pass for the wrong reason and the feature would ship switched off. + */ + @Test + public void probeAcceptsTheBootstrapOnTheClasspath() { + assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); + assertTrue(DDEvaluator.exposureSerialIdSupported(ExposureEvent.class)); + assertTrue(DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.get()); + assertFalse(DDEvaluator.USE_LEGACY_EXPOSURE_API.get()); + } + + @Test + public void probeRejectsAnAgentWhoseSplitHasNoSerialId() { + assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithoutSerialId.class)); + } + + @Test + public void probeRejectsAnAgentWhoseSerialIdIsNotAnInteger() { + assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithWrongSerialIdType.class)); + } + + @Test + public void probeRejectsAnAgentWithoutTheSerialIdConstructor() { + assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); + } + + /** + * Tests legacy bootstrap behavior when Split.serialId exists but the exposure event has only the + * five-argument constructor. Split support must remain independent of exposure constructor + * support. + */ + @Test + public void probeKeepsSplitSupportWhenOnlyTheEventConstructorIsMissing() { + assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); + assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); + } + + /** + * Evaluates a logging allocation whose split carries the given serial id and returns the single + * dispatched exposure. Span enrichment is off here, as it is by default, so this also pins that + * the serial id does not travel via the enrichment-gated evaluation metadata. + */ + private static ExposureEvent exposureFor(final Integer serialId) { + final List dispatched = new ArrayList<>(); + final FeatureFlaggingGateway.ExposureListener listener = dispatched::add; + FeatureFlaggingGateway.addExposureListener(listener); + try { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), serialId); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE); + evaluateFlag(new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), true); + } finally { + FeatureFlaggingGateway.removeExposureListener(listener); + } + assertEquals(1, dispatched.size()); + return dispatched.get(0); + } + + // Builds a flag that reaches resolveVariant: enabled, one allocation with no rules, one split + // with empty shards (so the shard-match branch is skipped and the split is picked immediately), + // and a single "on" variant whose value maps to the requested Integer type. + private static ProviderEvaluation evaluateMatchingFlag(final boolean observeFullEvaluationData) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), null); + final Allocation allocation = new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + return evaluateFlag( + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), + observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateDisabledFlag(final boolean observeFullEvaluationData) { + return evaluateFlag(new Flag("target", false, ValueType.INTEGER, emptyMap(), null), observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateWithEmptySplits(final boolean observeFullEvaluationData) { + // Enabled, allocations present, allocation active, no rules, empty splits → falls through the + // for-loop to the DEFAULT return. + final Allocation allocation = new Allocation("alloc-1", null, null, null, emptyList(), Boolean.FALSE); + return evaluateFlag( + new Flag("target", true, ValueType.INTEGER, emptyMap(), singletonList(allocation)), + observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateFlag(final Flag flag, final boolean observeFullEvaluationData) { + final Map flags = new HashMap<>(); + flags.put("target", flag); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); + return evaluator.evaluate(Integer.class, "target", 23, ctx); + } + + // ---- error message redaction respects observeFullEvaluationData ---- + + @Test + public void numericConditionOnTargetingKeyDropsExceptionMessageUnderConsentOff() { + // Rule {attribute:"id", operator:GT, value:0} + "id" not in context → + // DDEvaluator.resolveAttribute + // falls back to the targeting key, so Double.parseDouble("jane.doe@datadoghq.com") throws + // NumberFormatException. The exception message echoes the raw context value verbatim, so it + // must be dropped when observeFullEvaluationData=false. + final ProviderEvaluation details = evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); + assertNull(details.getErrorMessage(), "consent-off must not surface the raw exception message"); + } + + @Test + public void numericConditionOnTargetingKeyPreservesExceptionMessageUnderConsentOn() { + // Symmetric case: with consent on, the raw exception message flows through unchanged so + // operators keep the diagnostic detail they opted in to. + final ProviderEvaluation details = evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", true); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); + assertThat(details.getErrorMessage(), equalTo("For input string: \"jane.doe@datadoghq.com\"")); + } + + @Test + public void numericConditionOnTargetingKeyErrorMessageNeverContainsPiiUnderConsentOff() { + // Belt-and-suspenders: independent of the exact null/empty form, the raw PII value must never + // appear in the message under consent-off. Guards against future changes that might replace + // null with a redacted string or a code-name suffix. + final ProviderEvaluation details = evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); + + final String message = details.getErrorMessage(); + assertFalse( + message != null && message.contains("jane.doe@datadoghq.com"), + "consent-off errorMessage must not contain raw context values"); + } + + private static ProviderEvaluation evaluateWithNumericRuleOnId( + final String targetingKey, final boolean observeFullEvaluationData) { + final Map flags = new HashMap<>(); + final List rules = + singletonList(new Rule(singletonList(new ConditionConfiguration(ConditionOperator.GT, "id", 0)))); + // Split must be non-empty so the allocation is considered a match target; its contents don't + // matter because the rule throws before a split is picked. + final Allocation allocation = new Allocation("alloc", rules, null, null, emptyList(), Boolean.FALSE); + flags.put("num-rule", new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + + final EvaluationContext ctx = new MutableContext(targetingKey); + return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); + } + + @Test + public void testAllocationDateAbiAndInstantAccessors() throws Exception { + final Date startAt = Date.from(Instant.parse("2024-01-01T00:00:00Z")); + final Date endAt = Date.from(Instant.parse("2024-12-31T23:59:59Z")); + final Allocation allocation = new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); + + assertThat(Allocation.class.getField("startAt").getType(), equalTo(Date.class)); + assertThat(Allocation.class.getField("endAt").getType(), equalTo(Date.class)); + assertThat(allocation.startAtInstant(), equalTo(startAt.toInstant())); + assertThat(allocation.endAtInstant(), equalTo(endAt.toInstant())); + } + + @Test + public void testAllocationWindowHonorsMicrosecondPrecision() { + final Instant startAt = Instant.parse("2024-01-01T00:00:00.123456Z"); + final Instant endAt = Instant.parse("2024-01-01T00:00:00.987654Z"); + final Allocation allocation = + Allocation.fromInstants("allocation", emptyList(), startAt, endAt, emptyList(), true); + + assertThat(DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); + assertThat(DDEvaluator.isAllocationActive(allocation, startAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); + } + + // --- SemVer condition evaluation tests (ported from Go evaluator_test.go) --- + + private static Flag semverFlag(final ConditionOperator operator, final String comparand) { + final ParsedSemver parsed = ParsedSemver.parse(comparand); + final ConditionConfiguration condition = new ConditionConfiguration(operator, "version", comparand); + condition.semverComparand = parsed; + final Rule rule = new Rule(singletonList(condition)); + final Split split = new Split(emptyList(), "on", null, null); + final Allocation allocation = + Allocation.fromInstants("targeted", singletonList(rule), null, null, singletonList(split), false); + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", true)); + return new Flag("test-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation)); + } + + private static EvaluationContext semverContext(final Object version) { + final Map attributes = new HashMap<>(); + if (version != null) { + attributes.put("version", version); + } + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + context.setTargetingKey("subject"); + return context; + } + + static Arguments[] semverConditionTestCases() { + return new Arguments[] { + // Equal + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.4", "1.2.3", false), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3.4.5.6", "1.2.3.4.5.6", true), + Arguments.of(ConditionOperator.SEMVER_GT, "1.2.3.4.5.7", "1.2.3.4.5.6", true), + // Not equal + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.4", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.3", "1.2.3", false), + // Less than + Arguments.of(ConditionOperator.SEMVER_LT, "1.9.9", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "2.0.0", "2.0.0", false), + // Less than or equal + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.0", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.1", "2.0.0", false), + // Greater than + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.0", "1.0.0", false), + // Greater than or equal + Arguments.of(ConditionOperator.SEMVER_GTE, "1.0.0", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GTE, "0.9.9", "1.0.0", false), + // Prerelease ordering + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.2", "1.0.0-beta.11", true), + // Build metadata is ignored + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+exp.sha.5114f85", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.0.0+linux", "1.0.0+darwin", true), + // Invalid attribute does not match + Arguments.of(ConditionOperator.SEMVER_NEQ, "not-a-version", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "1.2", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GTE, "v1.2.3", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "18446744073709551616.0.0", "1.0.0", false), + // Non-string attribute does not match + Arguments.of(ConditionOperator.SEMVER_EQ, 1.2, "1.2.0", false), + }; + } + + @ParameterizedTest(name = "{0} attr={1} comparand={2} -> {3}") + @MethodSource("semverConditionTestCases") + public void testEvaluateSemverCondition( + final ConditionOperator operator, final Object attribute, final String comparand, final boolean wantMatch) { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(operator, comparand)); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); + + if (wantMatch) { + assertThat(details.getValue(), equalTo(true)); + assertThat(details.getReason(), equalTo("TARGETING_MATCH")); + } else { + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + } + + @Test + public void testEvaluateSemverConditionMissingAttribute() { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + + @Test + public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { + // A flag with an invalid semver comparand is dropped during parsing. + // The evaluator should return PARSE_ERROR when the flag is queried. + final Map flags = new HashMap<>(); + final Map invalidFlags = new HashMap<>(); + invalidFlags.put("invalid-semver", "invalid_semver_comparand"); + final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); + config.invalidFlags = invalidFlags; + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(config); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); + } + + @Test + public void testEvaluateMalformedFlagReturnsParseError() { + final Map flags = new HashMap<>(); + final Map invalidFlags = new HashMap<>(); + invalidFlags.put("malformed-flag", "invalid_flag"); + final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); config.invalidFlags = invalidFlags; - } - validateAndCacheSemverComparands(config); - return config; - } finally { - INVALID_FLAGS_HOLDER.get().clear(); - } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in the configuration. Flags - * with invalid comparands are removed from the flags map and tracked in invalidFlags, matching - * the behavior of {@link com.datadog.featureflag.UniversalFlagConfigParser}. - */ - private static void validateAndCacheSemverComparands(final ServerConfiguration config) { - if (config.flags == null) { - return; - } - final Map invalidFlags = - config.invalidFlags == null ? new HashMap<>() : new HashMap<>(config.invalidFlags); - final Map flagsToRemove = new HashMap<>(); - for (final Map.Entry entry : config.flags.entrySet()) { - final String flagKey = entry.getKey(); - final Flag flag = entry.getValue(); - if (flag.allocations == null) { - continue; - } - boolean invalid = false; - for (final Allocation allocation : flag.allocations) { - if (allocation.rules == null) { - continue; + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(config); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); + } + + private static Arguments[] flatteningTestCases() { + final List arguments = new ArrayList<>(); + arguments.add(Arguments.of(emptyMap(), emptyMap())); + arguments.add(Arguments.of( + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null), + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null))); + arguments.add(Arguments.of( + mapOf("list", asList(1, 2, singletonList(4))), mapOf("list[0]", 1, "list[1]", 2, "list[2][0]", 4))); + arguments.add(Arguments.of( + mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), + mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); + arguments.add(Arguments.of(mapOf("plan", "gold", "cohort", "gold"), mapOf("plan", "gold", "cohort", "gold"))); + final Instant instant = Instant.parse("2026-07-10T12:34:56Z"); + arguments.add(Arguments.of(mapOf("instant", instant), mapOf("instant", instant.toString()))); + return arguments.toArray(new Arguments[0]); + } + + @MethodSource("flatteningTestCases") + @ParameterizedTest + public void testFlattening(final Map attributes, final Map expected) { + final EvaluationContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + final Map result = DDEvaluator.flattenContext(context); + + assertThat(result.size(), equalTo(expected.size())); + for (final Map.Entry entry : expected.entrySet()) { + assertThat(result, hasEntry(entry.getKey(), entry.getValue())); + } + } + + @Test + public void testDeeplyNestedContextIsTruncatedRatherThanOverflowingTheStack() { + Value nested = new Value("leaf"); + for (int i = 0; i < 10_000; i++) { + nested = new Value(singletonList(nested)); + } + final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); + + final Map result = DDEvaluator.flattenContext(context); + + final StringBuilder truncatedKey = new StringBuilder("deep"); + for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { + truncatedKey.append("[0]"); + } + assertThat(result.size(), equalTo(1)); + assertThat(result, hasEntry(truncatedKey.toString(), null)); + } + + @Test + public void testCopyPrunedContextCapsTopLevelFieldCount() { + final MutableContext context = new MutableContext(); + for (int i = 0; i < DDEvaluator.MAX_CONTEXT_FIELDS + 100; i++) { + context.add(String.format("k%04d", i), "v"); + } + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs.size(), equalTo(DDEvaluator.MAX_CONTEXT_FIELDS)); + assertThat(result.truncatedReason, equalTo("max_context_fields")); + } + + @Test + public void testCopyPrunedContextSkipsOversizedStringValues() { + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add("drop", new String(longChars)); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("keep", "ok")); + assertThat(result.attrs.containsKey("drop"), equalTo(false)); + assertThat(result.truncatedReason, equalTo("max_value_length")); + } + + @Test + public void testCopyPrunedContextSkipsOversizedKeys() { + final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; + java.util.Arrays.fill(longKeyChars, 'k'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add(new String(longKeyChars), "drop"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("keep", "ok")); + assertThat(result.attrs.size(), equalTo(1)); + assertThat(result.truncatedReason, equalTo("max_key_length")); + } + + @Test + public void testCopyPrunedContextCapsListWidth() { + final List wide = new java.util.ArrayList<>(); + for (int i = 0; i < DDEvaluator.MAX_LIST_ELEMENTS + 50; i++) { + wide.add(Value.objectToValue("v" + i)); + } + final EvaluationContext context = new MutableContext().add("list", wide); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs.containsKey("list[0]"), equalTo(true)); + assertThat(result.attrs.containsKey("list[" + (DDEvaluator.MAX_LIST_ELEMENTS - 1) + "]"), equalTo(true)); + assertThat(result.attrs.containsKey("list[" + DDEvaluator.MAX_LIST_ELEMENTS + "]"), equalTo(false)); + assertThat(result.truncatedReason, equalTo("max_list_elements")); + } + + @Test + public void testCopyPrunedContextCapsStructureWidth() { + final dev.openfeature.sdk.MutableStructure wide = new dev.openfeature.sdk.MutableStructure(); + for (int i = 0; i < DDEvaluator.MAX_STRUCTURE_PROPERTIES + 50; i++) { + wide.add(String.format("p%04d", i), "v"); } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; + final EvaluationContext context = new MutableContext().add("struct", wide); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + long structKeys = result.attrs.keySet().stream() + .filter(k -> k.startsWith("struct.")) + .count(); + assertThat(structKeys, equalTo((long) DDEvaluator.MAX_STRUCTURE_PROPERTIES)); + assertThat(result.truncatedReason, equalTo("max_structure_properties")); + } + + @Test + public void testCopyPrunedContextTruncatesDeepNesting() { + Value nested = new Value("leaf"); + for (int i = 0; i < 10_000; i++) { + nested = new Value(singletonList(nested)); + } + final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + final StringBuilder truncatedKey = new StringBuilder("deep"); + for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { + truncatedKey.append("[0]"); + } + // The recursion stops on the first list element at MAX_SNAPSHOT_DEPTH; deeper elements are + // never walked and no entry is emitted for them. + assertThat(result.attrs.containsKey(truncatedKey.toString()), equalTo(false)); + assertThat(result.attrs.size(), equalTo(0)); + assertThat(result.truncatedReason, equalTo("max_snapshot_depth")); + } + + @Test + public void testCopyPrunedContextExcludesTargetingKey() { + final MutableContext context = new MutableContext("user-42").add("region", "us-east-1"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("region", "us-east-1")); + assertThat(result.attrs.containsKey("targetingKey"), equalTo(false)); + assertThat(result.truncatedReason, equalTo(null)); + } + + @Test + public void testCopyPrunedContextNoTruncationReturnsNullReason() { + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + context.add("tier", "gold"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("region", "us-east-1")); + assertThat(result.truncatedReason, equalTo(null)); + } + + @Test + public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; + java.util.Arrays.fill(longKeyChars, 'k'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add("dropValue", new String(longChars)); + context.add(new String(longKeyChars), "dropKey"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + // Both max_key_length and max_value_length fired; sorted alphabetically, no duplicates. + assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); + } + + @Test + public void testCanonicalFixturesArePresent() throws IOException { + assertThat(canonicalTestCases().size(), greaterThan(0)); + } + + @MethodSource("canonicalTestCases") + @ParameterizedTest(name = "{0}") + public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(loadCanonicalConfiguration()); + + final Class targetType = targetType(testCase.variationType); + final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); + final Object expectedValue = mapFixtureValue(targetType, testCase.result.value); + final ProviderEvaluation details = + evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); + + assertThat(details.getValue(), equalTo(expectedValue)); + assertThat(details.getReason(), equalTo(testCase.result.reason)); + if (testCase.result.variant != null) { + assertThat(details.getVariant(), equalTo(testCase.result.variant)); + } + if (testCase.result.errorCode != null) { + assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.result.errorCode))); + } + if (testCase.result.flagMetadata != null && testCase.result.flagMetadata.get("allocationKey") != null) { + assertThat( + details.getFlagMetadata().getString("allocationKey"), + equalTo(String.valueOf(testCase.result.flagMetadata.get("allocationKey")))); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ProviderEvaluation evaluate( + final DDEvaluator evaluator, + final Class targetType, + final String flag, + final Object defaultValue, + final EvaluationContext context) { + return evaluator.evaluate((Class) targetType, flag, defaultValue, context); + } + + private static ServerConfiguration loadCanonicalConfiguration() throws IOException { + INVALID_FLAGS_HOLDER.get().clear(); + try { + final ServerConfiguration config = + CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); + final Map invalidFlags = new HashMap<>(INVALID_FLAGS_HOLDER.get()); + if (!invalidFlags.isEmpty()) { + config.invalidFlags = invalidFlags; } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - invalid = true; - break; + validateAndCacheSemverComparands(config); + return config; + } finally { + INVALID_FLAGS_HOLDER.get().clear(); + } + } + + /** + * Validates and caches SemVer comparands for all SEMVER_* conditions in the configuration. Flags + * with invalid comparands are removed from the flags map and tracked in invalidFlags, matching + * the behavior of {@link com.datadog.featureflag.UniversalFlagConfigParser}. + */ + private static void validateAndCacheSemverComparands(final ServerConfiguration config) { + if (config.flags == null) { + return; + } + final Map invalidFlags = + config.invalidFlags == null ? new HashMap<>() : new HashMap<>(config.invalidFlags); + final Map flagsToRemove = new HashMap<>(); + for (final Map.Entry entry : config.flags.entrySet()) { + final String flagKey = entry.getKey(); + final Flag flag = entry.getValue(); + if (flag.allocations == null) { + continue; + } + boolean invalid = false; + for (final Allocation allocation : flag.allocations) { + if (allocation.rules == null) { + continue; } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - invalid = true; - break; + for (final Rule rule : allocation.rules) { + if (rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition.operator == null) { + continue; + } + switch (condition.operator) { + case SEMVER_EQ: + case SEMVER_NEQ: + case SEMVER_LT: + case SEMVER_LTE: + case SEMVER_GT: + case SEMVER_GTE: + if (!(condition.value instanceof String)) { + invalid = true; + break; + } + final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); + if (parsed == null) { + invalid = true; + break; + } + condition.semverComparand = parsed; + break; + default: + break; + } + } } - condition.semverComparand = parsed; - break; - default: - break; } - } + if (invalid) { + flagsToRemove.put(flagKey, flag); + invalidFlags.put(flagKey, "invalid_semver_comparand"); + } } - } - if (invalid) { - flagsToRemove.put(flagKey, flag); - invalidFlags.put(flagKey, "invalid_semver_comparand"); - } - } - for (final String flagKey : flagsToRemove.keySet()) { - config.flags.remove(flagKey); - } - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } - } - - private static List canonicalTestCases() throws IOException { - final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); - final List result = new ArrayList<>(); - - try (final Stream paths = Files.list(evaluationCases)) { - final List files = - paths - .filter(path -> path.getFileName().toString().endsWith(".json")) - .sorted((left, right) -> left.getFileName().compareTo(right.getFileName())) - .collect(Collectors.toList()); - for (final Path file : files) { - final List testCases = FIXTURE_LIST_ADAPTER.fromJson(read(file)); - if (testCases == null) { - throw new JsonDataException("Fixture file did not contain an array: " + file); + for (final String flagKey : flagsToRemove.keySet()) { + config.flags.remove(flagKey); } - for (int index = 0; index < testCases.size(); index++) { - final FixtureCase testCase = testCases.get(index); - testCase.fileName = file.getFileName().toString(); - testCase.index = index; - result.add(testCase); + if (!invalidFlags.isEmpty()) { + config.invalidFlags = invalidFlags; } - } - } - - assertThat(result.size(), greaterThan(0)); - return result; - } - - private static Path fixtureRoot() { - Path directory = Paths.get("").toAbsolutePath(); - while (directory != null) { - final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); - if (Files.exists(candidate.resolve("ufc-config.json")) - && Files.isDirectory(candidate.resolve("evaluation-cases"))) { - return candidate; - } - directory = directory.getParent(); - } - throw new IllegalStateException("Unable to find canonical FFE fixtures"); - } - - private static String read(final Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } - - private static EvaluationContext context(final FixtureCase testCase) { - final Map attributes = - testCase.attributes == null ? emptyMap() : testCase.attributes; - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - if (testCase.targetingKey != null) { - context.setTargetingKey(testCase.targetingKey); - } - return context; - } - - private static Class targetType(final String variationType) { - switch (variationType) { - case "BOOLEAN": - return Boolean.class; - case "INTEGER": - return Integer.class; - case "NUMERIC": - return Double.class; - case "STRING": - return String.class; - case "JSON": - return Value.class; - default: - throw new IllegalArgumentException("Unsupported variationType: " + variationType); - } - } - - private static Object mapFixtureValue(final Class targetType, final Object value) { - return DDEvaluator.mapValue(targetType, value); - } - - private static Map mapOf(final Object... props) { - final Map result = new HashMap<>(props.length << 1); - int index = 0; - while (index < props.length) { - final String key = String.valueOf(props[index++]); - final Object value = props[index++]; - result.put(key, value); - } - return result; - } - - private static final class FixtureCase { - Map attributes = emptyMap(); - Object defaultValue; - String flag; - FixtureResult result; - String targetingKey; - String variationType; - transient String fileName; - transient int index; - - @Override - public String toString() { - return fileName + "[" + index + "] flag=" + flag; - } - } - - private static final class FixtureResult { - Object value; - String reason; - String errorCode; - String variant; - Map flagMetadata = emptyMap(); - } - - /** - * Mirrors the production parser's binary-compatible uint32 representation for fixture parsing. - */ - private static final class ShardAdapter extends JsonAdapter { - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - }; + } - private final JsonAdapter delegate; - - private ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Override - public Shard fromJson(final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); + private static List canonicalTestCases() throws IOException { + final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); + final List result = new ArrayList<>(); + + try (final Stream paths = Files.list(evaluationCases)) { + final List files = paths.filter( + path -> path.getFileName().toString().endsWith(".json")) + .sorted((left, right) -> left.getFileName().compareTo(right.getFileName())) + .collect(Collectors.toList()); + for (final Path file : files) { + final List testCases = FIXTURE_LIST_ADAPTER.fromJson(read(file)); + if (testCases == null) { + throw new JsonDataException("Fixture file did not contain an array: " + file); + } + for (int index = 0; index < testCases.size(); index++) { + final FixtureCase testCase = testCases.get(index); + testCase.fileName = file.getFileName().toString(); + testCase.index = index; + result.add(testCase); + } + } } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); + + assertThat(result.size(), greaterThan(0)); + return result; } - @Override - public void toJson(final JsonWriter writer, final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); + private static Path fixtureRoot() { + Path directory = Paths.get("").toAbsolutePath(); + while (directory != null) { + final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); + if (Files.exists(candidate.resolve("ufc-config.json")) + && Files.isDirectory(candidate.resolve("evaluation-cases"))) { + return candidate; + } + directory = directory.getParent(); + } + throw new IllegalStateException("Unable to find canonical FFE fixtures"); + } + + private static String read(final Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static EvaluationContext context(final FixtureCase testCase) { + final Map attributes = testCase.attributes == null ? emptyMap() : testCase.attributes; + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + if (testCase.targetingKey != null) { + context.setTargetingKey(testCase.targetingKey); + } + return context; } - } - private static int toUnsignedInt(final Long value, final String fieldName) { - if (value == null) { - return 0; + private static Class targetType(final String variationType) { + switch (variationType) { + case "BOOLEAN": + return Boolean.class; + case "INTEGER": + return Integer.class; + case "NUMERIC": + return Double.class; + case "STRING": + return String.class; + case "JSON": + return Value.class; + default: + throw new IllegalArgumentException("Unsupported variationType: " + variationType); + } } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new IllegalArgumentException("flag contains an invalid " + fieldName + " value"); + + private static Object mapFixtureValue(final Class targetType, final Object value) { + return DDEvaluator.mapValue(targetType, value); } - return value.intValue(); - } - private static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } + private static Map mapOf(final Object... props) { + final Map result = new HashMap<>(props.length << 1); + int index = 0; + while (index < props.length) { + final String key = String.valueOf(props[index++]); + final Object value = props[index++]; + result.put(key, value); + } + return result; + } - private static final class ShardRangeJson { - Long start; - Long end; - } + private static final class FixtureCase { + Map attributes = emptyMap(); + Object defaultValue; + String flag; + FixtureResult result; + String targetingKey; + String variationType; + transient String fileName; + transient int index; + + @Override + public String toString() { + return fileName + "[" + index + "] flag=" + flag; + } + } - /** Reads the flags map with per-flag failure isolation, matching the production parser. */ - private static final class FlagMapAdapter extends JsonAdapter> { - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); + private static final class FixtureResult { + Object value; + String reason; + String errorCode; + String variant; + Map flagMetadata = emptyMap(); + } - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); + /** + * Mirrors the production parser's binary-compatible uint32 representation for fixture parsing. + */ + private static final class ShardAdapter extends JsonAdapter { + private static final JsonAdapter.Factory FACTORY = (type, annotations, moshi) -> { + if (!annotations.isEmpty() || type != Shard.class) { + return null; + } + return new ShardAdapter(moshi.adapter(ShardJson.class)); }; - private final JsonAdapter flagAdapter; + private final JsonAdapter delegate; + + private ShardAdapter(final JsonAdapter delegate) { + this.delegate = delegate; + } - private FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; + @Override + public Shard fromJson(final JsonReader reader) throws IOException { + final ShardJson shard = delegate.fromJson(reader); + if (shard == null) { + return null; + } + final List ranges; + if (shard.ranges == null) { + ranges = null; + } else { + ranges = new ArrayList<>(); + for (final ShardRangeJson range : shard.ranges) { + ranges.add( + range == null + ? null + : new ShardRange( + toUnsignedInt(range.start, "range start"), + toUnsignedInt(range.end, "range end"))); + } + } + return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); + } + + @Override + public void toJson(final JsonWriter writer, final Shard value) { + throw new UnsupportedOperationException("Reading only adapter"); + } } - @Override - public Map fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException ignored) { - INVALID_FLAGS_HOLDER.get().put(flagKey, "invalid_flag"); - // A malformed flag must not prevent valid flags in the same configuration from loading. + private static int toUnsignedInt(final Long value, final String fieldName) { + if (value == null) { + return 0; } - } - reader.endObject(); - return flags; - } - - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; + if (value < 0 || value > MAX_UNSIGNED_INT) { + throw new IllegalArgumentException("flag contains an invalid " + fieldName + " value"); } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; + return value.intValue(); + } + + private static final class ShardJson { + String salt; + List ranges; + Long totalShards; + } + + private static final class ShardRangeJson { + Long start; + Long end; + } + + /** Reads the flags map with per-flag failure isolation, matching the production parser. */ + private static final class FlagMapAdapter extends JsonAdapter> { + private static final Type FLAGS_TYPE = Types.newParameterizedType(Map.class, String.class, Flag.class); + + private static final JsonAdapter.Factory FACTORY = (type, annotations, moshi) -> { + if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { + return null; + } + return new FlagMapAdapter(moshi.adapter(Flag.class)); + }; + + private final JsonAdapter flagAdapter; + + private FlagMapAdapter(final JsonAdapter flagAdapter) { + this.flagAdapter = flagAdapter; } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains invalid shards"); + + @Override + public Map fromJson(final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } + final Map flags = new HashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + final String flagKey = reader.nextName(); + final Object rawFlag = reader.readJsonValue(); + try { + final Flag flag = flagAdapter.fromJsonValue(rawFlag); + if (flag != null) { + validateFlag(flagKey, flag); + flags.put(flagKey, flag); + } + } catch (JsonDataException | IllegalArgumentException ignored) { + INVALID_FLAGS_HOLDER.get().put(flagKey, "invalid_flag"); + // A malformed flag must not prevent valid flags in the same configuration from loading. + } } - } + reader.endObject(); + return flags; + } + + private static void validateFlag(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; + } + for (final Allocation allocation : flag.allocations) { + if (allocation == null) { + continue; + } + validateConditionOperands(flagKey, allocation); + if (allocation.splits == null) { + continue; + } + for (final Split split : allocation.splits) { + if (split == null) { + continue; + } + if (split.shards == null) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" contains a split with missing shards"); + } + for (final Shard shard : split.shards) { + if (shard == null || Integer.toUnsignedLong(shard.totalShards) == 0 || shard.ranges == null) { + throw new IllegalArgumentException("flag \"" + flagKey + "\" contains invalid shards"); + } + for (final ShardRange range : shard.ranges) { + if (range == null) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" contains an invalid shard range"); + } + } + } + } + } + } + + private static void validateConditionOperands(final String flagKey, final Allocation allocation) { + if (allocation.rules == null) { + return; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition == null || condition.operator == null) { + continue; + } + switch (condition.operator) { + case LT: + case LTE: + case GT: + case GTE: + if (!(condition.value instanceof Number)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-numeric condition"); + } + break; + case ONE_OF: + case NOT_ONE_OF: + if (!(condition.value instanceof List)) { + throw new IllegalArgumentException("flag \"" + flagKey + "\" has a non-list condition"); + } + break; + case IS_NULL: + if (!(condition.value instanceof Boolean)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-boolean condition"); + } + break; + default: + break; + } + } + } + } + + @Override + public void toJson(final JsonWriter writer, final Map value) { + throw new UnsupportedOperationException("Reading only adapter"); } - } } - private static void validateConditionOperands( - final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; + private static final class DateAdapter extends JsonAdapter { + @Override + public Date fromJson(final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + try { + return Date.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); + } catch (final Exception ignored) { + return null; + } } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } + + @Override + public void toJson(final JsonWriter writer, final Date value) throws IOException { + if (value == null) { + writer.nullValue(); + return; + } + writer.value(value.toInstant().toString()); } - } - } - - @Override - public void toJson(final JsonWriter writer, final Map value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static final class DateAdapter extends JsonAdapter { - @Override - public Date fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - try { - return Date.from( - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); - } catch (final Exception ignored) { - return null; - } - } - - @Override - public void toJson(final JsonWriter writer, final Date value) throws IOException { - if (value == null) { - writer.nullValue(); - return; - } - writer.value(value.toInstant().toString()); - } - } + } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java index 3b7910d4712..b314d4a9daa 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java @@ -43,678 +43,640 @@ */ class FlagEvalLoggingHookTest { - @BeforeEach - void enableFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @AfterEach - void resetFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - // Clear any dispatched UFC so an observeFullEvaluationData value can't leak into other tests - // that share the static gateway. - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - // ---- helpers ---- - - /** - * Creates a writer that captures the enqueued event for assertion. Uses an anonymous class since - * FlagEvaluationWriter has multiple abstract methods. - */ - private FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; - } - - private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { - return new FlagEvalLoggingHook<>(writer); - } - - private static FlagEvaluationDetails details( - final String flagKey, - final Object value, - final String variant, - final String reason, - final ImmutableMetadata metadata) { - final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = - FlagEvaluationDetails.builder().flagKey(flagKey).value(value).reason(reason); - if (variant != null) { - builder.variant(variant); - } - if (metadata != null) { - builder.flagMetadata(metadata); - } - return builder.build(); - } - - private static HookContext hookCtxWithTargetingKey( - final String flagKey, final String targetingKey) { - final MutableContext ctx = new MutableContext(targetingKey); - return HookContext.builder() - .flagKey(flagKey) - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - } - - // ---- test: hook calls writer.enqueue once with flagKey, variant, allocationKey ---- - - @Test - void finallyAfterEnqueuesEventWithAllBasicFields() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details( - "my-flag", - "on-value", - "on", - Reason.TARGETING_MATCH.name(), - ImmutableMetadata.builder().addString("allocationKey", "alloc-1").build()); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get(), "writer.enqueue must be called once"); - final FlagEvalEvent e = captured.get(); - assertEquals("my-flag", e.flagKey); - assertEquals("on", e.variant, "variant must be the OpenFeature variant key"); - assertEquals("alloc-1", e.allocationKey); - } - - // ---- variant comes from details.getVariant(), NOT details.getValue() ---- - - @Test - void variantIsTheVariantKeyNotTheEvaluatedValue() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - // value and variant DIFFER, so a value-vs-variant mistake is detectable. - final FlagEvaluationDetails det = - details( - "g1-flag", - "the-evaluated-value", // value - "the-variant-key", // variant - Reason.TARGETING_MATCH.name(), - null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "the-variant-key", - captured.get().variant, - "variant must be sourced from details.getVariant(), not details.getValue()"); - } - - // ---- test: evalTimeMs from metadata "__dd_eval_timestamp_ms" ---- - - @Test - void evalTimeMsComesFromMetadataWhenPresent() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final long expectedTimestamp = 1_700_000_000_000L; - final FlagEvaluationDetails det = - details( - "ts-flag", - "v", - "v", - Reason.SPLIT.name(), - ImmutableMetadata.builder() - .addString("allocationKey", "a") - .addLong("__dd_eval_timestamp_ms", expectedTimestamp) - .build()); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - expectedTimestamp, - captured.get().evalTimeMs, - "evalTimeMs must come from __dd_eval_timestamp_ms metadata when present"); - } - - // ---- test: evalTimeMs falls back to System.currentTimeMillis() when absent ---- - - @Test - void evalTimeMsFallsBackToCurrentTimeWhenMetadataAbsent() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final long before = System.currentTimeMillis(); - final FlagEvaluationDetails det = - details("ts-flag", "v", "v", Reason.SPLIT.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - final long after = System.currentTimeMillis(); - assertNotNull(captured.get()); - final long ts = captured.get().evalTimeMs; - assertTrue( - ts >= before && ts <= after, - "evalTimeMs must fall back to hook-fire time when metadata absent. got: " + ts); - } - - // ---- test: absent variant -> variant is null -> runtime default ---- - - @Test - void absentVariantProducesNullVariant() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - // A runtime default returns the default value but no variant. - final FlagEvaluationDetails det = - details("def-flag", "default-value", null, Reason.DEFAULT.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().variant, "Absent variant must stay null (runtime default)"); - } - - // ---- test: error message captured from details (error object support) ---- - - @Test - void errorMessageCapturedFromDetailsUnderConsentOn() { - // With observeFullEvaluationData=true the provider's raw message is preserved verbatim so - // operators keep the diagnostic detail they opted in to. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage("value does not match declared type") - .flagMetadata(consentOnMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "value does not match declared type", - captured.get().errorMessage, - "errorMessage must be captured from the evaluation details under consent-on"); - } - - @Test - void errorMessageReplacedByErrorCodeUnderConsentOff() { - // Defense-in-depth: even if a provider hands us a raw message under consent-off (a bug in the - // provider, or a third-party provider that doesn't distinguish consent tiers), the hook must - // substitute the ErrorCode name so raw PII from exception messages never reaches the wire. - // Uses a PII-looking marker exactly like the wire-level guards in FlagEvaluationWriterImplTest. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage("For input string: \"jane.doe@datadoghq.com\"") - .flagMetadata(consentOffMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "TYPE_MISMATCH", - captured.get().errorMessage, - "consent-off must replace the raw message with the ErrorCode name"); - assertFalse( - captured.get().errorMessage.contains("jane.doe@datadoghq.com"), - "raw PII must never survive into the enqueued event under consent-off"); - } - - @Test - void errorMessageDroppedWhenConsentOffAndNoErrorCode() { - // Edge case: no ErrorCode available (unusual — providers should set one for ERROR reason). - // Consent-off drops the message and there's nothing to substitute, so the enqueued event has - // no error message at all. This is the strictest privacy-preserving outcome. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorMessage("For input string: \"jane.doe@datadoghq.com\"") - .flagMetadata(consentOffMetadata()) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().errorMessage); - } - - // ---- test: error code used as fallback message when error message is empty ---- - - @Test - void errorCodeUsedAsFallbackWhenMessageEmpty() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - FlagEvaluationDetails.builder() - .flagKey("err-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .build(); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "FLAG_NOT_FOUND", - captured.get().errorMessage, - "error code name must be used when no error message is present"); - } - - // ---- test: success path has no error message ---- - - @Test - void successPathHasNullErrorMessage() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details("ok-flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertNull(captured.get().errorMessage, "success path must have no error message"); - } - - // ---- test: hook does NO aggregation on the hook thread ---- - - @Test - void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - // Capacity check gates the enqueue; exactly one enqueue call; no start/close. - verify(writer, times(1)).hasCapacityForEnqueue(); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - verify(writer, never()).countPreQueueOverflow(); - verify(writer, never()).countContextTruncated(any()); - verify(writer, never()).close(); - verify(writer, never()).start(); - } - - @Test - void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(false); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(null, det, Collections.emptyMap()); - - // Pre-queue guard fires: one capacity check, one drop count, and no enqueue at all. - verify(writer, times(1)).hasCapacityForEnqueue(); - verify(writer, times(1)).countPreQueueOverflow(); - verify(writer, never()).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void countContextTruncatedCalledWhenContextIsTruncated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - // Context with one oversized string value — triggers max_value_length. - final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; - java.util.Arrays.fill(longChars, 'x'); - final MutableContext ctx = new MutableContext("user-1"); - ctx.add("oversized", new String(longChars)); - final HookContext hookCtx = - HookContext.builder() - .flagKey("flag") - .type(dev.openfeature.sdk.FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - final FlagEvaluationDetails det = - details( - "flag", - "v", - "v", - dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), - consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - verify(writer, times(1)).countContextTruncated("max_value_length"); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void countContextTruncatedNotCalledWhenNoTruncation() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - when(writer.hasCapacityForEnqueue()).thenReturn(true); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - final MutableContext ctx = new MutableContext("user-1"); - ctx.add("region", "us-east-1"); - final HookContext hookCtx = - HookContext.builder() - .flagKey("flag") - .type(dev.openfeature.sdk.FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - final FlagEvaluationDetails det = - details( - "flag", - "v", - "v", - dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), - consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - verify(writer, never()).countContextTruncated(any()); - verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - } - - @Test - void enqueueDisabledIsNoOpBeforeWriterLookup() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - final AtomicReference writerResolved = new AtomicReference<>(false); - final FlagEvalLoggingHook hook = - new FlagEvalLoggingHook<>( - () -> { - writerResolved.set(true); - throw new AssertionError("writer should not be resolved when enqueue is disabled"); - }); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(hookCtxWithTargetingKey("flag", "user-1"), det, Collections.emptyMap()); - - assertFalse(writerResolved.get()); - } - - // ---- test: writer=null -> no-op (killswitch off / not yet started) ---- - - @Test - void writerNullIsNoOp() { - final FlagEvalLoggingHook hook = hookWithWriter(null); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - // Must not throw; nothing is enqueued - hook.finallyAfter(null, det, Collections.emptyMap()); - } - - @Test - void writerLookupLinkageErrorIsNoOp() { - final FlagEvalLoggingHook hook = - new FlagEvalLoggingHook<>( - () -> { - throw new NoSuchMethodError("old bootstrap"); - }); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - assertDoesNotThrow(() -> hook.finallyAfter(null, det, Collections.emptyMap())); - } - - // ---- test: details=null -> no-op ---- - - @Test - void detailsNullIsNoOp() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); - final FlagEvalLoggingHook hook = hookWithWriter(writer); - - // Should not throw - hook.finallyAfter(null, null, Collections.emptyMap()); - - verifyNoInteractions(writer); - } - - // ---- test: targetingKey extracted from evaluation context ---- - - @Test - void targetingKeyExtractedFromContext() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.SPLIT.name(), null); - - final HookContext hookCtx = hookCtxWithTargetingKey("ctx-flag", "user-42"); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - assertEquals( - "user-42", - captured.get().targetingKey, - "targetingKey must be extracted from the evaluation context"); - } - - @Test - void contextAttributesAreFlattenedAndConvertedInline() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final Map profile = new HashMap<>(); - profile.put("tier", "gold"); - final Map attributes = new HashMap<>(); - attributes.put("score", 42); - attributes.put("profile", profile); - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - context.setTargetingKey("user-42"); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - - assertNotNull(captured.get()); - // Flatten now happens inline in DDEvaluator#copyPrunedContext on the hot path, so the event - // arrives with attrs already populated (no deferred supplier). - final Map attrs = captured.get().attrs; - assertFalse(attrs.isEmpty(), "hook must flatten context inline"); - assertEquals(42, attrs.get("score")); - assertEquals("gold", attrs.get("profile.tier")); - assertFalse(attrs.containsKey("targetingKey")); - assertTrue( - attrs.values().stream().noneMatch(Value.class::isInstance), - "context attrs must contain converted scalar values, not OpenFeature Value wrappers"); - } - - @Test - void contextAttributesUseEnqueueTimeSnapshot() { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final MutableContext context = new MutableContext("user-42"); - context.add("region", "us-east-1"); - final MutableStructure profile = new MutableStructure(); - profile.add("tier", "gold"); - context.add("profile", profile); - final List cohorts = new ArrayList<>(); - cohorts.add(Value.objectToValue("beta")); - context.add("cohorts", cohorts); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final FlagEvaluationDetails det = - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); - - hook.finallyAfter(hookCtx, det, Collections.emptyMap()); - context.add("region", "eu-west-1"); - context.add("late", "ignored"); - profile.add("tier", "platinum"); - profile.add("late", "ignored"); - cohorts.set(0, Value.objectToValue("ga")); - cohorts.add(Value.objectToValue("late")); - - assertNotNull(captured.get()); - final Map attrs = captured.get().attrs; - assertEquals("us-east-1", attrs.get("region")); - assertEquals("gold", attrs.get("profile.tier")); - assertEquals("beta", attrs.get("cohorts[0]")); - assertFalse(attrs.containsKey("late")); - assertFalse(attrs.containsKey("profile.late")); - assertFalse(attrs.containsKey("cohorts[1]")); - } - - // ---- observeFullEvaluationData is read from evaluation metadata, never the gateway ---- - - @Test - void readsObserveFullEvaluationDataTrueFromEvaluationMetadata() { - assertTrue(enqueuedEventWithConsentMetadata(true).observeFullEvaluationData); - } - - @Test - void readsObserveFullEvaluationDataFalseFromEvaluationMetadata() { - assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseWhenMetadataAbsent() { - // No metadata at all: fail-closed toward privacy. - assertFalse(enqueuedEventWithConsentMetadata(null).observeFullEvaluationData); - } - - @Test - void protectedPathSkipsEvaluationContextCapture() { - // Consent off → the hook must not snapshot the evaluation context at all. Verified by mutating - // the context after finallyAfter returns and asserting the enqueued event still sees nothing. - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - - final HookContext hookCtx = - HookContext.builder() - .flagKey("ctx-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(context) - .build(); - final ImmutableMetadata consentOff = - ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); - - hook.finallyAfter( - hookCtx, - details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOff), - Collections.emptyMap()); - context.add("region", "eu-west-1"); - - assertNotNull(captured.get()); - assertTrue(captured.get().attrs.isEmpty()); - } - - @Test - void ignoresGatewayConsentEvenWhenItDisagreesWithMetadata() { - // Gateway says on, metadata says off; hook must trust metadata. - FeatureFlaggingGateway.dispatch(observeConfig(true)); - try { - assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); - } finally { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - } - - /** - * Fires the hook once for a simple targeted evaluation whose metadata carries the given consent - * value ({@code null} = key absent) and returns the enqueued event. - */ - private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { - final AtomicReference captured = new AtomicReference<>(); - final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); - final ImmutableMetadata metadata = - consent == null - ? null - : ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, consent) + @BeforeEach + void enableFlagEvaluationEnqueue() { + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + } + + @AfterEach + void resetFlagEvaluationEnqueue() { + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + // Clear any dispatched UFC so an observeFullEvaluationData value can't leak into other tests + // that share the static gateway. + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } + + // ---- helpers ---- + + /** + * Creates a writer that captures the enqueued event for assertion. Uses an anonymous class since + * FlagEvaluationWriter has multiple abstract methods. + */ + private FlagEvaluationWriter capturingWriter(final AtomicReference ref) { + return new FlagEvaluationWriter() { + @Override + public void enqueue(final FlagEvalEvent event) { + ref.set(event); + } + + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} + + @Override + public void countContextTruncated(final String reason) {} + + @Override + public void start() {} + + @Override + public void close() {} + }; + } + + private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { + return new FlagEvalLoggingHook<>(writer); + } + + private static FlagEvaluationDetails details( + final String flagKey, + final Object value, + final String variant, + final String reason, + final ImmutableMetadata metadata) { + final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = + FlagEvaluationDetails.builder() + .flagKey(flagKey) + .value(value) + .reason(reason); + if (variant != null) { + builder.variant(variant); + } + if (metadata != null) { + builder.flagMetadata(metadata); + } + return builder.build(); + } + + private static HookContext hookCtxWithTargetingKey(final String flagKey, final String targetingKey) { + final MutableContext ctx = new MutableContext(targetingKey); + return HookContext.builder() + .flagKey(flagKey) + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + } + + // ---- test: hook calls writer.enqueue once with flagKey, variant, allocationKey ---- + + @Test + void finallyAfterEnqueuesEventWithAllBasicFields() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = details( + "my-flag", + "on-value", + "on", + Reason.TARGETING_MATCH.name(), + ImmutableMetadata.builder() + .addString("allocationKey", "alloc-1") + .build()); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get(), "writer.enqueue must be called once"); + final FlagEvalEvent e = captured.get(); + assertEquals("my-flag", e.flagKey); + assertEquals("on", e.variant, "variant must be the OpenFeature variant key"); + assertEquals("alloc-1", e.allocationKey); + } + + // ---- variant comes from details.getVariant(), NOT details.getValue() ---- + + @Test + void variantIsTheVariantKeyNotTheEvaluatedValue() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + // value and variant DIFFER, so a value-vs-variant mistake is detectable. + final FlagEvaluationDetails det = details( + "g1-flag", + "the-evaluated-value", // value + "the-variant-key", // variant + Reason.TARGETING_MATCH.name(), + null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "the-variant-key", + captured.get().variant, + "variant must be sourced from details.getVariant(), not details.getValue()"); + } + + // ---- test: evalTimeMs from metadata "__dd_eval_timestamp_ms" ---- + + @Test + void evalTimeMsComesFromMetadataWhenPresent() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final long expectedTimestamp = 1_700_000_000_000L; + final FlagEvaluationDetails det = details( + "ts-flag", + "v", + "v", + Reason.SPLIT.name(), + ImmutableMetadata.builder() + .addString("allocationKey", "a") + .addLong("__dd_eval_timestamp_ms", expectedTimestamp) + .build()); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + expectedTimestamp, + captured.get().evalTimeMs, + "evalTimeMs must come from __dd_eval_timestamp_ms metadata when present"); + } + + // ---- test: evalTimeMs falls back to System.currentTimeMillis() when absent ---- + + @Test + void evalTimeMsFallsBackToCurrentTimeWhenMetadataAbsent() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final long before = System.currentTimeMillis(); + final FlagEvaluationDetails det = details("ts-flag", "v", "v", Reason.SPLIT.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + final long after = System.currentTimeMillis(); + assertNotNull(captured.get()); + final long ts = captured.get().evalTimeMs; + assertTrue( + ts >= before && ts <= after, + "evalTimeMs must fall back to hook-fire time when metadata absent. got: " + ts); + } + + // ---- test: absent variant -> variant is null -> runtime default ---- + + @Test + void absentVariantProducesNullVariant() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + // A runtime default returns the default value but no variant. + final FlagEvaluationDetails det = + details("def-flag", "default-value", null, Reason.DEFAULT.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().variant, "Absent variant must stay null (runtime default)"); + } + + // ---- test: error message captured from details (error object support) ---- + + @Test + void errorMessageCapturedFromDetailsUnderConsentOn() { + // With observeFullEvaluationData=true the provider's raw message is preserved verbatim so + // operators keep the diagnostic detail they opted in to. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("value does not match declared type") + .flagMetadata(consentOnMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "value does not match declared type", + captured.get().errorMessage, + "errorMessage must be captured from the evaluation details under consent-on"); + } + + @Test + void errorMessageReplacedByErrorCodeUnderConsentOff() { + // Defense-in-depth: even if a provider hands us a raw message under consent-off (a bug in the + // provider, or a third-party provider that doesn't distinguish consent tiers), the hook must + // substitute the ErrorCode name so raw PII from exception messages never reaches the wire. + // Uses a PII-looking marker exactly like the wire-level guards in FlagEvaluationWriterImplTest. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("For input string: \"jane.doe@datadoghq.com\"") + .flagMetadata(consentOffMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "TYPE_MISMATCH", + captured.get().errorMessage, + "consent-off must replace the raw message with the ErrorCode name"); + assertFalse( + captured.get().errorMessage.contains("jane.doe@datadoghq.com"), + "raw PII must never survive into the enqueued event under consent-off"); + } + + @Test + void errorMessageDroppedWhenConsentOffAndNoErrorCode() { + // Edge case: no ErrorCode available (unusual — providers should set one for ERROR reason). + // Consent-off drops the message and there's nothing to substitute, so the enqueued event has + // no error message at all. This is the strictest privacy-preserving outcome. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorMessage("For input string: \"jane.doe@datadoghq.com\"") + .flagMetadata(consentOffMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().errorMessage); + } + + // ---- test: error code used as fallback message when error message is empty ---- + + @Test + void errorCodeUsedAsFallbackWhenMessageEmpty() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "FLAG_NOT_FOUND", + captured.get().errorMessage, + "error code name must be used when no error message is present"); + } + + // ---- test: success path has no error message ---- + + @Test + void successPathHasNullErrorMessage() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = details("ok-flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().errorMessage, "success path must have no error message"); + } + + // ---- test: hook does NO aggregation on the hook thread ---- + + @Test + void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final FlagEvaluationDetails det = details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + // Capacity check gates the enqueue; exactly one enqueue call; no start/close. + verify(writer, times(1)).hasCapacityForEnqueue(); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + verify(writer, never()).countPreQueueOverflow(); + verify(writer, never()).countContextTruncated(any()); + verify(writer, never()).close(); + verify(writer, never()).start(); + } + + @Test + void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(false); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final FlagEvaluationDetails det = details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + // Pre-queue guard fires: one capacity check, one drop count, and no enqueue at all. + verify(writer, times(1)).hasCapacityForEnqueue(); + verify(writer, times(1)).countPreQueueOverflow(); + verify(writer, never()).enqueue(any(FlagEvalEvent.class)); + } + + @Test + void countContextTruncatedCalledWhenContextIsTruncated() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + // Context with one oversized string value — triggers max_value_length. + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final MutableContext ctx = new MutableContext("user-1"); + ctx.add("oversized", new String(longChars)); + final HookContext hookCtx = HookContext.builder() + .flagKey("flag") + .type(dev.openfeature.sdk.FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) .build(); - hook.finallyAfter( - hookCtxWithTargetingKey("obs-flag", "user-1"), - details("obs-flag", "on", "on", Reason.TARGETING_MATCH.name(), metadata), - Collections.emptyMap()); - assertNotNull(captured.get(), "writer.enqueue must be called once"); - return captured.get(); - } - - private static ImmutableMetadata consentOnMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .build(); - } - - private static ImmutableMetadata consentOffMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); - } - - private static ServerConfiguration observeConfig(final boolean observeFullEvaluationData) { - return new ServerConfiguration( - "2024-04-17T19:40:53.716Z", - "SERVER", - observeFullEvaluationData, - null, - Collections.emptyMap()); - } + final FlagEvaluationDetails det = + details("flag", "v", "v", dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + verify(writer, times(1)).countContextTruncated("max_value_length"); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + } + + @Test + void countContextTruncatedNotCalledWhenNoTruncation() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final MutableContext ctx = new MutableContext("user-1"); + ctx.add("region", "us-east-1"); + final HookContext hookCtx = HookContext.builder() + .flagKey("flag") + .type(dev.openfeature.sdk.FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + final FlagEvaluationDetails det = + details("flag", "v", "v", dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + verify(writer, never()).countContextTruncated(any()); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + } + + @Test + void enqueueDisabledIsNoOpBeforeWriterLookup() { + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); + final AtomicReference writerResolved = new AtomicReference<>(false); + final FlagEvalLoggingHook hook = new FlagEvalLoggingHook<>(() -> { + writerResolved.set(true); + throw new AssertionError("writer should not be resolved when enqueue is disabled"); + }); + final FlagEvaluationDetails det = details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(hookCtxWithTargetingKey("flag", "user-1"), det, Collections.emptyMap()); + + assertFalse(writerResolved.get()); + } + + // ---- test: writer=null -> no-op (killswitch off / not yet started) ---- + + @Test + void writerNullIsNoOp() { + final FlagEvalLoggingHook hook = hookWithWriter(null); + final FlagEvaluationDetails det = details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + // Must not throw; nothing is enqueued + hook.finallyAfter(null, det, Collections.emptyMap()); + } + + @Test + void writerLookupLinkageErrorIsNoOp() { + final FlagEvalLoggingHook hook = new FlagEvalLoggingHook<>(() -> { + throw new NoSuchMethodError("old bootstrap"); + }); + final FlagEvaluationDetails det = details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + assertDoesNotThrow(() -> hook.finallyAfter(null, det, Collections.emptyMap())); + } + + // ---- test: details=null -> no-op ---- + + @Test + void detailsNullIsNoOp() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + // Should not throw + hook.finallyAfter(null, null, Collections.emptyMap()); + + verifyNoInteractions(writer); + } + + // ---- test: targetingKey extracted from evaluation context ---- + + @Test + void targetingKeyExtractedFromContext() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = details("ctx-flag", "v", "v", Reason.SPLIT.name(), null); + + final HookContext hookCtx = hookCtxWithTargetingKey("ctx-flag", "user-42"); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "user-42", captured.get().targetingKey, "targetingKey must be extracted from the evaluation context"); + } + + @Test + void contextAttributesAreFlattenedAndConvertedInline() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final Map profile = new HashMap<>(); + profile.put("tier", "gold"); + final Map attributes = new HashMap<>(); + attributes.put("score", 42); + attributes.put("profile", profile); + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + context.setTargetingKey("user-42"); + + final HookContext hookCtx = HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + // Flatten now happens inline in DDEvaluator#copyPrunedContext on the hot path, so the event + // arrives with attrs already populated (no deferred supplier). + final Map attrs = captured.get().attrs; + assertFalse(attrs.isEmpty(), "hook must flatten context inline"); + assertEquals(42, attrs.get("score")); + assertEquals("gold", attrs.get("profile.tier")); + assertFalse(attrs.containsKey("targetingKey")); + assertTrue( + attrs.values().stream().noneMatch(Value.class::isInstance), + "context attrs must contain converted scalar values, not OpenFeature Value wrappers"); + } + + @Test + void contextAttributesUseEnqueueTimeSnapshot() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final MutableContext context = new MutableContext("user-42"); + context.add("region", "us-east-1"); + final MutableStructure profile = new MutableStructure(); + profile.add("tier", "gold"); + context.add("profile", profile); + final List cohorts = new ArrayList<>(); + cohorts.add(Value.objectToValue("beta")); + context.add("cohorts", cohorts); + + final HookContext hookCtx = HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + context.add("region", "eu-west-1"); + context.add("late", "ignored"); + profile.add("tier", "platinum"); + profile.add("late", "ignored"); + cohorts.set(0, Value.objectToValue("ga")); + cohorts.add(Value.objectToValue("late")); + + assertNotNull(captured.get()); + final Map attrs = captured.get().attrs; + assertEquals("us-east-1", attrs.get("region")); + assertEquals("gold", attrs.get("profile.tier")); + assertEquals("beta", attrs.get("cohorts[0]")); + assertFalse(attrs.containsKey("late")); + assertFalse(attrs.containsKey("profile.late")); + assertFalse(attrs.containsKey("cohorts[1]")); + } + + // ---- observeFullEvaluationData is read from evaluation metadata, never the gateway ---- + + @Test + void readsObserveFullEvaluationDataTrueFromEvaluationMetadata() { + assertTrue(enqueuedEventWithConsentMetadata(true).observeFullEvaluationData); + } + + @Test + void readsObserveFullEvaluationDataFalseFromEvaluationMetadata() { + assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); + } + + @Test + void observeFullEvaluationDataDefaultsToFalseWhenMetadataAbsent() { + // No metadata at all: fail-closed toward privacy. + assertFalse(enqueuedEventWithConsentMetadata(null).observeFullEvaluationData); + } + + @Test + void protectedPathSkipsEvaluationContextCapture() { + // Consent off → the hook must not snapshot the evaluation context at all. Verified by mutating + // the context after finallyAfter returns and asserting the enqueued event still sees nothing. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + + final HookContext hookCtx = HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final ImmutableMetadata consentOff = ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) + .build(); + + hook.finallyAfter( + hookCtx, + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOff), + Collections.emptyMap()); + context.add("region", "eu-west-1"); + + assertNotNull(captured.get()); + assertTrue(captured.get().attrs.isEmpty()); + } + + @Test + void ignoresGatewayConsentEvenWhenItDisagreesWithMetadata() { + // Gateway says on, metadata says off; hook must trust metadata. + FeatureFlaggingGateway.dispatch(observeConfig(true)); + try { + assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); + } finally { + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } + } + + /** + * Fires the hook once for a simple targeted evaluation whose metadata carries the given consent + * value ({@code null} = key absent) and returns the enqueued event. + */ + private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + final ImmutableMetadata metadata = consent == null + ? null + : ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, consent) + .build(); + hook.finallyAfter( + hookCtxWithTargetingKey("obs-flag", "user-1"), + details("obs-flag", "on", "on", Reason.TARGETING_MATCH.name(), metadata), + Collections.emptyMap()); + assertNotNull(captured.get(), "writer.enqueue must be called once"); + return captured.get(); + } + + private static ImmutableMetadata consentOnMetadata() { + return ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) + .build(); + } + + private static ImmutableMetadata consentOffMetadata() { + return ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) + .build(); + } + + private static ServerConfiguration observeConfig(final boolean observeFullEvaluationData) { + return new ServerConfiguration( + "2024-04-17T19:40:53.716Z", "SERVER", observeFullEvaluationData, null, Collections.emptyMap()); + } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java index 322a06d2de7..16f0a88b08f 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java @@ -15,112 +15,100 @@ class FlagEvalMetricsHookTest { - @Test - void finallyAfterRecordsBasicEvaluation() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value("on-value") - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata( - ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build()) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record( - eq("my-flag"), - eq("on"), - eq(Reason.TARGETING_MATCH.name()), - isNull(), - eq("default-alloc")); - } - - @Test - void finallyAfterRecordsErrorEvaluation() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("missing-flag") - .value("default") - .reason(Reason.ERROR.name()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record( - eq("missing-flag"), - isNull(), - eq(Reason.ERROR.name()), - eq(ErrorCode.FLAG_NOT_FOUND), - isNull()); - } - - @Test - void finallyAfterHandlesNullFlagMetadata() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value(true) - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics) - .record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull()); - } - - @Test - void finallyAfterHandlesNullVariantAndReason() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder().flagKey("my-flag").value("default").build(); - - hook.finallyAfter(null, details, Collections.emptyMap()); - - verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull()); - } - - @Test - void finallyAfterNeverThrows() { - FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); - - // Should not throw even with completely null inputs - hook.finallyAfter(null, null, null); - - verifyNoInteractions(metrics); - } - - @Test - void finallyAfterIsNoOpWhenMetricsIsNull() { - FlagEvalMetricsHook hook = new FlagEvalMetricsHook(null); - - FlagEvaluationDetails details = - FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value(true) - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .build(); - - // Should not throw - hook.finallyAfter(null, details, Collections.emptyMap()); - } + @Test + void finallyAfterRecordsBasicEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata(ImmutableMetadata.builder() + .addString("allocationKey", "default-alloc") + .build()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), eq("default-alloc")); + } + + @Test + void finallyAfterRecordsErrorEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = FlagEvaluationDetails.builder() + .flagKey("missing-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record(eq("missing-flag"), isNull(), eq(Reason.ERROR.name()), eq(ErrorCode.FLAG_NOT_FOUND), isNull()); + } + + @Test + void finallyAfterHandlesNullFlagMetadata() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics).record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull()); + } + + @Test + void finallyAfterHandlesNullVariantAndReason() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value("default") + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull()); + } + + @Test + void finallyAfterNeverThrows() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + // Should not throw even with completely null inputs + hook.finallyAfter(null, null, null); + + verifyNoInteractions(metrics); + } + + @Test + void finallyAfterIsNoOpWhenMetricsIsNull() { + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(null); + + FlagEvaluationDetails details = FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + // Should not throw + hook.finallyAfter(null, details, Collections.emptyMap()); + } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java index 214fadc7274..0486b2b055f 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java @@ -13,153 +13,153 @@ class FlagEvalMetricsTest { - @Test - void recordBasicAttributes() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); - - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.key", "my-flag"); - assertAttribute(attrs, "feature_flag.result.variant", "on"); - assertAttribute(attrs, "feature_flag.result.reason", "targeting_match"); - assertNoAttribute(attrs, "error.type"); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } + @Test + void recordBasicAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "my-flag"); + assertAttribute(attrs, "feature_flag.result.variant", "on"); + assertAttribute(attrs, "feature_flag.result.reason", "targeting_match"); + assertNoAttribute(attrs, "error.type"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } - @Test - void recordErrorAttributes() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + @Test + void recordErrorAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - metrics.record("missing-flag", "", "ERROR", ErrorCode.FLAG_NOT_FOUND, null); + metrics.record("missing-flag", "", "ERROR", ErrorCode.FLAG_NOT_FOUND, null); - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.key", "missing-flag"); - assertAttribute(attrs, "feature_flag.result.variant", ""); - assertAttribute(attrs, "feature_flag.result.reason", "error"); - assertAttribute(attrs, "error.type", "flag_not_found"); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "missing-flag"); + assertAttribute(attrs, "feature_flag.result.variant", ""); + assertAttribute(attrs, "feature_flag.result.reason", "error"); + assertAttribute(attrs, "error.type", "flag_not_found"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } - @Test - void recordTypeMismatchError() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + @Test + void recordTypeMismatchError() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - metrics.record("my-flag", "", "ERROR", ErrorCode.TYPE_MISMATCH, null); + metrics.record("my-flag", "", "ERROR", ErrorCode.TYPE_MISMATCH, null); - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "error.type", "type_mismatch"); - } + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "error.type", "type_mismatch"); + } - @Test - void recordWithAllocationKey() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + @Test + void recordWithAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - metrics.record("my-flag", "on", "TARGETING_MATCH", null, "default-allocation"); + metrics.record("my-flag", "on", "TARGETING_MATCH", null, "default-allocation"); - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.allocation_key", "default-allocation"); - } + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.allocation_key", "default-allocation"); + } - @Test - void recordOmitsEmptyAllocationKey() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + @Test + void recordOmitsEmptyAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - metrics.record("my-flag", "on", "TARGETING_MATCH", null, ""); + metrics.record("my-flag", "on", "TARGETING_MATCH", null, ""); - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); - Attributes attrs = captor.getValue(); - assertNoAttribute(attrs, "feature_flag.result.allocation_key"); - } + Attributes attrs = captor.getValue(); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } - @Test - void recordNullVariantBecomesEmptyString() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + @Test + void recordNullVariantBecomesEmptyString() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - metrics.record("my-flag", null, "DEFAULT", null, null); + metrics.record("my-flag", null, "DEFAULT", null, null); - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.variant", ""); - } + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.variant", ""); + } - @Test - void recordNullReasonBecomesUnknown() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + @Test + void recordNullReasonBecomesUnknown() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - metrics.record("my-flag", "on", null, null, null); + metrics.record("my-flag", "on", null, null, null); - ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); - verify(counter).add(eq(1L), captor.capture()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); - Attributes attrs = captor.getValue(); - assertAttribute(attrs, "feature_flag.result.reason", "unknown"); - } + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.reason", "unknown"); + } - @Test - void recordIsNoOpWhenCounterIsNull() { - FlagEvalMetrics metrics = new FlagEvalMetrics((LongCounter) null); - // Should not throw - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - } + @Test + void recordIsNoOpWhenCounterIsNull() { + FlagEvalMetrics metrics = new FlagEvalMetrics((LongCounter) null); + // Should not throw + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + } - @Test - void shutdownClearsCounter() { - LongCounter counter = mock(LongCounter.class); - FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + @Test + void shutdownClearsCounter() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); - metrics.shutdown(); - metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + metrics.shutdown(); + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); - verifyNoInteractions(counter); - } + verifyNoInteractions(counter); + } - @Test - void defaultConstructorUsesOpenTelemetryApiOnly() { - try (FlagEvalMetrics metrics = new FlagEvalMetrics()) { - metrics.record("count-flag", "on", "STATIC", null, "default-alloc"); + @Test + void defaultConstructorUsesOpenTelemetryApiOnly() { + try (FlagEvalMetrics metrics = new FlagEvalMetrics()) { + metrics.record("count-flag", "on", "STATIC", null, "default-alloc"); + } } - } - - private static void assertAttribute(Attributes attrs, String key, String expected) { - String value = - attrs.asMap().entrySet().stream() - .filter(e -> e.getKey().getKey().equals(key)) - .map(e -> e.getValue().toString()) - .findFirst() - .orElse(null); - if (!expected.equals(value)) { - throw new AssertionError("Expected attribute " + key + "=" + expected + " but got " + value); + + private static void assertAttribute(Attributes attrs, String key, String expected) { + String value = attrs.asMap().entrySet().stream() + .filter(e -> e.getKey().getKey().equals(key)) + .map(e -> e.getValue().toString()) + .findFirst() + .orElse(null); + if (!expected.equals(value)) { + throw new AssertionError("Expected attribute " + key + "=" + expected + " but got " + value); + } } - } - private static void assertNoAttribute(Attributes attrs, String key) { - boolean present = attrs.asMap().keySet().stream().anyMatch(k -> k.getKey().equals(key)); - if (present) { - throw new AssertionError("Expected no attribute " + key + " but it was present"); + private static void assertNoAttribute(Attributes attrs, String key) { + boolean present = + attrs.asMap().keySet().stream().anyMatch(k -> k.getKey().equals(key)); + if (present) { + throw new AssertionError("Expected no attribute " + key + " but it was present"); + } } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java index d7adf645e74..318b6e175d2 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -54,455 +54,421 @@ public class ProviderTest { - private static final long EVENT_TIMEOUT_SECONDS = 10; - - private ExecutorService executor; - - @BeforeEach - public void setup() { - executor = Executors.newSingleThreadExecutor(); - } - - @AfterEach - public void tearDown() { - executor.shutdownNow(); - OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - public void testSetProvider() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final CompletableFuture readyEvent = new CompletableFuture<>(); - api.onProviderReady(readyEvent::complete); - api.setProvider(new Provider()); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @Test - public void testSetProviderAndWait() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Future provider = executor.submit(() -> api.setProviderAndWait(new Provider())); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @Test - public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() throws Exception { - final CompletableFuture readyEvent = new CompletableFuture<>(); - final Consumer readyEventHandler = completingHandler(readyEvent); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Client client = api.getClient(); - client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); - - assertThrows( - ProviderNotReadyError.class, - () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); - - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - assertFalse(readyEvent.isDone()); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - - final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - assertThat(eventDetails.getProviderName(), equalTo(METADATA)); - verify(readyEventHandler, times(1)).accept(any()); - } - - @Test - public void testSetProviderAndWaitCompletesWhenConfigurationArrivesAtTimeoutBoundary() - throws Exception { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - return false; - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } + private static final long EVENT_TIMEOUT_SECONDS = 10; + + private ExecutorService executor; + + @BeforeEach + public void setup() { + executor = Executors.newSingleThreadExecutor(); + } + + @AfterEach + public void tearDown() { + executor.shutdownNow(); + OpenFeatureAPI.getInstance().shutdown(); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + FeatureFlaggingGateway.setFlagEvalWriter(null); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + } + + @Test + public void testSetProvider() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final CompletableFuture readyEvent = new CompletableFuture<>(); + api.onProviderReady(readyEvent::complete); + api.setProvider(new Provider()); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); + + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWait() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final Future provider = executor.submit(() -> api.setProviderAndWait(new Provider())); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); + + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() throws Exception { + final CompletableFuture readyEvent = new CompletableFuture<>(); + final Consumer readyEventHandler = completingHandler(readyEvent); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final Client client = api.getClient(); + client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); + + assertThrows( + ProviderNotReadyError.class, + () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); + + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + assertFalse(readyEvent.isDone()); + + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + + final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + assertThat(eventDetails.getProviderName(), equalTo(METADATA)); + verify(readyEventHandler, times(1)).accept(any()); + } + + @Test + public void testSetProviderAndWaitCompletesWhenConfigurationArrivesAtTimeoutBoundary() throws Exception { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, final java.util.concurrent.TimeUnit timeUnit, final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + return false; + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, final String key, final T defaultValue, final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } }; - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - api.setProviderAndWait(providerRef[0]); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - } - - @Test - public void testSetProviderAndWaitFailsWhenConfigurationIsRemovedBeforeInitializationCompletes() { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - hasConfiguration = false; - providerRef[0].onConfigurationChange(); - return true; - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + api.setProviderAndWait(providerRef[0]); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWaitFailsWhenConfigurationIsRemovedBeforeInitializationCompletes() { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, final java.util.concurrent.TimeUnit timeUnit, final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + hasConfiguration = false; + providerRef[0].onConfigurationChange(); + return true; + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, final String key, final T defaultValue, final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } }; - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - - assertThrows(ProviderNotReadyError.class, () -> api.setProviderAndWait(providerRef[0])); - - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - } - - @Test - public void testInitializationErrorDoesNotOverwriteRecoveredReadyState() throws Exception { - final Provider[] providerRef = new Provider[1]; - final Evaluator evaluator = - new Evaluator() { - private boolean hasConfiguration; - - @Override - public boolean initialize( - final long timeout, - final java.util.concurrent.TimeUnit timeUnit, - final EvaluationContext context) { - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - hasConfiguration = false; - providerRef[0].onConfigurationChange(); - hasConfiguration = true; - providerRef[0].onConfigurationChange(); - throw new ProviderNotReadyError( - "Provider timed-out while waiting for initial configuration"); - } - - @Override - public boolean hasConfiguration() { - return hasConfiguration; - } - - @Override - public void shutdown() {} - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - return ProviderEvaluation.builder().value(defaultValue).build(); - } + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + + assertThrows(ProviderNotReadyError.class, () -> api.setProviderAndWait(providerRef[0])); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + } + + @Test + public void testInitializationErrorDoesNotOverwriteRecoveredReadyState() throws Exception { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, final java.util.concurrent.TimeUnit timeUnit, final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + hasConfiguration = false; + providerRef[0].onConfigurationChange(); + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + throw new ProviderNotReadyError("Provider timed-out while waiting for initial configuration"); + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, final String key, final T defaultValue, final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } }; - providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); - - assertThrows(ProviderNotReadyError.class, () -> providerRef[0].initialize(null)); - - assertThat(initializationState(providerRef[0]), equalTo("READY")); - } - - @Test - public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throws Exception { - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - api.setProviderAndWait(new Provider()); - final Client client = api.getClient(); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - - final CompletableFuture errorEvent = new CompletableFuture<>(); - final CompletableFuture readyEvent = new CompletableFuture<>(); - final CompletableFuture configChangedEvent = new CompletableFuture<>(); - final Consumer errorEventHandler = completingHandler(errorEvent); - final Consumer readyEventHandler = completingHandler(readyEvent); - final Consumer configChangedEventHandler = completingHandler(configChangedEvent); - client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); - client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); - - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); - assertThat(eventDetails.getProviderName(), equalTo(METADATA)); - - final FlagEvaluationDetails evalDetails = client.getStringDetails("missing", "default"); - assertThat(evalDetails.getValue(), equalTo("default")); - assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - - client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); - verify(errorEventHandler, times(1)).accept(any()); - verify(readyEventHandler, times(1)).accept(any()); - verify(configChangedEventHandler, times(1)).accept(any()); - } - - @Test - public void testFailureToLoadInternalApi() { - @SuppressWarnings("unchecked") - final Consumer consumer = mock(Consumer.class); - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.onProviderError(consumer); - - assertThrows( - FatalError.class, - () -> - api.setProviderAndWait( - new Provider() { - @Override - protected Class loadEvaluatorClass() throws ClassNotFoundException { - throw new ClassNotFoundException( - "Class " + FeatureFlaggingGateway.class.getName() + " not found"); - } + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + + assertThrows(ProviderNotReadyError.class, () -> providerRef[0].initialize(null)); + + assertThat(initializationState(providerRef[0]), equalTo("READY")); + } + + @Test + public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + api.setProviderAndWait(new Provider()); + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + + final CompletableFuture errorEvent = new CompletableFuture<>(); + final CompletableFuture readyEvent = new CompletableFuture<>(); + final CompletableFuture configChangedEvent = new CompletableFuture<>(); + final Consumer errorEventHandler = completingHandler(errorEvent); + final Consumer readyEventHandler = completingHandler(readyEvent); + final Consumer configChangedEventHandler = completingHandler(configChangedEvent); + client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); + client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); + + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + assertThat(eventDetails.getProviderName(), equalTo(METADATA)); + + final FlagEvaluationDetails evalDetails = client.getStringDetails("missing", "default"); + assertThat(evalDetails.getValue(), equalTo("default")); + assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + + client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + verify(errorEventHandler, times(1)).accept(any()); + verify(readyEventHandler, times(1)).accept(any()); + verify(configChangedEventHandler, times(1)).accept(any()); + } + + @Test + public void testFailureToLoadInternalApi() { + @SuppressWarnings("unchecked") + final Consumer consumer = mock(Consumer.class); + + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.onProviderError(consumer); + + assertThrows( + FatalError.class, + () -> api.setProviderAndWait(new Provider() { + @Override + protected Class loadEvaluatorClass() throws ClassNotFoundException { + throw new ClassNotFoundException( + "Class " + FeatureFlaggingGateway.class.getName() + " not found"); + } })); - } - - @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHook() { - Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); - List hooks = provider.getProviderHooks(); - // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) - assertThat(hooks.size(), equalTo(2)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); - } - - @Test - public void testGetProviderHooksSkipsFlagEvalLoggingHookOnLinkageFailure() { - Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)) { - @Override - Hook buildFlagEvalLoggingHook() { - throw new NoClassDefFoundError("old bootstrap"); - } + } + + @Test + public void testGetProviderHooksReturnsFlagEvalMetricsHook() { + Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); + List hooks = provider.getProviderHooks(); + // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) + assertThat(hooks.size(), equalTo(2)); + assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); + assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); + } + + @Test + public void testGetProviderHooksSkipsFlagEvalLoggingHookOnLinkageFailure() { + Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)) { + @Override + Hook buildFlagEvalLoggingHook() { + throw new NoClassDefFoundError("old bootstrap"); + } + }; + + List hooks = provider.getProviderHooks(); + + assertThat(hooks.size(), equalTo(1)); + assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); + } + + @Test + public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + final AtomicReference captured = new AtomicReference<>(); + FeatureFlaggingGateway.setFlagEvalWriter(capturingWriter(captured)); + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + when(evaluator.evaluate(eq(String.class), eq("logged-flag"), eq("default"), any())) + .thenReturn(ProviderEvaluation.builder() + .value("value") + .reason("STATIC") + .variant("variant-1") + .flagMetadata(ImmutableMetadata.builder() + .addString("allocationKey", "allocation-1") + .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) + .build()) + .build()); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + + final FlagEvaluationDetails details = + api.getClient().getStringDetails("logged-flag", "default", context); + + assertThat(details.getValue(), equalTo("value")); + final FlagEvalEvent event = captured.get(); + assertThat(event.flagKey, equalTo("logged-flag")); + assertThat(event.variant, equalTo("variant-1")); + assertThat(event.allocationKey, equalTo("allocation-1")); + assertThat(event.targetingKey, equalTo("user-1")); + assertThat(event.evalTimeMs, equalTo(1_700_000_000_000L)); + assertThat(event.attrs.get("region"), equalTo("us-east-1")); + } + + @Test + public void testGetProviderHooksReturnsFlagEvalMetricsHookWithAndWithoutSpanEnrichment() { + final Evaluator evaluator = mock(Evaluator.class); + final Provider providerWithoutSpanEnrichment = new Provider(new Options(), evaluator, Boolean.FALSE); + final Provider providerWithSpanEnrichment = new Provider(new Options(), evaluator, Boolean.TRUE); + + assertHasFlagEvalMetricsHook(providerWithoutSpanEnrichment); + assertHasFlagEvalMetricsHook(providerWithSpanEnrichment); + } + + @Test + public void testShutdownCleansUpEvaluator() throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + final Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator, Boolean.FALSE); + + provider.initialize(null); + provider.shutdown(); + + verify(evaluator).shutdown(); + // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks + assertThat(provider.getProviderHooks().size(), equalTo(2)); + } + + private static void assertHasFlagEvalMetricsHook(final Provider provider) { + assertTrue( + provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), + "flag evaluation metrics hook should be registered"); + } + + public interface EvaluateMethod { + FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); + } + + private static Arguments[] providerMethods() { + return new Arguments[] { + Arguments.of("bool", false, (EvaluateMethod) Features::getBooleanDetails), + Arguments.of("string", "Hello!", (EvaluateMethod) Features::getStringDetails), + Arguments.of("int", 23, (EvaluateMethod) Features::getIntegerDetails), + Arguments.of("double", 3.14D, (EvaluateMethod) Features::getDoubleDetails), + Arguments.of("object", new Value(), (EvaluateMethod) Features::getObjectDetails) }; + } + + @MethodSource("providerMethods") + @ParameterizedTest + public void testProviderEvaluation(final String flag, final E defaultValue, final EvaluateMethod method) + throws Exception { + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + when(evaluator.evaluate(any(), any(), any(), any())) + .thenAnswer(invocation -> ProviderEvaluation.builder() + .value(invocation.getArgument(2)) + .reason("MOCK") + .build()); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + final Client client = api.getClient(); + final FlagEvaluationDetails result = method.evaluate(client, flag, defaultValue); + assertThat(result.getValue(), equalTo(defaultValue)); + assertThat(result.getReason(), equalTo("MOCK")); + verify(evaluator, times(1)).initialize(eq(10L), eq(SECONDS), any()); + verify(evaluator, times(1)).evaluate(any(), eq(flag), eq(defaultValue), any(EvaluationContext.class)); + } + + private static String initializationState(final Provider provider) throws Exception { + final Field stateField = Provider.class.getDeclaredField("initializationState"); + stateField.setAccessible(true); + final AtomicReference state = (AtomicReference) stateField.get(provider); + return state.get().toString(); + } - List hooks = provider.getProviderHooks(); - - assertThat(hooks.size(), equalTo(1)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - } - - @Test - public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - final AtomicReference captured = new AtomicReference<>(); - FeatureFlaggingGateway.setFlagEvalWriter(capturingWriter(captured)); - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - when(evaluator.evaluate(eq(String.class), eq("logged-flag"), eq("default"), any())) - .thenReturn( - ProviderEvaluation.builder() - .value("value") - .reason("STATIC") - .variant("variant-1") - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "allocation-1") - .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .build()) - .build()); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - - final FlagEvaluationDetails details = - api.getClient().getStringDetails("logged-flag", "default", context); - - assertThat(details.getValue(), equalTo("value")); - final FlagEvalEvent event = captured.get(); - assertThat(event.flagKey, equalTo("logged-flag")); - assertThat(event.variant, equalTo("variant-1")); - assertThat(event.allocationKey, equalTo("allocation-1")); - assertThat(event.targetingKey, equalTo("user-1")); - assertThat(event.evalTimeMs, equalTo(1_700_000_000_000L)); - assertThat(event.attrs.get("region"), equalTo("us-east-1")); - } - - @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHookWithAndWithoutSpanEnrichment() { - final Evaluator evaluator = mock(Evaluator.class); - final Provider providerWithoutSpanEnrichment = - new Provider(new Options(), evaluator, Boolean.FALSE); - final Provider providerWithSpanEnrichment = - new Provider(new Options(), evaluator, Boolean.TRUE); - - assertHasFlagEvalMetricsHook(providerWithoutSpanEnrichment); - assertHasFlagEvalMetricsHook(providerWithSpanEnrichment); - } - - @Test - public void testShutdownCleansUpEvaluator() throws Exception { - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - final Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator, Boolean.FALSE); - - provider.initialize(null); - provider.shutdown(); - - verify(evaluator).shutdown(); - // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks - assertThat(provider.getProviderHooks().size(), equalTo(2)); - } - - private static void assertHasFlagEvalMetricsHook(final Provider provider) { - assertTrue( - provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), - "flag evaluation metrics hook should be registered"); - } - - public interface EvaluateMethod { - FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); - } - - private static Arguments[] providerMethods() { - return new Arguments[] { - Arguments.of("bool", false, (EvaluateMethod) Features::getBooleanDetails), - Arguments.of("string", "Hello!", (EvaluateMethod) Features::getStringDetails), - Arguments.of("int", 23, (EvaluateMethod) Features::getIntegerDetails), - Arguments.of("double", 3.14D, (EvaluateMethod) Features::getDoubleDetails), - Arguments.of("object", new Value(), (EvaluateMethod) Features::getObjectDetails) - }; - } - - @MethodSource("providerMethods") - @ParameterizedTest - public void testProviderEvaluation( - final String flag, final E defaultValue, final EvaluateMethod method) throws Exception { - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - when(evaluator.evaluate(any(), any(), any(), any())) - .thenAnswer( - invocation -> - ProviderEvaluation.builder() - .value(invocation.getArgument(2)) - .reason("MOCK") - .build()); - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); - final Client client = api.getClient(); - final FlagEvaluationDetails result = method.evaluate(client, flag, defaultValue); - assertThat(result.getValue(), equalTo(defaultValue)); - assertThat(result.getReason(), equalTo("MOCK")); - verify(evaluator, times(1)).initialize(eq(10L), eq(SECONDS), any()); - verify(evaluator, times(1)) - .evaluate(any(), eq(flag), eq(defaultValue), any(EvaluationContext.class)); - } - - private static String initializationState(final Provider provider) throws Exception { - final Field stateField = Provider.class.getDeclaredField("initializationState"); - stateField.setAccessible(true); - final AtomicReference state = (AtomicReference) stateField.get(provider); - return state.get().toString(); - } - - @SuppressWarnings("unchecked") - private static Consumer completingHandler( - final CompletableFuture event) { - final Consumer handler = mock(Consumer.class); - doAnswer( - invocation -> { - event.complete(invocation.getArgument(0)); - return null; - }) - .when(handler) - .accept(any()); - return handler; - } - - private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; - } + @SuppressWarnings("unchecked") + private static Consumer completingHandler(final CompletableFuture event) { + final Consumer handler = mock(Consumer.class); + doAnswer(invocation -> { + event.complete(invocation.getArgument(0)); + return null; + }) + .when(handler) + .accept(any()); + return handler; + } + + private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { + return new FlagEvaluationWriter() { + @Override + public void enqueue(final FlagEvalEvent event) { + ref.set(event); + } + + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} + + @Override + public void countContextTruncated(final String reason) {} + + @Override + public void start() {} + + @Override + public void close() {} + }; + } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java index 2ce0931e116..f11e1989d50 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java @@ -34,208 +34,203 @@ */ class SpanEnrichmentHookTest { - private final List captured = new ArrayList<>(); - private final FeatureFlaggingGateway.SpanEnrichmentListener listener = captured::add; - - @BeforeEach - void register() { - FeatureFlaggingGateway.addSpanEnrichmentListener(listener); - } - - @AfterEach - void deregister() { - FeatureFlaggingGateway.removeSpanEnrichmentListener(listener); - } - - // ---- helpers ---- - - private static FlagEvaluationDetails details( - final String flagKey, - final String variant, - final Object value, - final ImmutableMetadata metadata) { - return FlagEvaluationDetails.builder() - .flagKey(flagKey) - .variant(variant) - .value(value) - .flagMetadata(metadata) - .build(); - } - - private static ImmutableMetadata metadata(final Integer serialId, final boolean doLog) { - final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); - if (serialId != null) { - builder.addInteger(SpanEnrichmentHook.METADATA_SERIAL_ID, serialId); - } - builder.addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, doLog); - return builder.build(); - } - - private static HookContext ctx(final String flagKey, final String targetingKey) { - return HookContext.from( - flagKey, - FlagValueType.STRING, - null, - null, - targetingKey == null ? new ImmutableContext() : new ImmutableContext(targetingKey), - "default"); - } - - // ---- serial-id branch ---- - - @Test - void serialIdWithDoLogDispatchesSerialAndSubject() { - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), - details("flag", "on", "v", metadata(42, true)), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertTrue(event.hasSerialId()); - assertEquals(42, event.serialId()); - assertTrue(event.doLog()); - assertEquals("user-1", event.targetingKey()); - } - - @Test - void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), - details("flag", "on", "v", metadata(7, false)), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertTrue(event.hasSerialId()); - assertEquals(7, event.serialId()); - assertFalse( - event.doLog(), "doLog=false must be carried through so the write side skips subject"); - } - - @Test - void wrongTypedSerialIdDispatchesNothing() { - // Defensive: a non-integer value under the serial-id key (wrong type) is ignored, not crashed. - final ImmutableMetadata bad = - ImmutableMetadata.builder() - .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") - .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) - .build(); - new SpanEnrichmentHook() - .finallyAfter( - ctx("flag", "user-1"), details("flag", "on", "v", bad), Collections.emptyMap()); - assertTrue(captured.isEmpty(), "a wrong-typed serial id must never break eval or dispatch"); - } - - // ---- runtime-default branch (missing variant) ---- - - @Test - void missingVariantDispatchesRuntimeDefaultWithNativeMap() { - final Map objectValue = Collections.singletonMap("k", "val"); - new SpanEnrichmentHook() - .finallyAfter( - ctx("obj-flag", "user-1"), - details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final SpanEnrichmentEvent event = captured.get(0); - assertFalse(event.hasSerialId()); - assertEquals("obj-flag", event.flagKey()); - assertEquals( - objectValue, event.defaultValue(), "a native map default passes through unchanged"); - } - - @Test - void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { - final Map inner = new LinkedHashMap<>(); - inner.put("enabled", new Value(true)); - final Value structureDefault = new Value(new ImmutableStructure(inner)); - - new SpanEnrichmentHook() - .finallyAfter( - ctx("struct-flag", "user-1"), - details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), - Collections.emptyMap()); - - assertEquals(1, captured.size()); - final Object value = captured.get(0).defaultValue(); - // Crucially a native Map, NOT an OpenFeature Value — the seam carries only JDK types. - final Map asMap = assertInstanceOf(Map.class, value); - assertEquals(Boolean.TRUE, asMap.get("enabled")); - } - - // ---- unwrapDefaultValue (the Value -> native conversion) ---- - - @Test - void unwrapConvertsValueScalarsToNative() { - assertEquals("hello", SpanEnrichmentHook.unwrapDefaultValue(new Value("hello"))); - assertEquals(Boolean.TRUE, SpanEnrichmentHook.unwrapDefaultValue(new Value(true))); - assertEquals(7, SpanEnrichmentHook.unwrapDefaultValue(new Value(7))); - assertNull(SpanEnrichmentHook.unwrapDefaultValue(new Value())); - } - - @Test - void unwrapConvertsValueListToNativeList() { - final Value listDefault = - new Value(Arrays.asList(new Value("a"), new Value(2), new Value(true))); - final Object out = SpanEnrichmentHook.unwrapDefaultValue(listDefault); - assertEquals(Arrays.asList("a", 2, Boolean.TRUE), out); - } - - @Test - void unwrapPassesNativeValuesThrough() { - final Map native0 = Collections.singletonMap("a", "b"); - assertEquals(native0, SpanEnrichmentHook.unwrapDefaultValue(native0)); - assertEquals("plain", SpanEnrichmentHook.unwrapDefaultValue("plain")); - assertNull(SpanEnrichmentHook.unwrapDefaultValue(null)); - } - - // ---- error isolation ---- - - @Test - void nullDetailsDispatchesNothing() { - new SpanEnrichmentHook().finallyAfter(null, null, null); - assertTrue(captured.isEmpty()); - } - - // ---- Provider gating ---- - - @Test - void gateOffConstructsNoHook() { - final Provider provider = new Provider(new Provider.Options(), null, Boolean.FALSE); - assertNull(provider.spanEnrichmentHook(), "gate off => no span-enrichment hook"); - for (final Hook hook : provider.getProviderHooks()) { - assertFalse( - hook instanceof SpanEnrichmentHook, "gate off => SpanEnrichmentHook never registered"); - } - } - - @Test - void gateOnConstructsHookAndRegistersItInProviderHooks() { - final Provider provider = new Provider(new Provider.Options(), null, Boolean.TRUE); - assertTrue(provider.spanEnrichmentHook() != null, "gate on => hook constructed"); - boolean registered = false; - for (final Hook hook : provider.getProviderHooks()) { - if (hook instanceof SpanEnrichmentHook) { - registered = true; - } - } - assertTrue(registered, "gate on => SpanEnrichmentHook registered in getProviderHooks"); - } - - @Test - void getProviderHooksReturnsSameInstanceEachCall() { - final Provider gateOff = new Provider(new Provider.Options(), null, Boolean.FALSE); - assertTrue( - gateOff.getProviderHooks() == gateOff.getProviderHooks(), - "gate off => getProviderHooks allocates nothing (same instance)"); - - final Provider gateOn = new Provider(new Provider.Options(), null, Boolean.TRUE); - assertTrue( - gateOn.getProviderHooks() == gateOn.getProviderHooks(), - "gate on => getProviderHooks allocates nothing (same instance)"); - } + private final List captured = new ArrayList<>(); + private final FeatureFlaggingGateway.SpanEnrichmentListener listener = captured::add; + + @BeforeEach + void register() { + FeatureFlaggingGateway.addSpanEnrichmentListener(listener); + } + + @AfterEach + void deregister() { + FeatureFlaggingGateway.removeSpanEnrichmentListener(listener); + } + + // ---- helpers ---- + + private static FlagEvaluationDetails details( + final String flagKey, final String variant, final Object value, final ImmutableMetadata metadata) { + return FlagEvaluationDetails.builder() + .flagKey(flagKey) + .variant(variant) + .value(value) + .flagMetadata(metadata) + .build(); + } + + private static ImmutableMetadata metadata(final Integer serialId, final boolean doLog) { + final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + if (serialId != null) { + builder.addInteger(SpanEnrichmentHook.METADATA_SERIAL_ID, serialId); + } + builder.addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, doLog); + return builder.build(); + } + + private static HookContext ctx(final String flagKey, final String targetingKey) { + return HookContext.from( + flagKey, + FlagValueType.STRING, + null, + null, + targetingKey == null ? new ImmutableContext() : new ImmutableContext(targetingKey), + "default"); + } + + // ---- serial-id branch ---- + + @Test + void serialIdWithDoLogDispatchesSerialAndSubject() { + new SpanEnrichmentHook() + .finallyAfter( + ctx("flag", "user-1"), details("flag", "on", "v", metadata(42, true)), Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(42, event.serialId()); + assertTrue(event.doLog()); + assertEquals("user-1", event.targetingKey()); + } + + @Test + void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { + new SpanEnrichmentHook() + .finallyAfter( + ctx("flag", "user-1"), details("flag", "on", "v", metadata(7, false)), Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(7, event.serialId()); + assertFalse(event.doLog(), "doLog=false must be carried through so the write side skips subject"); + } + + @Test + void wrongTypedSerialIdDispatchesNothing() { + // Defensive: a non-integer value under the serial-id key (wrong type) is ignored, not crashed. + final ImmutableMetadata bad = ImmutableMetadata.builder() + .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") + .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) + .build(); + new SpanEnrichmentHook() + .finallyAfter(ctx("flag", "user-1"), details("flag", "on", "v", bad), Collections.emptyMap()); + assertTrue(captured.isEmpty(), "a wrong-typed serial id must never break eval or dispatch"); + } + + // ---- runtime-default branch (missing variant) ---- + + @Test + void missingVariantDispatchesRuntimeDefaultWithNativeMap() { + final Map objectValue = Collections.singletonMap("k", "val"); + new SpanEnrichmentHook() + .finallyAfter( + ctx("obj-flag", "user-1"), + details( + "obj-flag", + null, + objectValue, + ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertFalse(event.hasSerialId()); + assertEquals("obj-flag", event.flagKey()); + assertEquals(objectValue, event.defaultValue(), "a native map default passes through unchanged"); + } + + @Test + void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { + final Map inner = new LinkedHashMap<>(); + inner.put("enabled", new Value(true)); + final Value structureDefault = new Value(new ImmutableStructure(inner)); + + new SpanEnrichmentHook() + .finallyAfter( + ctx("struct-flag", "user-1"), + details( + "struct-flag", + null, + structureDefault, + ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final Object value = captured.get(0).defaultValue(); + // Crucially a native Map, NOT an OpenFeature Value — the seam carries only JDK types. + final Map asMap = assertInstanceOf(Map.class, value); + assertEquals(Boolean.TRUE, asMap.get("enabled")); + } + + // ---- unwrapDefaultValue (the Value -> native conversion) ---- + + @Test + void unwrapConvertsValueScalarsToNative() { + assertEquals("hello", SpanEnrichmentHook.unwrapDefaultValue(new Value("hello"))); + assertEquals(Boolean.TRUE, SpanEnrichmentHook.unwrapDefaultValue(new Value(true))); + assertEquals(7, SpanEnrichmentHook.unwrapDefaultValue(new Value(7))); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(new Value())); + } + + @Test + void unwrapConvertsValueListToNativeList() { + final Value listDefault = new Value(Arrays.asList(new Value("a"), new Value(2), new Value(true))); + final Object out = SpanEnrichmentHook.unwrapDefaultValue(listDefault); + assertEquals(Arrays.asList("a", 2, Boolean.TRUE), out); + } + + @Test + void unwrapPassesNativeValuesThrough() { + final Map native0 = Collections.singletonMap("a", "b"); + assertEquals(native0, SpanEnrichmentHook.unwrapDefaultValue(native0)); + assertEquals("plain", SpanEnrichmentHook.unwrapDefaultValue("plain")); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(null)); + } + + // ---- error isolation ---- + + @Test + void nullDetailsDispatchesNothing() { + new SpanEnrichmentHook().finallyAfter(null, null, null); + assertTrue(captured.isEmpty()); + } + + // ---- Provider gating ---- + + @Test + void gateOffConstructsNoHook() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertNull(provider.spanEnrichmentHook(), "gate off => no span-enrichment hook"); + for (final Hook hook : provider.getProviderHooks()) { + assertFalse(hook instanceof SpanEnrichmentHook, "gate off => SpanEnrichmentHook never registered"); + } + } + + @Test + void gateOnConstructsHookAndRegistersItInProviderHooks() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue(provider.spanEnrichmentHook() != null, "gate on => hook constructed"); + boolean registered = false; + for (final Hook hook : provider.getProviderHooks()) { + if (hook instanceof SpanEnrichmentHook) { + registered = true; + } + } + assertTrue(registered, "gate on => SpanEnrichmentHook registered in getProviderHooks"); + } + + @Test + void getProviderHooksReturnsSameInstanceEachCall() { + final Provider gateOff = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertTrue( + gateOff.getProviderHooks() == gateOff.getProviderHooks(), + "gate off => getProviderHooks allocates nothing (same instance)"); + + final Provider gateOn = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue( + gateOn.getProviderHooks() == gateOn.getProviderHooks(), + "gate on => getProviderHooks allocates nothing (same instance)"); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java index 2a823bd32ef..9c34979af54 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java @@ -10,122 +10,119 @@ public abstract class FeatureFlaggingGateway { - public interface ConfigListener extends Consumer {} - - public interface ActivationListener { - void activate(); - } - - public interface ExposureListener extends Consumer {} - - public interface SpanEnrichmentListener extends Consumer {} - - private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); - private static final List ACTIVATION_LISTENERS = new CopyOnWriteArrayList<>(); - private static final List EXPOSURE_LISTENERS = new CopyOnWriteArrayList<>(); - private static final List SPAN_ENRICHMENT_LISTENERS = - new CopyOnWriteArrayList<>(); - - private static final AtomicReference CURRENT_CONFIG = - new AtomicReference<>(); - - /** - * The active EVP flagevaluation writer. Registered by {@code FlagEvaluationWriterImpl.start()} - * when the killswitch {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} is on (default). Read by - * {@code FlagEvalLoggingHook} to route evaluations into the two-tier aggregator. {@code null} - * when the EVP path is disabled. - */ - private static final AtomicReference FLAG_EVAL_WRITER = - new AtomicReference<>(); - - private static volatile boolean flagEvalEnqueueEnabled = true; - - private FeatureFlaggingGateway() {} - - public static void addConfigListener(final ConfigListener listener) { - CONFIG_LISTENERS.add(listener); - final ServerConfiguration current = CURRENT_CONFIG.get(); - if (current != null) { - listener.accept(current); - } - } - - public static void removeConfigListener(final ConfigListener listener) { - CONFIG_LISTENERS.remove(listener); - } - - public static void dispatch(final ServerConfiguration config) { - CURRENT_CONFIG.set(config); - CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); - } - - public static void addActivationListener(final ActivationListener listener) { - ACTIVATION_LISTENERS.add(listener); - } - - public static void removeActivationListener(final ActivationListener listener) { - ACTIVATION_LISTENERS.remove(listener); - } - - /** Signals that application code initialized the Datadog OpenFeature provider. */ - public static void activate() { - ACTIVATION_LISTENERS.forEach(ActivationListener::activate); - } - - public static void addExposureListener(final ExposureListener listener) { - EXPOSURE_LISTENERS.add(listener); - } - - public static void removeExposureListener(final ExposureListener listener) { - EXPOSURE_LISTENERS.remove(listener); - } - - public static void dispatch(final ExposureEvent event) { - EXPOSURE_LISTENERS.forEach(listener -> listener.accept(event)); - } - - /** - * Registers the active EVP flagevaluation writer. Called by {@code - * FlagEvaluationWriterImpl.start()} when the feature is enabled. Replaces any previously - * registered writer. - * - * @param writer the writer to register, or {@code null} to deregister - */ - public static void setFlagEvalWriter(final FlagEvaluationWriter writer) { - FLAG_EVAL_WRITER.set(writer); - } - - /** - * Enables or disables enqueueing EVP flagevaluation events on the OpenFeature hook path. This is - * populated from {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} at feature-flagging startup and - * cleared during shutdown before the writer drains. - */ - public static void setFlagEvaluationEnqueueEnabled(final boolean enabled) { - flagEvalEnqueueEnabled = enabled; - } - - /** - * Returns the active EVP flagevaluation writer, or {@code null} when disabled (killswitch off or - * not yet started). - */ - public static FlagEvaluationWriter getFlagEvalWriter() { - return FLAG_EVAL_WRITER.get(); - } - - /** Returns whether EVP flagevaluation hook events may be enqueued. */ - public static boolean isFlagEvaluationEnqueueEnabled() { - return flagEvalEnqueueEnabled; - } - - public static void addSpanEnrichmentListener(final SpanEnrichmentListener listener) { - SPAN_ENRICHMENT_LISTENERS.add(listener); - } - - public static void removeSpanEnrichmentListener(final SpanEnrichmentListener listener) { - SPAN_ENRICHMENT_LISTENERS.remove(listener); - } - - public static void dispatch(final SpanEnrichmentEvent event) { - SPAN_ENRICHMENT_LISTENERS.forEach(listener -> listener.accept(event)); - } + public interface ConfigListener extends Consumer {} + + public interface ActivationListener { + void activate(); + } + + public interface ExposureListener extends Consumer {} + + public interface SpanEnrichmentListener extends Consumer {} + + private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); + private static final List ACTIVATION_LISTENERS = new CopyOnWriteArrayList<>(); + private static final List EXPOSURE_LISTENERS = new CopyOnWriteArrayList<>(); + private static final List SPAN_ENRICHMENT_LISTENERS = new CopyOnWriteArrayList<>(); + + private static final AtomicReference CURRENT_CONFIG = new AtomicReference<>(); + + /** + * The active EVP flagevaluation writer. Registered by {@code FlagEvaluationWriterImpl.start()} + * when the killswitch {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} is on (default). Read by + * {@code FlagEvalLoggingHook} to route evaluations into the two-tier aggregator. {@code null} + * when the EVP path is disabled. + */ + private static final AtomicReference FLAG_EVAL_WRITER = new AtomicReference<>(); + + private static volatile boolean flagEvalEnqueueEnabled = true; + + private FeatureFlaggingGateway() {} + + public static void addConfigListener(final ConfigListener listener) { + CONFIG_LISTENERS.add(listener); + final ServerConfiguration current = CURRENT_CONFIG.get(); + if (current != null) { + listener.accept(current); + } + } + + public static void removeConfigListener(final ConfigListener listener) { + CONFIG_LISTENERS.remove(listener); + } + + public static void dispatch(final ServerConfiguration config) { + CURRENT_CONFIG.set(config); + CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); + } + + public static void addActivationListener(final ActivationListener listener) { + ACTIVATION_LISTENERS.add(listener); + } + + public static void removeActivationListener(final ActivationListener listener) { + ACTIVATION_LISTENERS.remove(listener); + } + + /** Signals that application code initialized the Datadog OpenFeature provider. */ + public static void activate() { + ACTIVATION_LISTENERS.forEach(ActivationListener::activate); + } + + public static void addExposureListener(final ExposureListener listener) { + EXPOSURE_LISTENERS.add(listener); + } + + public static void removeExposureListener(final ExposureListener listener) { + EXPOSURE_LISTENERS.remove(listener); + } + + public static void dispatch(final ExposureEvent event) { + EXPOSURE_LISTENERS.forEach(listener -> listener.accept(event)); + } + + /** + * Registers the active EVP flagevaluation writer. Called by {@code + * FlagEvaluationWriterImpl.start()} when the feature is enabled. Replaces any previously + * registered writer. + * + * @param writer the writer to register, or {@code null} to deregister + */ + public static void setFlagEvalWriter(final FlagEvaluationWriter writer) { + FLAG_EVAL_WRITER.set(writer); + } + + /** + * Enables or disables enqueueing EVP flagevaluation events on the OpenFeature hook path. This is + * populated from {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} at feature-flagging startup and + * cleared during shutdown before the writer drains. + */ + public static void setFlagEvaluationEnqueueEnabled(final boolean enabled) { + flagEvalEnqueueEnabled = enabled; + } + + /** + * Returns the active EVP flagevaluation writer, or {@code null} when disabled (killswitch off or + * not yet started). + */ + public static FlagEvaluationWriter getFlagEvalWriter() { + return FLAG_EVAL_WRITER.get(); + } + + /** Returns whether EVP flagevaluation hook events may be enqueued. */ + public static boolean isFlagEvaluationEnqueueEnabled() { + return flagEvalEnqueueEnabled; + } + + public static void addSpanEnrichmentListener(final SpanEnrichmentListener listener) { + SPAN_ENRICHMENT_LISTENERS.add(listener); + } + + public static void removeSpanEnrichmentListener(final SpanEnrichmentListener listener) { + SPAN_ENRICHMENT_LISTENERS.remove(listener); + } + + public static void dispatch(final SpanEnrichmentEvent event) { + SPAN_ENRICHMENT_LISTENERS.forEach(listener -> listener.accept(event)); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java index a7518c71743..325744cb2fb 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java @@ -19,64 +19,63 @@ */ public final class SpanEnrichmentEvent { - private final boolean serialIdPresent; - private final int serialId; - private final boolean doLog; - private final String targetingKey; - private final String flagKey; - private final Object defaultValue; + private final boolean serialIdPresent; + private final int serialId; + private final boolean doLog; + private final String targetingKey; + private final String flagKey; + private final Object defaultValue; - private SpanEnrichmentEvent( - final boolean serialIdPresent, - final int serialId, - final boolean doLog, - final String targetingKey, - final String flagKey, - final Object defaultValue) { - this.serialIdPresent = serialIdPresent; - this.serialId = serialId; - this.doLog = doLog; - this.targetingKey = targetingKey; - this.flagKey = flagKey; - this.defaultValue = defaultValue; - } + private SpanEnrichmentEvent( + final boolean serialIdPresent, + final int serialId, + final boolean doLog, + final String targetingKey, + final String flagKey, + final Object defaultValue) { + this.serialIdPresent = serialIdPresent; + this.serialId = serialId; + this.doLog = doLog; + this.targetingKey = targetingKey; + this.flagKey = flagKey; + this.defaultValue = defaultValue; + } - /** A UFC split evaluation carrying a serial id (and, when {@code doLog}, a subject). */ - public static SpanEnrichmentEvent serialId( - final int serialId, final boolean doLog, final String targetingKey) { - return new SpanEnrichmentEvent(true, serialId, doLog, targetingKey, null, null); - } + /** A UFC split evaluation carrying a serial id (and, when {@code doLog}, a subject). */ + public static SpanEnrichmentEvent serialId(final int serialId, final boolean doLog, final String targetingKey) { + return new SpanEnrichmentEvent(true, serialId, doLog, targetingKey, null, null); + } - /** - * A runtime-default evaluation (missing variant). {@code value} must already be unwrapped to a - * native Java type (Map/List/scalar/null) by the caller. - */ - public static SpanEnrichmentEvent runtimeDefault(final String flagKey, final Object value) { - return new SpanEnrichmentEvent(false, 0, false, null, flagKey, value); - } + /** + * A runtime-default evaluation (missing variant). {@code value} must already be unwrapped to a + * native Java type (Map/List/scalar/null) by the caller. + */ + public static SpanEnrichmentEvent runtimeDefault(final String flagKey, final Object value) { + return new SpanEnrichmentEvent(false, 0, false, null, flagKey, value); + } - /** True for the serial-id shape; false for the runtime-default shape. */ - public boolean hasSerialId() { - return serialIdPresent; - } + /** True for the serial-id shape; false for the runtime-default shape. */ + public boolean hasSerialId() { + return serialIdPresent; + } - public int serialId() { - return serialId; - } + public int serialId() { + return serialId; + } - public boolean doLog() { - return doLog; - } + public boolean doLog() { + return doLog; + } - public String targetingKey() { - return targetingKey; - } + public String targetingKey() { + return targetingKey; + } - public String flagKey() { - return flagKey; - } + public String flagKey() { + return flagKey; + } - public Object defaultValue() { - return defaultValue; - } + public Object defaultValue() { + return defaultValue; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java index 53df4b4b512..e3504f44117 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java @@ -1,9 +1,9 @@ package datadog.trace.api.featureflag.exposure; public class Allocation { - public final String key; + public final String key; - public Allocation(final String key) { - this.key = key; - } + public Allocation(final String key) { + this.key = key; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java index 4101b767760..f4b56561a2e 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java @@ -1,36 +1,36 @@ package datadog.trace.api.featureflag.exposure; public class ExposureEvent { - // milliseconds since epoch as given by System.currentTimeMillis() - public final long timestamp; - public final Allocation allocation; - public final Flag flag; - public final Variant variant; - public final Subject subject; + // milliseconds since epoch as given by System.currentTimeMillis() + public final long timestamp; + public final Allocation allocation; + public final Flag flag; + public final Variant variant; + public final Subject subject; - public final Integer serial_id; + public final Integer serial_id; - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject) { - this(timestamp, allocation, flag, variant, subject, null); - } + public ExposureEvent( + final long timestamp, + final Allocation allocation, + final Flag flag, + final Variant variant, + final Subject subject) { + this(timestamp, allocation, flag, variant, subject, null); + } - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject, - final Integer serialId) { - this.timestamp = timestamp; - this.allocation = allocation; - this.flag = flag; - this.variant = variant; - this.subject = subject; - this.serial_id = serialId; - } + public ExposureEvent( + final long timestamp, + final Allocation allocation, + final Flag flag, + final Variant variant, + final Subject subject, + final Integer serialId) { + this.timestamp = timestamp; + this.allocation = allocation; + this.flag = flag; + this.variant = variant; + this.subject = subject; + this.serial_id = serialId; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java index 3b25028c245..9a84dd074f0 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java @@ -5,11 +5,11 @@ public class ExposuresRequest { - public final Map context; - public final List exposures; + public final Map context; + public final List exposures; - public ExposuresRequest(final Map context, final List exposures) { - this.context = context; - this.exposures = exposures; - } + public ExposuresRequest(final Map context, final List exposures) { + this.context = context; + this.exposures = exposures; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java index c13b123ad6c..dda0b36470e 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java @@ -1,9 +1,9 @@ package datadog.trace.api.featureflag.exposure; public class Flag { - public final String key; + public final String key; - public Flag(final String key) { - this.key = key; - } + public Flag(final String key) { + this.key = key; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java index ebd381e7069..5f48b0d6c58 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java @@ -3,11 +3,11 @@ import java.util.Map; public class Subject { - public final String id; - public final Map attributes; + public final String id; + public final Map attributes; - public Subject(final String id, final Map attributes) { - this.id = id; - this.attributes = attributes; - } + public Subject(final String id, final Map attributes) { + this.id = id; + this.attributes = attributes; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java index dfc4cbe38c7..474b471f889 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java @@ -1,9 +1,9 @@ package datadog.trace.api.featureflag.exposure; public class Variant { - public final String key; + public final String key; - public Variant(final String key) { - this.key = key; - } + public Variant(final String key) { + this.key = key; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java index f5f44b94d7b..871f6aa772c 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java @@ -14,85 +14,85 @@ */ public final class FlagEvalEvent { - /** The feature flag key. Never null. */ - public final String flagKey; + /** The feature flag key. Never null. */ + public final String flagKey; - /** - * The OpenFeature variant key selected for the evaluation. Null means the default value was - * returned (runtime default). - */ - public final String variant; + /** + * The OpenFeature variant key selected for the evaluation. Null means the default value was + * returned (runtime default). + */ + public final String variant; - /** The allocation key from flag metadata ("allocationKey"). May be null. */ - public final String allocationKey; + /** The allocation key from flag metadata ("allocationKey"). May be null. */ + public final String allocationKey; - /** The targeting key from the evaluation context. May be null. */ - public final String targetingKey; + /** The targeting key from the evaluation context. May be null. */ + public final String targetingKey; - /** - * The evaluation error message when the evaluation failed, else null. Sourced from the - * OpenFeature evaluation details (error message, falling back to the error code). - */ - public final String errorMessage; + /** + * The evaluation error message when the evaluation failed, else null. Sourced from the + * OpenFeature evaluation details (error message, falling back to the error code). + */ + public final String errorMessage; - /** - * Evaluation timestamp in milliseconds since epoch. Stamped at eval-entry time from flag metadata - * key __dd_eval_timestamp_ms, or falls back to hook-fire time when absent. This ensures - * first/last_evaluation reflect evaluation time, not hook-fire time. - */ - public final long evalTimeMs; + /** + * Evaluation timestamp in milliseconds since epoch. Stamped at eval-entry time from flag metadata + * key __dd_eval_timestamp_ms, or falls back to hook-fire time when absent. This ensures + * first/last_evaluation reflect evaluation time, not hook-fire time. + */ + public final long evalTimeMs; - /** - * Flattened evaluation context attributes. Used for the full-tier canonical context key. May be - * empty but never null. - */ - public final Map attrs; + /** + * Flattened evaluation context attributes. Used for the full-tier canonical context key. May be + * empty but never null. + */ + public final Map attrs; - /** - * PII consent from the ServerConfiguration used by the evaluation. When false (privacy-preserving - * default), the targeting key is hashed and the per-evaluation context is omitted on emission. - */ - public final boolean observeFullEvaluationData; + /** + * PII consent from the ServerConfiguration used by the evaluation. When false (privacy-preserving + * default), the targeting key is hashed and the per-evaluation context is omitted on emission. + */ + public final boolean observeFullEvaluationData; - /** Convenience constructor; consent defaults to the privacy-preserving false. */ - public FlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - this(flagKey, variant, allocationKey, targetingKey, null, evalTimeMs, false, attrs); - } + /** Convenience constructor; consent defaults to the privacy-preserving false. */ + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + this(flagKey, variant, allocationKey, targetingKey, null, evalTimeMs, false, attrs); + } - /** Convenience constructor; consent defaults to the privacy-preserving false. */ - public FlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final Map attrs) { - this(flagKey, variant, allocationKey, targetingKey, errorMessage, evalTimeMs, false, attrs); - } + /** Convenience constructor; consent defaults to the privacy-preserving false. */ + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final Map attrs) { + this(flagKey, variant, allocationKey, targetingKey, errorMessage, evalTimeMs, false, attrs); + } - public FlagEvalEvent( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.targetingKey = targetingKey; - this.errorMessage = errorMessage; - this.evalTimeMs = evalTimeMs; - this.observeFullEvaluationData = observeFullEvaluationData; - this.attrs = attrs != null ? attrs : Collections.emptyMap(); - } + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.targetingKey = targetingKey; + this.errorMessage = errorMessage; + this.evalTimeMs = evalTimeMs; + this.observeFullEvaluationData = observeFullEvaluationData; + this.attrs = attrs != null ? attrs : Collections.emptyMap(); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java index 65ffaf80591..122d7643b2b 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java @@ -10,37 +10,37 @@ */ public interface FlagEvaluationWriter extends AutoCloseable { - /** - * Non-blocking enqueue of a flag evaluation event. May silently drop the event if the internal - * bounded queue is full (best-effort, observable via drop counter). - */ - void enqueue(FlagEvalEvent event); + /** + * Non-blocking enqueue of a flag evaluation event. May silently drop the event if the internal + * bounded queue is full (best-effort, observable via drop counter). + */ + void enqueue(FlagEvalEvent event); - /** - * Reports whether the internal queue currently has room for another event. Producers should - * consult this before performing expensive context-copy work so a saturated queue is observed as - * an O(1) read rather than a full snapshot followed by a discarded offer. - * - *

Best-effort: the worker can drain (or a peer producer can fill) between this check and the - * next enqueue, so callers must still tolerate offer failure. - */ - boolean hasCapacityForEnqueue(); + /** + * Reports whether the internal queue currently has room for another event. Producers should + * consult this before performing expensive context-copy work so a saturated queue is observed as + * an O(1) read rather than a full snapshot followed by a discarded offer. + * + *

Best-effort: the worker can drain (or a peer producer can fill) between this check and the + * next enqueue, so callers must still tolerate offer failure. + */ + boolean hasCapacityForEnqueue(); - /** Counts one queue-overflow drop without offering an event. */ - void countPreQueueOverflow(); + /** Counts one queue-overflow drop without offering an event. */ + void countPreQueueOverflow(); - /** - * Counts one evaluation whose context was truncated by copyPrunedContext. The reason string is - * the sorted, comma-separated set of cap names that fired (e.g. - * "max_key_length,max_value_length"). Each unique reason string is counted separately so - * telemetry can distinguish which caps are hot. - */ - void countContextTruncated(String reason); + /** + * Counts one evaluation whose context was truncated by copyPrunedContext. The reason string is + * the sorted, comma-separated set of cap names that fired (e.g. + * "max_key_length,max_value_length"). Each unique reason string is counted separately so + * telemetry can distinguish which caps are hot. + */ + void countContextTruncated(String reason); - /** Starts the background serializing thread. Must be called once after construction. */ - void start(); + /** Starts the background serializing thread. Must be called once after construction. */ + void start(); - /** Stops the background thread and releases resources. */ - @Override - void close(); + /** Stops the background thread and releases resources. */ + @Override + void close(); } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java index b572e7e5aff..88a331e85d6 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java @@ -5,76 +5,76 @@ import java.util.List; public class Allocation { - public final String key; - public final List rules; - public final Date startAt; - public final Date endAt; - public final List splits; - public final Boolean doLog; + public final String key; + public final List rules; + public final Date startAt; + public final Date endAt; + public final List splits; + public final Boolean doLog; - private final transient Instant preciseStartAt; - private final transient Instant preciseEndAt; + private final transient Instant preciseStartAt; + private final transient Instant preciseEndAt; - public Allocation( - final String key, - final List rules, - final Date startAt, - final Date endAt, - final List splits, - final Boolean doLog) { - this( - key, - rules, - startAt, - endAt, - splits, - doLog, - startAt == null ? null : startAt.toInstant(), - endAt == null ? null : endAt.toInstant()); - } + public Allocation( + final String key, + final List rules, + final Date startAt, + final Date endAt, + final List splits, + final Boolean doLog) { + this( + key, + rules, + startAt, + endAt, + splits, + doLog, + startAt == null ? null : startAt.toInstant(), + endAt == null ? null : endAt.toInstant()); + } - private Allocation( - final String key, - final List rules, - final Date startAt, - final Date endAt, - final List splits, - final Boolean doLog, - final Instant preciseStartAt, - final Instant preciseEndAt) { - this.key = key; - this.rules = rules; - this.startAt = startAt; - this.endAt = endAt; - this.splits = splits; - this.doLog = doLog; - this.preciseStartAt = preciseStartAt; - this.preciseEndAt = preciseEndAt; - } + private Allocation( + final String key, + final List rules, + final Date startAt, + final Date endAt, + final List splits, + final Boolean doLog, + final Instant preciseStartAt, + final Instant preciseEndAt) { + this.key = key; + this.rules = rules; + this.startAt = startAt; + this.endAt = endAt; + this.splits = splits; + this.doLog = doLog; + this.preciseStartAt = preciseStartAt; + this.preciseEndAt = preciseEndAt; + } - public static Allocation fromInstants( - final String key, - final List rules, - final Instant startAt, - final Instant endAt, - final List splits, - final Boolean doLog) { - return new Allocation( - key, - rules, - startAt == null ? null : Date.from(startAt), - endAt == null ? null : Date.from(endAt), - splits, - doLog, - startAt, - endAt); - } + public static Allocation fromInstants( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog) { + return new Allocation( + key, + rules, + startAt == null ? null : Date.from(startAt), + endAt == null ? null : Date.from(endAt), + splits, + doLog, + startAt, + endAt); + } - public Instant startAtInstant() { - return preciseStartAt != null ? preciseStartAt : startAt == null ? null : startAt.toInstant(); - } + public Instant startAtInstant() { + return preciseStartAt != null ? preciseStartAt : startAt == null ? null : startAt.toInstant(); + } - public Instant endAtInstant() { - return preciseEndAt != null ? preciseEndAt : endAt == null ? null : endAt.toInstant(); - } + public Instant endAtInstant() { + return preciseEndAt != null ? preciseEndAt : endAt == null ? null : endAt.toInstant(); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java index ae693674acc..4f89c82e647 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java @@ -1,18 +1,17 @@ package datadog.trace.api.featureflag.ufc.v1; public class ConditionConfiguration { - public final ConditionOperator operator; - public final String attribute; - public final Object value; + public final ConditionOperator operator; + public final String attribute; + public final Object value; - // The validated, parsed SemVer condition value. Set during configuration preprocessing - // (not from JSON) when the operator is a SEMVER_* operator. - public transient ParsedSemver semverComparand; + // The validated, parsed SemVer condition value. Set during configuration preprocessing + // (not from JSON) when the operator is a SEMVER_* operator. + public transient ParsedSemver semverComparand; - public ConditionConfiguration( - final ConditionOperator operator, final String attribute, final Object value) { - this.operator = operator; - this.attribute = attribute; - this.value = value; - } + public ConditionConfiguration(final ConditionOperator operator, final String attribute, final Object value) { + this.operator = operator; + this.attribute = attribute; + this.value = value; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java index 1e864da2b6a..c90459a5322 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java @@ -1,19 +1,19 @@ package datadog.trace.api.featureflag.ufc.v1; public enum ConditionOperator { - LT, - LTE, - GT, - GTE, - MATCHES, - NOT_MATCHES, - ONE_OF, - NOT_ONE_OF, - IS_NULL, - SEMVER_EQ, - SEMVER_NEQ, - SEMVER_LT, - SEMVER_LTE, - SEMVER_GT, - SEMVER_GTE + LT, + LTE, + GT, + GTE, + MATCHES, + NOT_MATCHES, + ONE_OF, + NOT_ONE_OF, + IS_NULL, + SEMVER_EQ, + SEMVER_NEQ, + SEMVER_LT, + SEMVER_LTE, + SEMVER_GT, + SEMVER_GTE } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java index 86d04c7d8ef..59116b3f43a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java @@ -1,9 +1,9 @@ package datadog.trace.api.featureflag.ufc.v1; public class Environment { - public final String name; + public final String name; - public Environment(String name) { - this.name = name; - } + public Environment(String name) { + this.name = name; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java index 529e3145344..6470f2073f5 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java @@ -4,22 +4,22 @@ import java.util.Map; public class Flag { - public final String key; - public final boolean enabled; - public final ValueType variationType; - public final Map variations; - public final List allocations; + public final String key; + public final boolean enabled; + public final ValueType variationType; + public final Map variations; + public final List allocations; - public Flag( - final String key, - final boolean enabled, - final ValueType variationType, - final Map variations, - final List allocations) { - this.key = key; - this.enabled = enabled; - this.variationType = variationType; - this.variations = variations; - this.allocations = allocations; - } + public Flag( + final String key, + final boolean enabled, + final ValueType variationType, + final Map variations, + final List allocations) { + this.key = key; + this.enabled = enabled; + this.variationType = variationType; + this.variations = variations; + this.allocations = allocations; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java index 03ef7590fa1..05fbc73ca99 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java @@ -14,305 +14,303 @@ */ public final class ParsedSemver { - /** Sentinel returned by {@link #parse(String)} when the input is not a valid semantic version. */ - public static final ParsedSemver INVALID = null; + /** Sentinel returned by {@link #parse(String)} when the input is not a valid semantic version. */ + public static final ParsedSemver INVALID = null; - private final long[] core; // unsigned, with omitted minor and patch normalized to zero - private final int coreLength; - private final String prerelease; + private final long[] core; // unsigned, with omitted minor and patch normalized to zero + private final int coreLength; + private final String prerelease; - ParsedSemver(final long[] core, final int coreLength, final String prerelease) { - this.core = core; - this.coreLength = coreLength; - this.prerelease = prerelease; - } - - /** - * Parses a semantic version string using the syntax supported by FFE. - * - * @param version the version string to parse - * @return a {@link ParsedSemver} instance, or {@code null} if the input is not a valid semantic - * version - */ - public static ParsedSemver parse(final String version) { - long[] core = new long[3]; - int coreSize = 0; - int next = 0; - while (true) { - final int end = coreIdentifierEnd(version, next); - if (end == -1) { - return INVALID; - } - final long component; - try { - component = Long.parseUnsignedLong(version.substring(next, end)); - } catch (final NumberFormatException e) { - return INVALID; // overflow - } - if (coreSize == core.length) { - core = Arrays.copyOf(core, core.length * 2); - } - core[coreSize++] = component; - next = end; - if (next == version.length() || version.charAt(next) != '.') { - break; - } - next++; - } - final int coreLength = Math.max(3, coreSize); - if (next == version.length()) { - return new ParsedSemver(core, coreLength, ""); + ParsedSemver(final long[] core, final int coreLength, final String prerelease) { + this.core = core; + this.coreLength = coreLength; + this.prerelease = prerelease; } - // Parse prerelease and/or build metadata. - String remainder = version.substring(next); - String prerelease = ""; - if (remainder.charAt(0) == '-') { - remainder = remainder.substring(1); - final int buildStart = remainder.indexOf('+'); - if (buildStart == -1) { - if (!validSemverIdentifiers(remainder, false)) { - return INVALID; + /** + * Parses a semantic version string using the syntax supported by FFE. + * + * @param version the version string to parse + * @return a {@link ParsedSemver} instance, or {@code null} if the input is not a valid semantic + * version + */ + public static ParsedSemver parse(final String version) { + long[] core = new long[3]; + int coreSize = 0; + int next = 0; + while (true) { + final int end = coreIdentifierEnd(version, next); + if (end == -1) { + return INVALID; + } + final long component; + try { + component = Long.parseUnsignedLong(version.substring(next, end)); + } catch (final NumberFormatException e) { + return INVALID; // overflow + } + if (coreSize == core.length) { + core = Arrays.copyOf(core, core.length * 2); + } + core[coreSize++] = component; + next = end; + if (next == version.length() || version.charAt(next) != '.') { + break; + } + next++; } - return new ParsedSemver(core, coreLength, remainder); - } - prerelease = remainder.substring(0, buildStart); - if (!validSemverIdentifiers(prerelease, false)) { - return INVALID; - } - remainder = remainder.substring(buildStart + 1); - } else if (remainder.charAt(0) == '+') { - remainder = remainder.substring(1); - } else { - return INVALID; - } - if (!validSemverIdentifiers(remainder, true)) { - return INVALID; + final int coreLength = Math.max(3, coreSize); + if (next == version.length()) { + return new ParsedSemver(core, coreLength, ""); + } + + // Parse prerelease and/or build metadata. + String remainder = version.substring(next); + String prerelease = ""; + if (remainder.charAt(0) == '-') { + remainder = remainder.substring(1); + final int buildStart = remainder.indexOf('+'); + if (buildStart == -1) { + if (!validSemverIdentifiers(remainder, false)) { + return INVALID; + } + return new ParsedSemver(core, coreLength, remainder); + } + prerelease = remainder.substring(0, buildStart); + if (!validSemverIdentifiers(prerelease, false)) { + return INVALID; + } + remainder = remainder.substring(buildStart + 1); + } else if (remainder.charAt(0) == '+') { + remainder = remainder.substring(1); + } else { + return INVALID; + } + if (!validSemverIdentifiers(remainder, true)) { + return INVALID; + } + return new ParsedSemver(core, coreLength, prerelease); } - return new ParsedSemver(core, coreLength, prerelease); - } - /** - * Compares two parsed semantic versions by precedence. Build metadata is intentionally ignored. - * - * @param left the left operand - * @param right the right operand - * @return negative if {@code left} precedes {@code right}, positive if {@code left} follows - * {@code right}, zero if they have equal precedence - */ - public static int compare(final ParsedSemver left, final ParsedSemver right) { - final int coreLength = Math.max(left.coreLength, right.coreLength); - for (int i = 0; i < coreLength; i++) { - final int cmp = Long.compareUnsigned(left.coreComponent(i), right.coreComponent(i)); - if (cmp != 0) { - return cmp; - } + /** + * Compares two parsed semantic versions by precedence. Build metadata is intentionally ignored. + * + * @param left the left operand + * @param right the right operand + * @return negative if {@code left} precedes {@code right}, positive if {@code left} follows + * {@code right}, zero if they have equal precedence + */ + public static int compare(final ParsedSemver left, final ParsedSemver right) { + final int coreLength = Math.max(left.coreLength, right.coreLength); + for (int i = 0; i < coreLength; i++) { + final int cmp = Long.compareUnsigned(left.coreComponent(i), right.coreComponent(i)); + if (cmp != 0) { + return cmp; + } + } + return comparePrerelease(left.prerelease, right.prerelease); } - return comparePrerelease(left.prerelease, right.prerelease); - } - /** - * Finds the end of a core version identifier (major, minor, or patch), rejecting leading zeros - * except for the value zero itself. - */ - private static int coreIdentifierEnd(final String version, final int start) { - if (start >= version.length() || !isASCIIDigit(version.charAt(start))) { - return -1; + /** + * Finds the end of a core version identifier (major, minor, or patch), rejecting leading zeros + * except for the value zero itself. + */ + private static int coreIdentifierEnd(final String version, final int start) { + if (start >= version.length() || !isASCIIDigit(version.charAt(start))) { + return -1; + } + if (version.charAt(start) == '0') { + return start + 1; + } + + int end = start; + while (end < version.length() && isASCIIDigit(version.charAt(end))) { + end++; + } + return end; } - if (version.charAt(start) == '0') { - return start + 1; + + private long coreComponent(final int index) { + return index < coreLength ? core[index] : 0; } - int end = start; - while (end < version.length() && isASCIIDigit(version.charAt(end))) { - end++; + /** + * Returns the number of significant core components, excluding trailing zero components that + * {@link #compare} treats as equal to omitted components. This keeps {@link #equals} and {@link + * #hashCode} consistent with precedence comparison so that versions like {@code 1.2.3} and {@code + * 1.2.3.0} are equal and hash identically. + */ + private int effectiveCoreLength() { + int length = coreLength; + while (length > 0 && core[length - 1] == 0L) { + length--; + } + return length; } - return end; - } - private long coreComponent(final int index) { - return index < coreLength ? core[index] : 0; - } + /** + * Validates dot-separated identifiers. Permits leading zeros for build metadata only; numeric + * prerelease identifiers reject them. + */ + private static boolean validSemverIdentifiers(final String value, final boolean allowLeadingZeros) { + int identifierStart = 0; + boolean identifierNumeric = true; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == '.') { + if (i == identifierStart) { + return false; // empty identifier + } + if (!allowLeadingZeros + && identifierNumeric + && i - identifierStart > 1 + && value.charAt(identifierStart) == '0') { + return false; // leading zero in numeric identifier + } + identifierStart = i + 1; + identifierNumeric = true; + continue; + } - /** - * Returns the number of significant core components, excluding trailing zero components that - * {@link #compare} treats as equal to omitted components. This keeps {@link #equals} and {@link - * #hashCode} consistent with precedence comparison so that versions like {@code 1.2.3} and {@code - * 1.2.3.0} are equal and hash identically. - */ - private int effectiveCoreLength() { - int length = coreLength; - while (length > 0 && core[length - 1] == 0L) { - length--; + final char c = value.charAt(i); + if (!isASCIIAlphanumeric(c) && c != '-') { + return false; + } + if (!isASCIIDigit(c)) { + identifierNumeric = false; + } + } + return true; } - return length; - } - /** - * Validates dot-separated identifiers. Permits leading zeros for build metadata only; numeric - * prerelease identifiers reject them. - */ - private static boolean validSemverIdentifiers( - final String value, final boolean allowLeadingZeros) { - int identifierStart = 0; - boolean identifierNumeric = true; - for (int i = 0; i <= value.length(); i++) { - if (i == value.length() || value.charAt(i) == '.') { - if (i == identifierStart) { - return false; // empty identifier + private static int comparePrerelease(final String left, final String right) { + if (left.equals(right)) { + return 0; } - if (!allowLeadingZeros - && identifierNumeric - && i - identifierStart > 1 - && value.charAt(identifierStart) == '0') { - return false; // leading zero in numeric identifier + if (left.isEmpty()) { + return 1; // release > prerelease + } + if (right.isEmpty()) { + return -1; // prerelease < release } - identifierStart = i + 1; - identifierNumeric = true; - continue; - } - - final char c = value.charAt(i); - if (!isASCIIAlphanumeric(c) && c != '-') { - return false; - } - if (!isASCIIDigit(c)) { - identifierNumeric = false; - } - } - return true; - } - - private static int comparePrerelease(final String left, final String right) { - if (left.equals(right)) { - return 0; - } - if (left.isEmpty()) { - return 1; // release > prerelease - } - if (right.isEmpty()) { - return -1; // prerelease < release - } - int leftPos = 0; - int rightPos = 0; - while (true) { - // Extract next identifier from each side - int leftDot = left.indexOf('.', leftPos); - int rightDot = right.indexOf('.', rightPos); - final String leftIdentifier = - leftDot == -1 ? left.substring(leftPos) : left.substring(leftPos, leftDot); - final String rightIdentifier = - rightDot == -1 ? right.substring(rightPos) : right.substring(rightPos, rightDot); + int leftPos = 0; + int rightPos = 0; + while (true) { + // Extract next identifier from each side + int leftDot = left.indexOf('.', leftPos); + int rightDot = right.indexOf('.', rightPos); + final String leftIdentifier = leftDot == -1 ? left.substring(leftPos) : left.substring(leftPos, leftDot); + final String rightIdentifier = + rightDot == -1 ? right.substring(rightPos) : right.substring(rightPos, rightDot); - final int ordering = compareIdentifier(leftIdentifier, rightIdentifier); - if (ordering != 0) { - return ordering; - } + final int ordering = compareIdentifier(leftIdentifier, rightIdentifier); + if (ordering != 0) { + return ordering; + } - if (leftDot == -1) { - if (rightDot == -1) { - return 0; + if (leftDot == -1) { + if (rightDot == -1) { + return 0; + } + return -1; // left has fewer identifiers + } + if (rightDot == -1) { + return 1; // right has fewer identifiers + } + leftPos = leftDot + 1; + rightPos = rightDot + 1; } - return -1; // left has fewer identifiers - } - if (rightDot == -1) { - return 1; // right has fewer identifiers - } - leftPos = leftDot + 1; - rightPos = rightDot + 1; } - } - private static int compareIdentifier(final String left, final String right) { - final boolean leftNumeric = isNumericIdentifier(left); - final boolean rightNumeric = isNumericIdentifier(right); + private static int compareIdentifier(final String left, final String right) { + final boolean leftNumeric = isNumericIdentifier(left); + final boolean rightNumeric = isNumericIdentifier(right); - if (leftNumeric && rightNumeric) { - // Numeric identifiers: compare by length first (longer = larger), then lexicographically - if (left.length() < right.length()) { - return -1; - } - if (left.length() > right.length()) { - return 1; - } - return left.compareTo(right); - } else if (leftNumeric) { - return -1; // numeric < alphanumeric - } else if (rightNumeric) { - return 1; // alphanumeric > numeric + if (leftNumeric && rightNumeric) { + // Numeric identifiers: compare by length first (longer = larger), then lexicographically + if (left.length() < right.length()) { + return -1; + } + if (left.length() > right.length()) { + return 1; + } + return left.compareTo(right); + } else if (leftNumeric) { + return -1; // numeric < alphanumeric + } else if (rightNumeric) { + return 1; // alphanumeric > numeric + } + return left.compareTo(right); } - return left.compareTo(right); - } - private static boolean isNumericIdentifier(final String value) { - for (int i = 0; i < value.length(); i++) { - if (!isASCIIDigit(value.charAt(i))) { - return false; - } + private static boolean isNumericIdentifier(final String value) { + for (int i = 0; i < value.length(); i++) { + if (!isASCIIDigit(value.charAt(i))) { + return false; + } + } + return true; } - return true; - } - - private static boolean isASCIIDigit(final char c) { - return c >= '0' && c <= '9'; - } - private static boolean isASCIIAlphanumeric(final char c) { - return isASCIIDigit(c) || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); - } + private static boolean isASCIIDigit(final char c) { + return c >= '0' && c <= '9'; + } - // --- Accessors for testing --- + private static boolean isASCIIAlphanumeric(final char c) { + return isASCIIDigit(c) || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } - long getMajor() { - return core[0]; - } + // --- Accessors for testing --- - long getMinor() { - return core[1]; - } + long getMajor() { + return core[0]; + } - long getPatch() { - return core[2]; - } + long getMinor() { + return core[1]; + } - String getPrerelease() { - return prerelease; - } + long getPatch() { + return core[2]; + } - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; + String getPrerelease() { + return prerelease; } - if (!(o instanceof ParsedSemver)) { - return false; + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ParsedSemver)) { + return false; + } + // Delegate to compare so equality matches precedence (SEMVER_EQ) and sorted collections, even + // when versions differ only by trailing zero components such as 1.2.3 and 1.2.3.0. + return compare(this, (ParsedSemver) o) == 0; } - // Delegate to compare so equality matches precedence (SEMVER_EQ) and sorted collections, even - // when versions differ only by trailing zero components such as 1.2.3 and 1.2.3.0. - return compare(this, (ParsedSemver) o) == 0; - } - @Override - public int hashCode() { - // Hash over the trailing-zero-stripped core so that compare-equal versions hash identically. - final int effectiveLength = effectiveCoreLength(); - int result = 1; - for (int i = 0; i < effectiveLength; i++) { - result = 31 * result + Long.hashCode(core[i]); + @Override + public int hashCode() { + // Hash over the trailing-zero-stripped core so that compare-equal versions hash identically. + final int effectiveLength = effectiveCoreLength(); + int result = 1; + for (int i = 0; i < effectiveLength; i++) { + result = 31 * result + Long.hashCode(core[i]); + } + return 31 * result + prerelease.hashCode(); } - return 31 * result + prerelease.hashCode(); - } - @Override - public String toString() { - final StringBuilder value = new StringBuilder(); - for (int i = 0; i < coreLength; i++) { - if (i > 0) { - value.append('.'); - } - value.append(Long.toUnsignedString(coreComponent(i))); + @Override + public String toString() { + final StringBuilder value = new StringBuilder(); + for (int i = 0; i < coreLength; i++) { + if (i > 0) { + value.append('.'); + } + value.append(Long.toUnsignedString(coreComponent(i))); + } + return value.append(prerelease.isEmpty() ? "" : "-" + prerelease).toString(); } - return value.append(prerelease.isEmpty() ? "" : "-" + prerelease).toString(); - } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java index ed0fe60f366..926567fd579 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java @@ -3,9 +3,9 @@ import java.util.List; public class Rule { - public final List conditions; + public final List conditions; - public Rule(final List conditions) { - this.conditions = conditions; - } + public Rule(final List conditions) { + this.conditions = conditions; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java index 60f39fc666f..6dad267b16a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java @@ -4,31 +4,31 @@ import java.util.Map; public class ServerConfiguration { - public final String createdAt; - public final String format; - // Boxed on purpose. Moshi's reflective adapter for a primitive boolean field aborts the whole - // UFC parse when the JSON value is null or not a boolean; with a Boolean field it tolerates - // null (and other malformed values are still caught locally) so a malformed consent field - // doesn't strand a fresh pod on PROVIDER_NOT_READY. Read sites must use - // Boolean.TRUE.equals(...) so null falls to the privacy-preserving default. - public final Boolean observeFullEvaluationData; - public final Environment environment; - public final Map flags; + public final String createdAt; + public final String format; + // Boxed on purpose. Moshi's reflective adapter for a primitive boolean field aborts the whole + // UFC parse when the JSON value is null or not a boolean; with a Boolean field it tolerates + // null (and other malformed values are still caught locally) so a malformed consent field + // doesn't strand a fresh pod on PROVIDER_NOT_READY. Read sites must use + // Boolean.TRUE.equals(...) so null falls to the privacy-preserving default. + public final Boolean observeFullEvaluationData; + public final Environment environment; + public final Map flags; - // Flags that could not be parsed or validated. The key is the flag key; the value is the error - // type (e.g. "invalid_semver_comparand"). Set during configuration preprocessing, not from JSON. - public transient Map invalidFlags = Collections.emptyMap(); + // Flags that could not be parsed or validated. The key is the flag key; the value is the error + // type (e.g. "invalid_semver_comparand"). Set during configuration preprocessing, not from JSON. + public transient Map invalidFlags = Collections.emptyMap(); - public ServerConfiguration( - final String createdAt, - final String format, - final Boolean observeFullEvaluationData, - final Environment environment, - final Map flags) { - this.createdAt = createdAt; - this.format = format; - this.observeFullEvaluationData = observeFullEvaluationData; - this.environment = environment; - this.flags = flags; - } + public ServerConfiguration( + final String createdAt, + final String format, + final Boolean observeFullEvaluationData, + final Environment environment, + final Map flags) { + this.createdAt = createdAt; + this.format = format; + this.observeFullEvaluationData = observeFullEvaluationData; + this.environment = environment; + this.flags = flags; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java index fef1e5f04b4..5f543edfc6d 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java @@ -10,13 +10,13 @@ * before comparing it or using it for arithmetic. */ public class Shard { - public final String salt; - public final List ranges; - public final int totalShards; + public final String salt; + public final List ranges; + public final int totalShards; - public Shard(final String salt, final List ranges, final int totalShards) { - this.salt = salt; - this.ranges = ranges; - this.totalShards = totalShards; - } + public Shard(final String salt, final List ranges, final int totalShards) { + this.salt = salt; + this.ranges = ranges; + this.totalShards = totalShards; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java index 4a8c5078daf..5d9fc13c838 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java @@ -8,11 +8,11 @@ * Integer#toUnsignedLong(int)} before comparing them. */ public class ShardRange { - public final int start; - public final int end; + public final int start; + public final int end; - public ShardRange(final int start, final int end) { - this.start = start; - this.end = end; - } + public ShardRange(final int start, final int end) { + this.start = start; + this.end = end; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java index 37f6909eeea..19e02622c19 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java @@ -4,22 +4,22 @@ import java.util.Map; public class Split { - public final List shards; - public final String variationKey; - public final Map extraLogging; - // Nullable Integer (not primitive int): the serialId is absent in some UFC shapes. Populated by - // Moshi reflective deserialization from the UFC "serialId" JSON field. Surfaced as - // __dd_split_serial_id in eval metadata for APM span enrichment. - public final Integer serialId; + public final List shards; + public final String variationKey; + public final Map extraLogging; + // Nullable Integer (not primitive int): the serialId is absent in some UFC shapes. Populated by + // Moshi reflective deserialization from the UFC "serialId" JSON field. Surfaced as + // __dd_split_serial_id in eval metadata for APM span enrichment. + public final Integer serialId; - public Split( - final List shards, - final String variationKey, - final Map extraLogging, - final Integer serialId) { - this.shards = shards; - this.variationKey = variationKey; - this.extraLogging = extraLogging; - this.serialId = serialId; - } + public Split( + final List shards, + final String variationKey, + final Map extraLogging, + final Integer serialId) { + this.shards = shards; + this.variationKey = variationKey; + this.extraLogging = extraLogging; + this.serialId = serialId; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java index 6f0a973a379..747becf82ee 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java @@ -1,9 +1,9 @@ package datadog.trace.api.featureflag.ufc.v1; public enum ValueType { - BOOLEAN, - INTEGER, - NUMERIC, - STRING, - JSON + BOOLEAN, + INTEGER, + NUMERIC, + STRING, + JSON } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java index 26e30aa1aad..9e2738ad13b 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java @@ -1,11 +1,11 @@ package datadog.trace.api.featureflag.ufc.v1; public class Variant { - public final String key; - public final Object value; + public final String key; + public final Object value; - public Variant(final String key, final Object value) { - this.key = key; - this.value = value; - } + public Variant(final String key, final Object value) { + this.key = key; + this.value = value; + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java index 887a153f0a1..e079ab0d920 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java @@ -12,104 +12,104 @@ class FeatureFlaggingGatewayTest { - private FeatureFlaggingGateway.ConfigListener configListener; - private FeatureFlaggingGateway.ActivationListener activationListener; - private FeatureFlaggingGateway.ExposureListener exposureListener; - private FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener; - private ServerConfiguration firstConfiguration; - private ServerConfiguration secondConfiguration; - private ExposureEvent firstExposure; - private ExposureEvent secondExposure; - - @BeforeEach - void setUp() { - configListener = mock(FeatureFlaggingGateway.ConfigListener.class); - activationListener = mock(FeatureFlaggingGateway.ActivationListener.class); - exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); - spanEnrichmentListener = mock(FeatureFlaggingGateway.SpanEnrichmentListener.class); - firstConfiguration = mock(ServerConfiguration.class); - secondConfiguration = mock(ServerConfiguration.class); - firstExposure = mock(ExposureEvent.class); - secondExposure = mock(ExposureEvent.class); - } - - @AfterEach - void tearDown() { - FeatureFlaggingGateway.removeConfigListener(configListener); - FeatureFlaggingGateway.removeActivationListener(activationListener); - FeatureFlaggingGateway.removeExposureListener(exposureListener); - FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - void testProviderActivationListener() { - FeatureFlaggingGateway.addActivationListener(activationListener); - - FeatureFlaggingGateway.activate(); - - verify(activationListener).activate(); - verifyNoMoreInteractions(activationListener); - } - - @Test - void testAttachingAConfigListener() { - clearCurrentServerConfiguration(); - - FeatureFlaggingGateway.addConfigListener(configListener); - FeatureFlaggingGateway.dispatch(firstConfiguration); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - - FeatureFlaggingGateway.dispatch(secondConfiguration); - - verify(configListener).accept(secondConfiguration); - verifyNoMoreInteractions(configListener); - } - - @Test - void testAttachingAListenerAfterConfigured() { - FeatureFlaggingGateway.dispatch(firstConfiguration); - FeatureFlaggingGateway.addConfigListener(configListener); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - } - - @Test - void testAttachingAnExposureListener() { - FeatureFlaggingGateway.addExposureListener(exposureListener); - FeatureFlaggingGateway.dispatch(firstExposure); - - verify(exposureListener).accept(firstExposure); - verifyNoMoreInteractions(exposureListener); - - FeatureFlaggingGateway.dispatch(secondExposure); - - verify(exposureListener).accept(secondExposure); - verifyNoMoreInteractions(exposureListener); - } - - @Test - void testAttachingASpanEnrichmentListener() { - final SpanEnrichmentEvent firstEvent = SpanEnrichmentEvent.serialId(42, true, "user-1"); - final SpanEnrichmentEvent secondEvent = SpanEnrichmentEvent.runtimeDefault("flag", "value"); - - FeatureFlaggingGateway.addSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.dispatch(firstEvent); - - verify(spanEnrichmentListener).accept(firstEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - - FeatureFlaggingGateway.dispatch(secondEvent); - - verify(spanEnrichmentListener).accept(secondEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - } - - private static void clearCurrentServerConfiguration() { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } + private FeatureFlaggingGateway.ConfigListener configListener; + private FeatureFlaggingGateway.ActivationListener activationListener; + private FeatureFlaggingGateway.ExposureListener exposureListener; + private FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener; + private ServerConfiguration firstConfiguration; + private ServerConfiguration secondConfiguration; + private ExposureEvent firstExposure; + private ExposureEvent secondExposure; + + @BeforeEach + void setUp() { + configListener = mock(FeatureFlaggingGateway.ConfigListener.class); + activationListener = mock(FeatureFlaggingGateway.ActivationListener.class); + exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); + spanEnrichmentListener = mock(FeatureFlaggingGateway.SpanEnrichmentListener.class); + firstConfiguration = mock(ServerConfiguration.class); + secondConfiguration = mock(ServerConfiguration.class); + firstExposure = mock(ExposureEvent.class); + secondExposure = mock(ExposureEvent.class); + } + + @AfterEach + void tearDown() { + FeatureFlaggingGateway.removeConfigListener(configListener); + FeatureFlaggingGateway.removeActivationListener(activationListener); + FeatureFlaggingGateway.removeExposureListener(exposureListener); + FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); + FeatureFlaggingGateway.setFlagEvalWriter(null); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + } + + @Test + void testProviderActivationListener() { + FeatureFlaggingGateway.addActivationListener(activationListener); + + FeatureFlaggingGateway.activate(); + + verify(activationListener).activate(); + verifyNoMoreInteractions(activationListener); + } + + @Test + void testAttachingAConfigListener() { + clearCurrentServerConfiguration(); + + FeatureFlaggingGateway.addConfigListener(configListener); + FeatureFlaggingGateway.dispatch(firstConfiguration); + + verify(configListener).accept(firstConfiguration); + verifyNoMoreInteractions(configListener); + + FeatureFlaggingGateway.dispatch(secondConfiguration); + + verify(configListener).accept(secondConfiguration); + verifyNoMoreInteractions(configListener); + } + + @Test + void testAttachingAListenerAfterConfigured() { + FeatureFlaggingGateway.dispatch(firstConfiguration); + FeatureFlaggingGateway.addConfigListener(configListener); + + verify(configListener).accept(firstConfiguration); + verifyNoMoreInteractions(configListener); + } + + @Test + void testAttachingAnExposureListener() { + FeatureFlaggingGateway.addExposureListener(exposureListener); + FeatureFlaggingGateway.dispatch(firstExposure); + + verify(exposureListener).accept(firstExposure); + verifyNoMoreInteractions(exposureListener); + + FeatureFlaggingGateway.dispatch(secondExposure); + + verify(exposureListener).accept(secondExposure); + verifyNoMoreInteractions(exposureListener); + } + + @Test + void testAttachingASpanEnrichmentListener() { + final SpanEnrichmentEvent firstEvent = SpanEnrichmentEvent.serialId(42, true, "user-1"); + final SpanEnrichmentEvent secondEvent = SpanEnrichmentEvent.runtimeDefault("flag", "value"); + + FeatureFlaggingGateway.addSpanEnrichmentListener(spanEnrichmentListener); + FeatureFlaggingGateway.dispatch(firstEvent); + + verify(spanEnrichmentListener).accept(firstEvent); + verifyNoMoreInteractions(spanEnrichmentListener); + + FeatureFlaggingGateway.dispatch(secondEvent); + + verify(spanEnrichmentListener).accept(secondEvent); + verifyNoMoreInteractions(spanEnrichmentListener); + } + + private static void clearCurrentServerConfiguration() { + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java index 55e7a8943ac..049f0d580d7 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java @@ -10,47 +10,47 @@ class SpanEnrichmentEventTest { - @Test - void serialIdEventCarriesItsFields() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(42, true, "user-1"); - - assertTrue(event.hasSerialId()); - assertEquals(42, event.serialId()); - assertTrue(event.doLog()); - assertEquals("user-1", event.targetingKey()); - assertNull(event.flagKey()); - assertNull(event.defaultValue()); - } - - @Test - void serialIdEventWithoutDoLogOrTargetingKey() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(7, false, null); - - assertTrue(event.hasSerialId()); - assertEquals(7, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventCarriesItsFields() { - final Object value = Collections.singletonMap("k", "v"); - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", value); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertEquals(value, event.defaultValue()); - assertEquals(0, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventAllowsNullValue() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", null); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertNull(event.defaultValue()); - } + @Test + void serialIdEventCarriesItsFields() { + final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(42, true, "user-1"); + + assertTrue(event.hasSerialId()); + assertEquals(42, event.serialId()); + assertTrue(event.doLog()); + assertEquals("user-1", event.targetingKey()); + assertNull(event.flagKey()); + assertNull(event.defaultValue()); + } + + @Test + void serialIdEventWithoutDoLogOrTargetingKey() { + final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(7, false, null); + + assertTrue(event.hasSerialId()); + assertEquals(7, event.serialId()); + assertFalse(event.doLog()); + assertNull(event.targetingKey()); + } + + @Test + void runtimeDefaultEventCarriesItsFields() { + final Object value = Collections.singletonMap("k", "v"); + final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", value); + + assertFalse(event.hasSerialId()); + assertEquals("flag", event.flagKey()); + assertEquals(value, event.defaultValue()); + assertEquals(0, event.serialId()); + assertFalse(event.doLog()); + assertNull(event.targetingKey()); + } + + @Test + void runtimeDefaultEventAllowsNullValue() { + final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", null); + + assertFalse(event.hasSerialId()); + assertEquals("flag", event.flagKey()); + assertNull(event.defaultValue()); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java index cc614150178..3df44c6477d 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java @@ -12,43 +12,40 @@ class FlagEvalEventTest { - @Test - void storesFieldsWithContextAttributes() { - final Map attrs = Collections.singletonMap("tier", "gold"); - - final FlagEvalEvent event = - new FlagEvalEvent("my-flag", "on", "allocation-1", "target-1", 123L, attrs); - - assertEquals("my-flag", event.flagKey); - assertEquals("on", event.variant); - assertEquals("allocation-1", event.allocationKey); - assertEquals("target-1", event.targetingKey); - assertNull(event.errorMessage); - assertEquals(123L, event.evalTimeMs); - assertSame(attrs, event.attrs); - } - - @Test - void storesErrorMessageAndDefaultsNullContextAttributes() { - final Map attrs = null; - final FlagEvalEvent event = - new FlagEvalEvent("my-flag", null, null, null, "type mismatch", 456L, attrs); - - assertEquals("type mismatch", event.errorMessage); - assertTrue(event.attrs.isEmpty()); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseOnConvenienceConstructors() { - final Map attrs = Collections.emptyMap(); - assertFalse(new FlagEvalEvent("f", "on", "a", "t", 1L, attrs).observeFullEvaluationData); - assertFalse(new FlagEvalEvent("f", "on", "a", "t", null, 1L, attrs).observeFullEvaluationData); - } - - @Test - void storesExplicitObserveFullEvaluationData() { - final Map attrs = Collections.emptyMap(); - assertTrue( - new FlagEvalEvent("f", "on", "a", "t", null, 1L, true, attrs).observeFullEvaluationData); - } + @Test + void storesFieldsWithContextAttributes() { + final Map attrs = Collections.singletonMap("tier", "gold"); + + final FlagEvalEvent event = new FlagEvalEvent("my-flag", "on", "allocation-1", "target-1", 123L, attrs); + + assertEquals("my-flag", event.flagKey); + assertEquals("on", event.variant); + assertEquals("allocation-1", event.allocationKey); + assertEquals("target-1", event.targetingKey); + assertNull(event.errorMessage); + assertEquals(123L, event.evalTimeMs); + assertSame(attrs, event.attrs); + } + + @Test + void storesErrorMessageAndDefaultsNullContextAttributes() { + final Map attrs = null; + final FlagEvalEvent event = new FlagEvalEvent("my-flag", null, null, null, "type mismatch", 456L, attrs); + + assertEquals("type mismatch", event.errorMessage); + assertTrue(event.attrs.isEmpty()); + } + + @Test + void observeFullEvaluationDataDefaultsToFalseOnConvenienceConstructors() { + final Map attrs = Collections.emptyMap(); + assertFalse(new FlagEvalEvent("f", "on", "a", "t", 1L, attrs).observeFullEvaluationData); + assertFalse(new FlagEvalEvent("f", "on", "a", "t", null, 1L, attrs).observeFullEvaluationData); + } + + @Test + void storesExplicitObserveFullEvaluationData() { + final Map attrs = Collections.emptyMap(); + assertTrue(new FlagEvalEvent("f", "on", "a", "t", null, 1L, true, attrs).observeFullEvaluationData); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java index e3d78ac4b86..01cde560e8b 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java @@ -14,207 +14,192 @@ class ParsedSemverTest { - // --- Parse tests (ported from Go semver_test.go TestParseSemver) --- - - static Stream validVersions() { - return Stream.of( - Arguments.of("0.0.0", 0L, 0L, 0L, ""), - Arguments.of( - "18446744073709551615.18446744073709551615.18446744073709551615", - Long.parseUnsignedLong("18446744073709551615"), - Long.parseUnsignedLong("18446744073709551615"), - Long.parseUnsignedLong("18446744073709551615"), - ""), - Arguments.of("1.2.3-alpha.1", 1L, 2L, 3L, "alpha.1"), - Arguments.of("1.2.3-18446744073709551616", 1L, 2L, 3L, "18446744073709551616"), - Arguments.of("1.2.3+build.001", 1L, 2L, 3L, ""), - Arguments.of("1.2.3-alpha-1+build.001", 1L, 2L, 3L, "alpha-1")); - } - - @ParameterizedTest(name = "valid: {0}") - @MethodSource("validVersions") - void testParseValid( - final String version, - final long major, - final long minor, - final long patch, - final String prerelease) { - final ParsedSemver parsed = ParsedSemver.parse(version); - assertTrue(parsed != null, "expected " + version + " to parse"); - assertEquals(major, parsed.getMajor()); - assertEquals(minor, parsed.getMinor()); - assertEquals(patch, parsed.getPatch()); - assertEquals(prerelease, parsed.getPrerelease()); - } - - static Stream invalidVersions() { - return Stream.of( - "", - "x", - "v1.2.3", - "01.2.3", - "1.02.3", - "1.2.03", - "18446744073709551616.0.0", - "0.18446744073709551616.0", - "0.0.18446744073709551616", - "1.2.3-", - "1.2.3+", - "1.2.3-alpha..1", - "1.2.3+build..1", - "1.2.3-01", - "1.2.3-alpha_1", - "1.2.3-alpha+build+other", - "1.2.3-α", - " 1.2.3", - "1.2.3 "); - } - - @ParameterizedTest(name = "invalid: {0}") - @MethodSource("invalidVersions") - void testParseInvalid(final String version) { - assertNull(ParsedSemver.parse(version), "expected " + version + " to be invalid"); - } - - // --- Compare tests (ported from Go semver_test.go TestCompareSemver) --- - - /** The canonical SemVer precedence ordering from the spec. */ - private static final String[] ORDERED_VERSIONS = { - "1.0.0-alpha", - "1.0.0-alpha.1", - "1.0.0-alpha.beta", - "1.0.0-beta", - "1.0.0-beta.2", - "1.0.0-beta.11", - "1.0.0-rc.1", - "1.0.0", - "1.0.1", - "1.1.0", - "2.0.0", - }; - - @Test - void normalizesMissingCorePartsAndComparesExtendedCoreParts() { - assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18"), ParsedSemver.parse("18.0.0"))); - assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18.0"), ParsedSemver.parse("18.0.0"))); - assertEquals( - 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0"))); - assertEquals( - 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0.0"))); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4"), ParsedSemver.parse("1.2.3.5")) < 0); - assertTrue(ParsedSemver.parse("1.2.3.4.5.6") != null); - assertTrue(ParsedSemver.parse("1.2.3.4.5.6.7") != null); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5"), ParsedSemver.parse("1.2.3.4.6")) < 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5.6"), ParsedSemver.parse("1.2.3.4.5.7")) - < 0); - assertTrue( - ParsedSemver.compare( - ParsedSemver.parse("1.2.3.4.5.6.7"), ParsedSemver.parse("1.2.3.4.5.6.8")) - < 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("18.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); - assertTrue( - ParsedSemver.compare(ParsedSemver.parse("18.0.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); - } - - @Test - void testCompareSemverOrdering() { - for (int i = 0; i < ORDERED_VERSIONS.length; i++) { - final ParsedSemver left = ParsedSemver.parse(ORDERED_VERSIONS[i]); - assertTrue(left != null, "left parse failed: " + ORDERED_VERSIONS[i]); - for (int j = 0; j < ORDERED_VERSIONS.length; j++) { - final ParsedSemver right = ParsedSemver.parse(ORDERED_VERSIONS[j]); - assertTrue(right != null, "right parse failed: " + ORDERED_VERSIONS[j]); - final int ordering = ParsedSemver.compare(left, right); - if (i < j) { - assertTrue(ordering < 0, ORDERED_VERSIONS[i] + " should precede " + ORDERED_VERSIONS[j]); - } else if (i > j) { - assertTrue(ordering > 0, ORDERED_VERSIONS[i] + " should follow " + ORDERED_VERSIONS[j]); - } else { - assertEquals(0, ordering); + // --- Parse tests (ported from Go semver_test.go TestParseSemver) --- + + static Stream validVersions() { + return Stream.of( + Arguments.of("0.0.0", 0L, 0L, 0L, ""), + Arguments.of( + "18446744073709551615.18446744073709551615.18446744073709551615", + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + ""), + Arguments.of("1.2.3-alpha.1", 1L, 2L, 3L, "alpha.1"), + Arguments.of("1.2.3-18446744073709551616", 1L, 2L, 3L, "18446744073709551616"), + Arguments.of("1.2.3+build.001", 1L, 2L, 3L, ""), + Arguments.of("1.2.3-alpha-1+build.001", 1L, 2L, 3L, "alpha-1")); + } + + @ParameterizedTest(name = "valid: {0}") + @MethodSource("validVersions") + void testParseValid( + final String version, final long major, final long minor, final long patch, final String prerelease) { + final ParsedSemver parsed = ParsedSemver.parse(version); + assertTrue(parsed != null, "expected " + version + " to parse"); + assertEquals(major, parsed.getMajor()); + assertEquals(minor, parsed.getMinor()); + assertEquals(patch, parsed.getPatch()); + assertEquals(prerelease, parsed.getPrerelease()); + } + + static Stream invalidVersions() { + return Stream.of( + "", + "x", + "v1.2.3", + "01.2.3", + "1.02.3", + "1.2.03", + "18446744073709551616.0.0", + "0.18446744073709551616.0", + "0.0.18446744073709551616", + "1.2.3-", + "1.2.3+", + "1.2.3-alpha..1", + "1.2.3+build..1", + "1.2.3-01", + "1.2.3-alpha_1", + "1.2.3-alpha+build+other", + "1.2.3-α", + " 1.2.3", + "1.2.3 "); + } + + @ParameterizedTest(name = "invalid: {0}") + @MethodSource("invalidVersions") + void testParseInvalid(final String version) { + assertNull(ParsedSemver.parse(version), "expected " + version + " to be invalid"); + } + + // --- Compare tests (ported from Go semver_test.go TestCompareSemver) --- + + /** The canonical SemVer precedence ordering from the spec. */ + private static final String[] ORDERED_VERSIONS = { + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.1.0", + "2.0.0", + }; + + @Test + void normalizesMissingCorePartsAndComparesExtendedCoreParts() { + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18"), ParsedSemver.parse("18.0.0"))); + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18.0"), ParsedSemver.parse("18.0.0"))); + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0"))); + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0.0"))); + assertTrue(ParsedSemver.compare(ParsedSemver.parse("1.2.3.4"), ParsedSemver.parse("1.2.3.5")) < 0); + assertTrue(ParsedSemver.parse("1.2.3.4.5.6") != null); + assertTrue(ParsedSemver.parse("1.2.3.4.5.6.7") != null); + assertTrue(ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5"), ParsedSemver.parse("1.2.3.4.6")) < 0); + assertTrue(ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5.6"), ParsedSemver.parse("1.2.3.4.5.7")) < 0); + assertTrue(ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5.6.7"), ParsedSemver.parse("1.2.3.4.5.6.8")) < 0); + assertTrue(ParsedSemver.compare(ParsedSemver.parse("18.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); + assertTrue(ParsedSemver.compare(ParsedSemver.parse("18.0.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); + } + + @Test + void testCompareSemverOrdering() { + for (int i = 0; i < ORDERED_VERSIONS.length; i++) { + final ParsedSemver left = ParsedSemver.parse(ORDERED_VERSIONS[i]); + assertTrue(left != null, "left parse failed: " + ORDERED_VERSIONS[i]); + for (int j = 0; j < ORDERED_VERSIONS.length; j++) { + final ParsedSemver right = ParsedSemver.parse(ORDERED_VERSIONS[j]); + assertTrue(right != null, "right parse failed: " + ORDERED_VERSIONS[j]); + final int ordering = ParsedSemver.compare(left, right); + if (i < j) { + assertTrue(ordering < 0, ORDERED_VERSIONS[i] + " should precede " + ORDERED_VERSIONS[j]); + } else if (i > j) { + assertTrue(ordering > 0, ORDERED_VERSIONS[i] + " should follow " + ORDERED_VERSIONS[j]); + } else { + assertEquals(0, ordering); + } + } } - } } - } - - @Test - void testCompareSemverArbitrarilyLargeNumericPrerelease() { - final ParsedSemver left = ParsedSemver.parse("1.0.0-99999999999999999999"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0-100000000000000000000"); - assertTrue(right != null); - assertTrue(ParsedSemver.compare(left, right) < 0); - } - - @Test - void testValueObjectMethods() { - final ParsedSemver value = ParsedSemver.parse("1.2.3-alpha"); - final ParsedSemver equalValue = ParsedSemver.parse("1.2.3-alpha"); - final ParsedSemver release = ParsedSemver.parse("1.2.3"); - assertTrue(value != null); - assertTrue(equalValue != null); - assertTrue(release != null); - assertTrue(value.equals(value)); - assertTrue(value.equals(equalValue)); - assertFalse(value.equals(null)); - assertFalse(value.equals("1.2.3-alpha")); - assertEquals(value.hashCode(), equalValue.hashCode()); - assertEquals("1.2.3-alpha", value.toString()); - assertEquals("1.2.3", release.toString()); - } - - @Test - void testCompareSemverNumericPrereleaseIdentifiersLexicographically() { - final ParsedSemver left = ParsedSemver.parse("1.0.0-10"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0-11"); - assertTrue(right != null); - assertTrue(ParsedSemver.compare(left, right) < 0); - } - - @Test - void testEqualsAndHashCodeNormalizeTrailingZeroComponents() { - // Versions that compare as equal must also be equals() and share a hashCode, even when they - // differ only by trailing zero components (1.2.3 vs 1.2.3.0 vs 1.2.3.0.0). - final ParsedSemver base = ParsedSemver.parse("1.2.3"); - final ParsedSemver trailingZero = ParsedSemver.parse("1.2.3.0"); - final ParsedSemver trailingZeros = ParsedSemver.parse("1.2.3.0.0"); - assertTrue(base != null); - assertTrue(trailingZero != null); - assertTrue(trailingZeros != null); - assertEquals(0, ParsedSemver.compare(base, trailingZero)); - assertEquals(0, ParsedSemver.compare(base, trailingZeros)); - assertEquals(base, trailingZero); - assertEquals(base, trailingZeros); - assertEquals(trailingZero, trailingZeros); - assertEquals(base.hashCode(), trailingZero.hashCode()); - assertEquals(base.hashCode(), trailingZeros.hashCode()); - assertEquals(trailingZero.hashCode(), trailingZeros.hashCode()); - - // All-zero versions of varying length are also compare-equal and must hash identically. - final ParsedSemver zero = ParsedSemver.parse("0"); - final ParsedSemver zeroZero = ParsedSemver.parse("0.0.0.0"); - assertTrue(zero != null); - assertTrue(zeroZero != null); - assertEquals(0, ParsedSemver.compare(zero, zeroZero)); - assertEquals(zero, zeroZero); - assertEquals(zero.hashCode(), zeroZero.hashCode()); - - // Distinct versions must remain unequal and may differ in hash. - final ParsedSemver release = ParsedSemver.parse("1.2.4"); - assertTrue(release != null); - assertNotEquals(base, release); - } - - @Test - void testCompareSemverBuildMetadataIsIgnored() { - final ParsedSemver left = ParsedSemver.parse("1.0.0+build.1"); - assertTrue(left != null); - final ParsedSemver right = ParsedSemver.parse("1.0.0+build.2"); - assertTrue(right != null); - assertEquals(0, ParsedSemver.compare(left, right)); - } + + @Test + void testCompareSemverArbitrarilyLargeNumericPrerelease() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-99999999999999999999"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-100000000000000000000"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testValueObjectMethods() { + final ParsedSemver value = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver equalValue = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver release = ParsedSemver.parse("1.2.3"); + assertTrue(value != null); + assertTrue(equalValue != null); + assertTrue(release != null); + assertTrue(value.equals(value)); + assertTrue(value.equals(equalValue)); + assertFalse(value.equals(null)); + assertFalse(value.equals("1.2.3-alpha")); + assertEquals(value.hashCode(), equalValue.hashCode()); + assertEquals("1.2.3-alpha", value.toString()); + assertEquals("1.2.3", release.toString()); + } + + @Test + void testCompareSemverNumericPrereleaseIdentifiersLexicographically() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-10"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-11"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testEqualsAndHashCodeNormalizeTrailingZeroComponents() { + // Versions that compare as equal must also be equals() and share a hashCode, even when they + // differ only by trailing zero components (1.2.3 vs 1.2.3.0 vs 1.2.3.0.0). + final ParsedSemver base = ParsedSemver.parse("1.2.3"); + final ParsedSemver trailingZero = ParsedSemver.parse("1.2.3.0"); + final ParsedSemver trailingZeros = ParsedSemver.parse("1.2.3.0.0"); + assertTrue(base != null); + assertTrue(trailingZero != null); + assertTrue(trailingZeros != null); + assertEquals(0, ParsedSemver.compare(base, trailingZero)); + assertEquals(0, ParsedSemver.compare(base, trailingZeros)); + assertEquals(base, trailingZero); + assertEquals(base, trailingZeros); + assertEquals(trailingZero, trailingZeros); + assertEquals(base.hashCode(), trailingZero.hashCode()); + assertEquals(base.hashCode(), trailingZeros.hashCode()); + assertEquals(trailingZero.hashCode(), trailingZeros.hashCode()); + + // All-zero versions of varying length are also compare-equal and must hash identically. + final ParsedSemver zero = ParsedSemver.parse("0"); + final ParsedSemver zeroZero = ParsedSemver.parse("0.0.0.0"); + assertTrue(zero != null); + assertTrue(zeroZero != null); + assertEquals(0, ParsedSemver.compare(zero, zeroZero)); + assertEquals(zero, zeroZero); + assertEquals(zero.hashCode(), zeroZero.hashCode()); + + // Distinct versions must remain unequal and may differ in hash. + final ParsedSemver release = ParsedSemver.parse("1.2.4"); + assertTrue(release != null); + assertNotEquals(base, release); + } + + @Test + void testCompareSemverBuildMetadataIsIgnored() { + final ParsedSemver left = ParsedSemver.parse("1.0.0+build.1"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0+build.2"); + assertTrue(right != null); + assertEquals(0, ParsedSemver.compare(left, right)); + } } diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java index 1edfd5bcb26..54a3ce9dce2 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -2,99 +2,93 @@ public class FeatureFlaggingConfig { - public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; - public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; + public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; - private static final Resolution DISABLED_RESOLUTION = new Resolution(false, null); - private static final Resolution AGENTLESS_CONFIGURATION = - new Resolution(true, CONFIGURATION_SOURCE_AGENTLESS); - private static final Resolution REMOTE_CONFIG_CONFIGURATION = - new Resolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG); + private static final Resolution DISABLED_RESOLUTION = new Resolution(false, null); + private static final Resolution AGENTLESS_CONFIGURATION = new Resolution(true, CONFIGURATION_SOURCE_AGENTLESS); + private static final Resolution REMOTE_CONFIG_CONFIGURATION = + new Resolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG); - public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; - public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = - "experimental.flagging.provider.enabled"; + public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; - /** - * Opt-in gate for APM span enrichment with feature-flag evaluation metadata. DISTINCT from {@link - * #FEATURE_FLAGS_ENABLED} and OFF by default — enabling the provider does not enable span - * enrichment. - */ - public static final String EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED = - "experimental.flagging.provider.span.enrichment.enabled"; + /** + * Opt-in gate for APM span enrichment with feature-flag evaluation metadata. DISTINCT from {@link + * #FEATURE_FLAGS_ENABLED} and OFF by default — enabling the provider does not enable span + * enrichment. + */ + public static final String EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED = + "experimental.flagging.provider.span.enrichment.enabled"; - /** - * Killswitch for the EVP {@code flagevaluation} emission path. Default: enabled. Disabling it - * turns off EVP flag-evaluation counts while leaving the OTel {@code feature_flag.evaluations} - * metric path untouched. Maps to {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED}. - */ - public static final String FLAGGING_EVALUATION_COUNTS_ENABLED = - "flagging.evaluation.counts.enabled"; + /** + * Killswitch for the EVP {@code flagevaluation} emission path. Default: enabled. Disabling it + * turns off EVP flag-evaluation counts while leaving the OTel {@code feature_flag.evaluations} + * metric path untouched. Maps to {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED}. + */ + public static final String FLAGGING_EVALUATION_COUNTS_ENABLED = "flagging.evaluation.counts.enabled"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = - "feature.flags.configuration.source"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = - "feature.flags.configuration.source.agentless.base.url"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = - "feature.flags.configuration.source.agentless.poll.interval.seconds"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = - "feature.flags.configuration.source.agentless.request.timeout.seconds"; + public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; + public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = + "feature.flags.configuration.source.agentless.base.url"; + public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = + "feature.flags.configuration.source.agentless.poll.interval.seconds"; + public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = + "feature.flags.configuration.source.agentless.request.timeout.seconds"; - public static Resolution resolveConfiguration( - final Boolean providerEnabled, - final String explicitSource, - final Boolean legacyProviderEnabled) { - final String normalizedSource = normalizeConfigurationSource(explicitSource); - if (Boolean.FALSE.equals(providerEnabled)) { - return new Resolution(false, normalizedSource); - } - if (normalizedSource != null) { - if (CONFIGURATION_SOURCE_AGENTLESS.equals(normalizedSource)) { + public static Resolution resolveConfiguration( + final Boolean providerEnabled, final String explicitSource, final Boolean legacyProviderEnabled) { + final String normalizedSource = normalizeConfigurationSource(explicitSource); + if (Boolean.FALSE.equals(providerEnabled)) { + return new Resolution(false, normalizedSource); + } + if (normalizedSource != null) { + if (CONFIGURATION_SOURCE_AGENTLESS.equals(normalizedSource)) { + return AGENTLESS_CONFIGURATION; + } + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(normalizedSource)) { + return REMOTE_CONFIG_CONFIGURATION; + } + return new Resolution(false, normalizedSource); + } + if (legacyProviderEnabled != null) { + return legacyProviderEnabled ? REMOTE_CONFIG_CONFIGURATION : DISABLED_RESOLUTION; + } return AGENTLESS_CONFIGURATION; - } - if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(normalizedSource)) { - return REMOTE_CONFIG_CONFIGURATION; - } - return new Resolution(false, normalizedSource); - } - if (legacyProviderEnabled != null) { - return legacyProviderEnabled ? REMOTE_CONFIG_CONFIGURATION : DISABLED_RESOLUTION; } - return AGENTLESS_CONFIGURATION; - } - public static boolean isSupportedConfigurationSource(final String source) { - final String normalizedSource = normalizeConfigurationSource(source); - return normalizedSource == null - || CONFIGURATION_SOURCE_AGENTLESS.equals(normalizedSource) - || CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(normalizedSource); - } + public static boolean isSupportedConfigurationSource(final String source) { + final String normalizedSource = normalizeConfigurationSource(source); + return normalizedSource == null + || CONFIGURATION_SOURCE_AGENTLESS.equals(normalizedSource) + || CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(normalizedSource); + } - private static String normalizeConfigurationSource(final String source) { - if (source == null) { - return null; + private static String normalizeConfigurationSource(final String source) { + if (source == null) { + return null; + } + final String normalized = source.trim().toLowerCase(java.util.Locale.ROOT); + return normalized.isEmpty() ? null : normalized; } - final String normalized = source.trim().toLowerCase(java.util.Locale.ROOT); - return normalized.isEmpty() ? null : normalized; - } - public static final class Resolution { - private final boolean enabled; - private final String source; + public static final class Resolution { + private final boolean enabled; + private final String source; - private Resolution(final boolean enabled, final String source) { - this.enabled = enabled; - this.source = source; - } + private Resolution(final boolean enabled, final String source) { + this.enabled = enabled; + this.source = source; + } - public boolean isEnabled() { - return enabled; - } + public boolean isEnabled() { + return enabled; + } - public String getSource() { - return source; + public String getSource() { + return source; + } } - } - private FeatureFlaggingConfig() {} + private FeatureFlaggingConfig() {} } diff --git a/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java index 16283869d71..381f6ba302a 100644 --- a/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java +++ b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java @@ -13,43 +13,43 @@ class FeatureFlaggingConfigTest { - @Test - void appliesConfigurationPrecedence() { - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, null, null); - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, " ", null); - assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, null, true); - assertResolution(false, null, null, null, false); - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, "agentless", true); - assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, " remote_CONFIG ", false); - assertResolution(false, CONFIGURATION_SOURCE_AGENTLESS, false, "agentless", true); - assertResolution(false, "invalid", null, "invalid", null); - assertResolution(false, "offline", null, " OFFLINE ", true); - } + @Test + void appliesConfigurationPrecedence() { + assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, null, null); + assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, " ", null); + assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, null, true); + assertResolution(false, null, null, null, false); + assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, "agentless", true); + assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, " remote_CONFIG ", false); + assertResolution(false, CONFIGURATION_SOURCE_AGENTLESS, false, "agentless", true); + assertResolution(false, "invalid", null, "invalid", null); + assertResolution(false, "offline", null, " OFFLINE ", true); + } - @Test - void recognizesSupportedExplicitSources() { - assertTrue(isSupportedConfigurationSource(null)); - assertTrue(isSupportedConfigurationSource(" ")); - assertTrue(isSupportedConfigurationSource("agentless")); - assertTrue(isSupportedConfigurationSource(" REMOTE_CONFIG ")); - assertFalse(isSupportedConfigurationSource("invalid")); - assertFalse(isSupportedConfigurationSource("offline")); - } + @Test + void recognizesSupportedExplicitSources() { + assertTrue(isSupportedConfigurationSource(null)); + assertTrue(isSupportedConfigurationSource(" ")); + assertTrue(isSupportedConfigurationSource("agentless")); + assertTrue(isSupportedConfigurationSource(" REMOTE_CONFIG ")); + assertFalse(isSupportedConfigurationSource("invalid")); + assertFalse(isSupportedConfigurationSource("offline")); + } - private static void assertResolution( - final boolean enabled, - final String source, - final Boolean providerEnabled, - final String explicitSource, - final Boolean legacyProviderEnabled) { - final FeatureFlaggingConfig.Resolution resolution = - resolveConfiguration(providerEnabled, explicitSource, legacyProviderEnabled); + private static void assertResolution( + final boolean enabled, + final String source, + final Boolean providerEnabled, + final String explicitSource, + final Boolean legacyProviderEnabled) { + final FeatureFlaggingConfig.Resolution resolution = + resolveConfiguration(providerEnabled, explicitSource, legacyProviderEnabled); - assertEquals(enabled, resolution.isEnabled()); - if (source == null) { - assertNull(resolution.getSource()); - } else { - assertEquals(source, resolution.getSource()); + assertEquals(enabled, resolution.isEnabled()); + if (source == null) { + assertNull(resolution.getSource()); + } else { + assertEquals(source, resolution.getSource()); + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java index bbec5d65229..fad3d66ef5e 100644 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java +++ b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java @@ -57,141 +57,139 @@ @Fork(value = 1) public class FlagEvaluationEnqueueContentionBenchmark { - /** Events the single consumer drains per invocation, so it can keep up with N producers. */ - static final int DRAIN_BATCH = 32; - - private static final int NUM_FLAGS = 100; - private static final int NUM_USERS = 50; - private static final int NUM_FIELDS = 10; - - private Map attrs; - private String[] flagKeys; - private String[] targetingKeys; - private FlagEvaluationWriterImpl writer; - - /** Per-producer cursor: a shared counter would add its own cache-line contention. */ - @State(Scope.Thread) - public static class ProducerCursor { - int cursor; - } - - @Setup(Level.Iteration) - public void setUp() { - // enqueue() no-ops unless the gateway gate is on; set it explicitly so the benchmark is not - // silently measuring an early return. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - - attrs = new HashMap<>(); - for (int i = 0; i < NUM_FIELDS; i++) { - attrs.put("field" + i, "value"); + /** Events the single consumer drains per invocation, so it can keep up with N producers. */ + static final int DRAIN_BATCH = 32; + + private static final int NUM_FLAGS = 100; + private static final int NUM_USERS = 50; + private static final int NUM_FIELDS = 10; + + private Map attrs; + private String[] flagKeys; + private String[] targetingKeys; + private FlagEvaluationWriterImpl writer; + + /** Per-producer cursor: a shared counter would add its own cache-line contention. */ + @State(Scope.Thread) + public static class ProducerCursor { + int cursor; } - flagKeys = keys("bench-flag-", NUM_FLAGS); - targetingKeys = keys("bench-user-", NUM_USERS); - - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); - // Capacity well above what the batch-draining consumer should ever let build up. - writer = - new FlagEvaluationWriterImpl( - 1 << 20, - Long.MAX_VALUE, - NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); - } - - /** - * Reports queue overflow so a saturated run - where the consumer failed to keep up and the - * numbers no longer describe the enqueue path - is visible in the benchmark output. - */ - @org.openjdk.jmh.annotations.TearDown(Level.Iteration) - @SuppressForbidden - public void reportOverflow() { - final long dropped = writer.droppedQueueOverflow(); - if (dropped > 0) { - System.out.println( - "\nWARNING: queue overflowed " - + dropped - + " times - consumer could not keep up, enqueue timings for this iteration are" - + " measuring overflow accounting, not the enqueue path."); + + @Setup(Level.Iteration) + public void setUp() { + // enqueue() no-ops unless the gateway gate is on; set it explicitly so the benchmark is not + // silently measuring an early return. + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + + attrs = new HashMap<>(); + for (int i = 0; i < NUM_FIELDS; i++) { + attrs.put("field" + i, "value"); + } + flagKeys = keys("bench-flag-", NUM_FLAGS); + targetingKeys = keys("bench-user-", NUM_USERS); + + final Config config = Config.get(); + final BackendApiFactory factory = new BackendApiFactory(config, null); + // Capacity well above what the batch-draining consumer should ever let build up. + writer = new FlagEvaluationWriterImpl( + 1 << 20, + Long.MAX_VALUE, + NANOSECONDS, + () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), + config); + } + + /** + * Reports queue overflow so a saturated run - where the consumer failed to keep up and the + * numbers no longer describe the enqueue path - is visible in the benchmark output. + */ + @org.openjdk.jmh.annotations.TearDown(Level.Iteration) + @SuppressForbidden + public void reportOverflow() { + final long dropped = writer.droppedQueueOverflow(); + if (dropped > 0) { + System.out.println("\nWARNING: queue overflowed " + + dropped + + " times - consumer could not keep up, enqueue timings for this iteration are" + + " measuring overflow accounting, not the enqueue path."); + } + } + + // ---- 1 producer: uncontended baseline ---- + + @Benchmark + @Group("producers1") + @GroupThreads(1) + public void enqueue1(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers1") + @GroupThreads(1) + public void drain1(final Blackhole blackhole) { + drain(blackhole); + } + + // ---- 4 producers ---- + + @Benchmark + @Group("producers4") + @GroupThreads(4) + public void enqueue4(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); } - } - - // ---- 1 producer: uncontended baseline ---- - - @Benchmark - @Group("producers1") - @GroupThreads(1) - public void enqueue1(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers1") - @GroupThreads(1) - public void drain1(final Blackhole blackhole) { - drain(blackhole); - } - - // ---- 4 producers ---- - - @Benchmark - @Group("producers4") - @GroupThreads(4) - public void enqueue4(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers4") - @GroupThreads(1) - public void drain4(final Blackhole blackhole) { - drain(blackhole); - } - - // ---- 16 producers ---- - - @Benchmark - @Group("producers16") - @GroupThreads(16) - public void enqueue16(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers16") - @GroupThreads(1) - public void drain16(final Blackhole blackhole) { - drain(blackhole); - } - - private void drain(final Blackhole blackhole) { - for (int i = 0; i < DRAIN_BATCH; i++) { - final FlagEvalEvent event = writer.pollQueuedEventForTest(); - if (event == null) { - break; - } - blackhole.consume(event); + + @Benchmark + @Group("producers4") + @GroupThreads(1) + public void drain4(final Blackhole blackhole) { + drain(blackhole); + } + + // ---- 16 producers ---- + + @Benchmark + @Group("producers16") + @GroupThreads(16) + public void enqueue16(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers16") + @GroupThreads(1) + public void drain16(final Blackhole blackhole) { + drain(blackhole); } - } - - private FlagEvalEvent nextEvent(final ProducerCursor c) { - final int i = c.cursor++; - return new FlagEvalEvent( - flagKeys[Math.floorMod(i, flagKeys.length)], - "variant-" + Math.floorMod(i, 4), - "alloc-" + Math.floorMod(i, flagKeys.length), - targetingKeys[Math.floorMod(i, targetingKeys.length)], - null, - 1_700_000_000_000L + i, - attrs); - } - - private static String[] keys(final String prefix, final int count) { - final String[] out = new String[count]; - for (int i = 0; i < count; i++) { - out[i] = prefix + i; + + private void drain(final Blackhole blackhole) { + for (int i = 0; i < DRAIN_BATCH; i++) { + final FlagEvalEvent event = writer.pollQueuedEventForTest(); + if (event == null) { + break; + } + blackhole.consume(event); + } + } + + private FlagEvalEvent nextEvent(final ProducerCursor c) { + final int i = c.cursor++; + return new FlagEvalEvent( + flagKeys[Math.floorMod(i, flagKeys.length)], + "variant-" + Math.floorMod(i, 4), + "alloc-" + Math.floorMod(i, flagKeys.length), + targetingKeys[Math.floorMod(i, targetingKeys.length)], + null, + 1_700_000_000_000L + i, + attrs); + } + + private static String[] keys(final String prefix, final int count) { + final String[] out = new String[count]; + for (int i = 0; i < count; i++) { + out[i] = prefix + i; + } + return out; } - return out; - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java index fbd467b88d2..8c4f7e8fbae 100644 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java +++ b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java @@ -50,119 +50,117 @@ @Fork(value = 1) public class FlagEvaluationHotPathBenchmark { - @Param({ - "typical/100flags_50users_10fields", - "stress/10flags_1000users_250fields", - "scale/2500flags_500users_20fields" - }) - public String profile; - - private Map attrs; - private String[] flagKeys; - private String[] targetingKeys; - private int cursor; - private FlagEvaluationWriterImpl writer; - private FlagEvaluationWriterImpl.SerializingHandlerForTest handler; - - @Setup(Level.Iteration) - public void setUp() { - final Profile p = Profile.fromName(profile); - - attrs = new HashMap<>(); - for (int i = 0; i < p.numFields; i++) { - attrs.put("field" + i, "value"); + @Param({ + "typical/100flags_50users_10fields", + "stress/10flags_1000users_250fields", + "scale/2500flags_500users_20fields" + }) + public String profile; + + private Map attrs; + private String[] flagKeys; + private String[] targetingKeys; + private int cursor; + private FlagEvaluationWriterImpl writer; + private FlagEvaluationWriterImpl.SerializingHandlerForTest handler; + + @Setup(Level.Iteration) + public void setUp() { + final Profile p = Profile.fromName(profile); + + attrs = new HashMap<>(); + for (int i = 0; i < p.numFields; i++) { + attrs.put("field" + i, "value"); + } + flagKeys = keys("bench-flag-", p.numFlags); + targetingKeys = keys("bench-user-", p.numUsers); + cursor = 0; + + final Config config = Config.get(); + final BackendApiFactory factory = new BackendApiFactory(config, null); + final Map ddContext = new HashMap<>(); + ddContext.put("service", "bench-service"); + handler = FlagEvaluationWriterImpl.createHandlerForTest( + () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), ddContext); + + // Capacity large enough that the benchmark never overflows within a measurement window. + writer = new FlagEvaluationWriterImpl( + 1 << 20, + Long.MAX_VALUE, + NANOSECONDS, + () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), + config); } - flagKeys = keys("bench-flag-", p.numFlags); - targetingKeys = keys("bench-user-", p.numUsers); - cursor = 0; - - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); - final Map ddContext = new HashMap<>(); - ddContext.put("service", "bench-service"); - handler = - FlagEvaluationWriterImpl.createHandlerForTest( - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), ddContext); - - // Capacity large enough that the benchmark never overflows within a measurement window. - writer = - new FlagEvaluationWriterImpl( - 1 << 20, - Long.MAX_VALUE, - NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); - } - - /** - * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes - * the hook's inline context snapshot - see the class javadoc. - */ - @Benchmark - public void writerEnqueue(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - writer.enqueue(event); - blackhole.consume(writer.pollQueuedEventForTest()); - blackhole.consume(event); - } - - /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ - @Benchmark - public void workerAggregate(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - handler.aggregateEvent(event); - if ((cursor % 10_000) == 0) { - handler.clearAggregationForTest(); + + /** + * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes + * the hook's inline context snapshot - see the class javadoc. + */ + @Benchmark + public void writerEnqueue(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + writer.enqueue(event); + blackhole.consume(writer.pollQueuedEventForTest()); + blackhole.consume(event); } - blackhole.consume(handler.fullTierSizeForTest()); - } - - private FlagEvalEvent nextEvent() { - final int i = cursor++; - // observeFullEvaluationData=true is required for this benchmark to mean anything: under the - // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so - // every field-count profile would collapse to the same scalar-only cost. - return new FlagEvalEvent( - flagKeys[Math.floorMod(i, flagKeys.length)], - "variant-" + Math.floorMod(i, 4), - "alloc-" + Math.floorMod(i, flagKeys.length), - targetingKeys[Math.floorMod(i, targetingKeys.length)], - null, - 1_700_000_000_000L + i, - true, - attrs); - } - - private static String[] keys(final String prefix, final int count) { - final String[] out = new String[count]; - for (int i = 0; i < count; i++) { - out[i] = prefix + i; + + /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ + @Benchmark + public void workerAggregate(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + handler.aggregateEvent(event); + if ((cursor % 10_000) == 0) { + handler.clearAggregationForTest(); + } + blackhole.consume(handler.fullTierSizeForTest()); } - return out; - } - - private static final class Profile { - private final int numFlags; - private final int numUsers; - private final int numFields; - - private Profile(final int numFlags, final int numUsers, final int numFields) { - this.numFlags = numFlags; - this.numUsers = numUsers; - this.numFields = numFields; + + private FlagEvalEvent nextEvent() { + final int i = cursor++; + // observeFullEvaluationData=true is required for this benchmark to mean anything: under the + // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so + // every field-count profile would collapse to the same scalar-only cost. + return new FlagEvalEvent( + flagKeys[Math.floorMod(i, flagKeys.length)], + "variant-" + Math.floorMod(i, 4), + "alloc-" + Math.floorMod(i, flagKeys.length), + targetingKeys[Math.floorMod(i, targetingKeys.length)], + null, + 1_700_000_000_000L + i, + true, + attrs); + } + + private static String[] keys(final String prefix, final int count) { + final String[] out = new String[count]; + for (int i = 0; i < count; i++) { + out[i] = prefix + i; + } + return out; } - private static Profile fromName(final String name) { - if ("typical/100flags_50users_10fields".equals(name)) { - return new Profile(100, 50, 10); - } - if ("stress/10flags_1000users_250fields".equals(name)) { - return new Profile(10, 1_000, 250); - } - if ("scale/2500flags_500users_20fields".equals(name)) { - return new Profile(2_500, 500, 20); - } - throw new IllegalArgumentException("unknown benchmark profile: " + name); + private static final class Profile { + private final int numFlags; + private final int numUsers; + private final int numFields; + + private Profile(final int numFlags, final int numUsers, final int numFields) { + this.numFlags = numFlags; + this.numUsers = numUsers; + this.numFields = numFields; + } + + private static Profile fromName(final String name) { + if ("typical/100flags_50users_10fields".equals(name)) { + return new Profile(100, 50, 10); + } + if ("stress/10flags_1000users_250fields".equals(name)) { + return new Profile(10, 1_000, 250); + } + if ("scale/2500flags_500users_20fields".equals(name)) { + return new Profile(2_500, 500, 20); + } + throw new IllegalArgumentException("unknown benchmark profile: " + name); + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java index f4fc35cce30..5f715b2fb17 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java @@ -37,453 +37,433 @@ import org.slf4j.LoggerFactory; final class AgentlessConfigurationSource implements ConfigurationSourceService { - private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class); - - private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH = - "/api/v2/feature-flagging/config/rules-based/server"; - private static final int MAX_ATTEMPTS = 3; - private static final int MINUTES_BETWEEN_WARNINGS = 5; - private static final long FIRST_RETRY_MIN_MILLIS = 2_000; - private static final long FIRST_RETRY_MAX_MILLIS = 10_000; - private static final long SECOND_RETRY_MIN_MILLIS = 5_000; - private static final long SECOND_RETRY_MAX_MILLIS = 30_000; - private static final double RETRY_JITTER = 0.2; - - private final HttpUrl endpoint; - private final Config config; - private final long pollIntervalMillis; - private final UfcHttpClient client; - private final ScheduledExecutorService executor; - private final RatelimitedLogger ratelimitedLogger; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean polling = new AtomicBoolean(); - private final AtomicReference pollingThread = new AtomicReference<>(); - private volatile boolean closed; - private volatile boolean started; - private volatile ScheduledFuture scheduledPoll; - private volatile String etag; - - AgentlessConfigurationSource(final Config config) { - this(config, endpoint(config)); - } - - private AgentlessConfigurationSource(final Config config, final HttpUrl endpoint) { - this( - endpoint, - config, - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - new OkHttpUfcHttpClient( - OkHttpUtils.buildHttpClient( - endpoint, - millis(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds())), - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)), - Executors.newSingleThreadScheduledExecutor( - new AgentThreadFactory(FEATURE_FLAG_CONFIGURATION_POLLER)), - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor) { - this( - endpoint, - config, - pollIntervalMillis, - client, - executor, - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor, - final RatelimitedLogger ratelimitedLogger) { - this.endpoint = endpoint; - this.config = config; - this.pollIntervalMillis = pollIntervalMillis; - this.client = client; - this.executor = executor; - this.ratelimitedLogger = ratelimitedLogger; - } - - @Override - public void init() { - synchronized (lifecycleLock) { - if (closed || started) { - return; - } - started = true; + private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class); + + private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH = + "/api/v2/feature-flagging/config/rules-based/server"; + private static final int MAX_ATTEMPTS = 3; + private static final int MINUTES_BETWEEN_WARNINGS = 5; + private static final long FIRST_RETRY_MIN_MILLIS = 2_000; + private static final long FIRST_RETRY_MAX_MILLIS = 10_000; + private static final long SECOND_RETRY_MIN_MILLIS = 5_000; + private static final long SECOND_RETRY_MAX_MILLIS = 30_000; + private static final double RETRY_JITTER = 0.2; + + private final HttpUrl endpoint; + private final Config config; + private final long pollIntervalMillis; + private final UfcHttpClient client; + private final ScheduledExecutorService executor; + private final RatelimitedLogger ratelimitedLogger; + private final Object lifecycleLock = new Object(); + private final AtomicBoolean polling = new AtomicBoolean(); + private final AtomicReference pollingThread = new AtomicReference<>(); + private volatile boolean closed; + private volatile boolean started; + private volatile ScheduledFuture scheduledPoll; + private volatile String etag; + + AgentlessConfigurationSource(final Config config) { + this(config, endpoint(config)); } - // Complete the first poll cycle on the activation thread. This lets OpenFeature provider - // initialization observe a successful retry before it checks whether configuration is ready. - // No request occurs before application code activates the provider. - pollOnceSafely(); + private AgentlessConfigurationSource(final Config config, final HttpUrl endpoint) { + this( + endpoint, + config, + millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), + new OkHttpUfcHttpClient( + OkHttpUtils.buildHttpClient( + endpoint, millis(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds())), + millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), + TimeUnit.MILLISECONDS::sleep, + () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)), + Executors.newSingleThreadScheduledExecutor(new AgentThreadFactory(FEATURE_FLAG_CONFIGURATION_POLLER)), + new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); + } - synchronized (lifecycleLock) { - if (!closed) { - scheduledPoll = - executor.scheduleWithFixedDelay( - this::pollOnceSafely, - pollIntervalMillis, + AgentlessConfigurationSource( + final HttpUrl endpoint, + final Config config, + final long pollIntervalMillis, + final UfcHttpClient client, + final ScheduledExecutorService executor) { + this( + endpoint, + config, pollIntervalMillis, - TimeUnit.MILLISECONDS); - } + client, + executor, + new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); } - } - boolean pollOnce() { - if (closed || !polling.compareAndSet(false, true)) { - return false; - } - pollingThread.set(Thread.currentThread()); - try { - return fetchAndApply(); - } finally { - pollingThread.compareAndSet(Thread.currentThread(), null); - polling.set(false); - } - } - - @Override - public void close() { - final ScheduledFuture poll; - synchronized (lifecycleLock) { - if (closed) { - return; - } - closed = true; - started = false; - poll = scheduledPoll; - scheduledPoll = null; - } - if (poll != null) { - poll.cancel(true); - } - client.cancel(); - final Thread activePollingThread = pollingThread.get(); - if (activePollingThread != null) { - activePollingThread.interrupt(); - } - executor.shutdownNow(); - } - - private void pollOnceSafely() { - try { - pollOnce(); - } catch (final RuntimeException e) { - LOGGER.debug("Unexpected error while polling Feature Flagging HTTP configuration source", e); + AgentlessConfigurationSource( + final HttpUrl endpoint, + final Config config, + final long pollIntervalMillis, + final UfcHttpClient client, + final ScheduledExecutorService executor, + final RatelimitedLogger ratelimitedLogger) { + this.endpoint = endpoint; + this.config = config; + this.pollIntervalMillis = pollIntervalMillis; + this.client = client; + this.executor = executor; + this.ratelimitedLogger = ratelimitedLogger; } - } - - private boolean fetchAndApply() { - try { - final UfcHttpResponse response = client.fetch(endpoint, config, etag); - if (closed) { - return false; - } - if (isRetryableStatus(response.status)) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", - MAX_ATTEMPTS, - response.status); - return false; - } - synchronized (lifecycleLock) { - return !closed && apply(response); - } - } catch (final IOException e) { - if (!closed) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - MAX_ATTEMPTS, - e); - } - return false; - } - } - private boolean apply(final UfcHttpResponse response) { - if (response.status == HttpURLConnection.HTTP_NOT_MODIFIED) { - return true; - } - if (response.status == HttpURLConnection.HTTP_UNAUTHORIZED - || response.status == HttpURLConnection.HTTP_FORBIDDEN) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - response.status); - return false; - } - if (response.status != HttpURLConnection.HTTP_OK || response.body == null) { - return false; - } - final ServerConfiguration configuration; - try { - configuration = JsonApiUfcResponseParser.INSTANCE.parse(response.body); - } catch (final IOException | RuntimeException e) { - LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e); - return false; - } - if (configuration == null) { - return false; - } - FeatureFlaggingGateway.dispatch(configuration); - updateEtag(response.etag); - return true; - } - - private static boolean isRetryableStatus(final int status) { - return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT - || status == 429 - || (status >= 500 && status <= 599); - } - - private void updateEtag(final String nextEtag) { - etag = isBlank(nextEtag) ? null : nextEtag; - } - - static HttpUrl endpoint(final Config config) { - final String configuredBaseUrl = config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl(); - return configuredBaseUrl == null - ? datadogApiServerDistributionEndpoint(config) - : endpointFromConfiguredBaseUrl(configuredBaseUrl); - } - - private static HttpUrl endpointFromConfiguredBaseUrl(final String configuredBaseUrl) { - final HttpUrl parsed = HttpUrl.parse(configuredBaseUrl.trim()); - if (parsed == null) { - throw new IllegalArgumentException( - "Invalid Feature Flagging HTTP configuration source URL: " + configuredBaseUrl); - } - if ("/".equals(parsed.encodedPath()) || parsed.encodedPath().isEmpty()) { - return parsed - .newBuilder() - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)) - .build(); + @Override + public void init() { + synchronized (lifecycleLock) { + if (closed || started) { + return; + } + started = true; + } + + // Complete the first poll cycle on the activation thread. This lets OpenFeature provider + // initialization observe a successful retry before it checks whether configuration is ready. + // No request occurs before application code activates the provider. + pollOnceSafely(); + + synchronized (lifecycleLock) { + if (!closed) { + scheduledPoll = executor.scheduleWithFixedDelay( + this::pollOnceSafely, pollIntervalMillis, pollIntervalMillis, TimeUnit.MILLISECONDS); + } + } } - return parsed; - } - - private static HttpUrl datadogApiServerDistributionEndpoint(final Config config) { - final HttpUrl.Builder endpoint = - new HttpUrl.Builder() - .scheme("https") - .host("ufc-server.ff-cdn." + config.getSite()) - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)); - final String env = config.getEnv(); - if (env != null && !env.isEmpty()) { - endpoint.addQueryParameter("dd_env", env); + + boolean pollOnce() { + if (closed || !polling.compareAndSet(false, true)) { + return false; + } + pollingThread.set(Thread.currentThread()); + try { + return fetchAndApply(); + } finally { + pollingThread.compareAndSet(Thread.currentThread(), null); + polling.set(false); + } } - return endpoint.build(); - } - - static long millis(final int seconds) { - return TimeUnit.SECONDS.toMillis(seconds); - } - - static long retryDelayMillis( - final long pollIntervalMillis, final int attempt, final double jitter) { - final long baseDelay; - if (attempt == 1) { - baseDelay = clamp(pollIntervalMillis / 6, FIRST_RETRY_MIN_MILLIS, FIRST_RETRY_MAX_MILLIS); - } else if (attempt == 2) { - baseDelay = clamp(pollIntervalMillis / 3, SECOND_RETRY_MIN_MILLIS, SECOND_RETRY_MAX_MILLIS); - } else { - throw new IllegalArgumentException("Unsupported Feature Flagging retry attempt: " + attempt); + + @Override + public void close() { + final ScheduledFuture poll; + synchronized (lifecycleLock) { + if (closed) { + return; + } + closed = true; + started = false; + poll = scheduledPoll; + scheduledPoll = null; + } + if (poll != null) { + poll.cancel(true); + } + client.cancel(); + final Thread activePollingThread = pollingThread.get(); + if (activePollingThread != null) { + activePollingThread.interrupt(); + } + executor.shutdownNow(); } - return Math.max(1, Math.round(baseDelay * jitter)); - } - private static long clamp(final long value, final long minimum, final long maximum) { - return Math.max(minimum, Math.min(maximum, value)); - } + private void pollOnceSafely() { + try { + pollOnce(); + } catch (final RuntimeException e) { + LOGGER.debug("Unexpected error while polling Feature Flagging HTTP configuration source", e); + } + } - interface UfcHttpClient { - UfcHttpResponse fetch(HttpUrl endpoint, Config config, String etag) throws IOException; + private boolean fetchAndApply() { + try { + final UfcHttpResponse response = client.fetch(endpoint, config, etag); + if (closed) { + return false; + } + if (isRetryableStatus(response.status)) { + ratelimitedLogger.warn( + "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", + MAX_ATTEMPTS, + response.status); + return false; + } + synchronized (lifecycleLock) { + return !closed && apply(response); + } + } catch (final IOException e) { + if (!closed) { + ratelimitedLogger.warn( + "Feature Flagging agentless endpoint request failed after {} attempts", MAX_ATTEMPTS, e); + } + return false; + } + } - void cancel(); - } + private boolean apply(final UfcHttpResponse response) { + if (response.status == HttpURLConnection.HTTP_NOT_MODIFIED) { + return true; + } + if (response.status == HttpURLConnection.HTTP_UNAUTHORIZED + || response.status == HttpURLConnection.HTTP_FORBIDDEN) { + ratelimitedLogger.warn( + "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", + response.status); + return false; + } + if (response.status != HttpURLConnection.HTTP_OK || response.body == null) { + return false; + } + final ServerConfiguration configuration; + try { + configuration = JsonApiUfcResponseParser.INSTANCE.parse(response.body); + } catch (final IOException | RuntimeException e) { + LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e); + return false; + } + if (configuration == null) { + return false; + } + FeatureFlaggingGateway.dispatch(configuration); + updateEtag(response.etag); + return true; + } - interface RetrySleeper { - void sleep(long delayMillis) throws InterruptedException; - } + private static boolean isRetryableStatus(final int status) { + return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT || status == 429 || (status >= 500 && status <= 599); + } - static final class UfcHttpResponse { - final int status; - @Nullable final String etag; - @Nullable final byte[] body; + private void updateEtag(final String nextEtag) { + etag = isBlank(nextEtag) ? null : nextEtag; + } - UfcHttpResponse(final int status, @Nullable final String etag, @Nullable final byte[] body) { - this.status = status; - this.etag = etag; - this.body = body; + static HttpUrl endpoint(final Config config) { + final String configuredBaseUrl = config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl(); + return configuredBaseUrl == null + ? datadogApiServerDistributionEndpoint(config) + : endpointFromConfiguredBaseUrl(configuredBaseUrl); } - } - static final class OkHttpUfcHttpClient implements UfcHttpClient { - private final OkHttpClient httpClient; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private final AtomicBoolean fetching = new AtomicBoolean(); - private final AtomicBoolean cancelled = new AtomicBoolean(); - private final AtomicReference activeCall = new AtomicReference<>(); - - OkHttpUfcHttpClient(final OkHttpClient httpClient) { - this( - httpClient, - TimeUnit.SECONDS.toMillis(30), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); + private static HttpUrl endpointFromConfiguredBaseUrl(final String configuredBaseUrl) { + final HttpUrl parsed = HttpUrl.parse(configuredBaseUrl.trim()); + if (parsed == null) { + throw new IllegalArgumentException( + "Invalid Feature Flagging HTTP configuration source URL: " + configuredBaseUrl); + } + if ("/".equals(parsed.encodedPath()) || parsed.encodedPath().isEmpty()) { + return parsed.newBuilder() + .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)) + .build(); + } + return parsed; } - OkHttpUfcHttpClient( - final OkHttpClient httpClient, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - this.httpClient = httpClient; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; + private static HttpUrl datadogApiServerDistributionEndpoint(final Config config) { + final HttpUrl.Builder endpoint = new HttpUrl.Builder() + .scheme("https") + .host("ufc-server.ff-cdn." + config.getSite()) + .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)); + final String env = config.getEnv(); + if (env != null && !env.isEmpty()) { + endpoint.addQueryParameter("dd_env", env); + } + return endpoint.build(); } - @Override - public UfcHttpResponse fetch(final HttpUrl endpoint, final Config config, final String etag) - throws IOException { - final Map headers = new HashMap<>(); - if (etag != null) { - headers.put("If-None-Match", etag); - } - // Leave Accept-Encoding unset so OkHttp negotiates gzip and transparently decompresses it. - final Request request = - prepareRequest(endpoint, headers, config, isDatadogManagedEndpoint(endpoint, config)) - .get() - .build(); - if (!fetching.compareAndSet(false, true)) { - throw new IllegalStateException("Feature Flagging HTTP request already in flight"); - } - if (cancelled.get()) { - fetching.set(false); - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - final HttpRetryPolicy.Factory retryPolicyFactory = - new HttpRetryPolicy.Factory(0, 0, 0) { - @Override - public HttpRetryPolicy create() { - return new AgentlessRetryPolicy( - cancelled, pollIntervalMillis, retrySleeper, jitter); - } - }; - final Call.Factory callFactory = - retryRequest -> { - final Call call = httpClient.newCall(retryRequest); - activeCall.set(call); - if (cancelled.get()) { - call.cancel(); - } - return call; - }; - return sendWithRetries( - callFactory, - retryPolicyFactory, - request, - response -> { - final int status = response.code(); - final String responseEtag = response.header("ETag"); - try (ResponseBody responseBody = response.body()) { - final byte[] body = responseBody != null ? responseBody.bytes() : null; - return new UfcHttpResponse(status, responseEtag, body); - } - }); - } finally { - activeCall.set(null); - fetching.set(false); - } + static long millis(final int seconds) { + return TimeUnit.SECONDS.toMillis(seconds); } - @Override - public void cancel() { - cancelled.set(true); - final Call call = activeCall.get(); - if (call != null) { - call.cancel(); - } + static long retryDelayMillis(final long pollIntervalMillis, final int attempt, final double jitter) { + final long baseDelay; + if (attempt == 1) { + baseDelay = clamp(pollIntervalMillis / 6, FIRST_RETRY_MIN_MILLIS, FIRST_RETRY_MAX_MILLIS); + } else if (attempt == 2) { + baseDelay = clamp(pollIntervalMillis / 3, SECOND_RETRY_MIN_MILLIS, SECOND_RETRY_MAX_MILLIS); + } else { + throw new IllegalArgumentException("Unsupported Feature Flagging retry attempt: " + attempt); + } + return Math.max(1, Math.round(baseDelay * jitter)); } - private static boolean isDatadogManagedEndpoint(final HttpUrl endpoint, final Config config) { - return config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() == null - && endpoint.isHttps() - && endpoint.host().equalsIgnoreCase("ufc-server.ff-cdn." + config.getSite()); + private static long clamp(final long value, final long minimum, final long maximum) { + return Math.max(minimum, Math.min(maximum, value)); } - } - - @SuppressFBWarnings( - value = "AT_NONATOMIC_OPERATIONS_ON_SHARED_VARIABLE", - justification = - "Each retry policy belongs to one synchronous HTTP request and is confined to one thread") - static final class AgentlessRetryPolicy extends HttpRetryPolicy { - private final AtomicBoolean cancelled; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private int retriesLeft = MAX_ATTEMPTS - 1; - private int retryAttempt; - - AgentlessRetryPolicy( - final AtomicBoolean cancelled, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - super(0, 0, 0, false); - this.cancelled = cancelled; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; + + interface UfcHttpClient { + UfcHttpResponse fetch(HttpUrl endpoint, Config config, String etag) throws IOException; + + void cancel(); } - @Override - public boolean shouldRetry(final Exception exception) { - return exception instanceof IOException - && !Thread.currentThread().isInterrupted() - && reserveRetry(); + interface RetrySleeper { + void sleep(long delayMillis) throws InterruptedException; } - @Override - public boolean shouldRetry(@Nullable final Response response) { - return response != null && isRetryableStatus(response.code()) && reserveRetry(); + static final class UfcHttpResponse { + final int status; + + @Nullable + final String etag; + + @Nullable + final byte[] body; + + UfcHttpResponse(final int status, @Nullable final String etag, @Nullable final byte[] body) { + this.status = status; + this.etag = etag; + this.body = body; + } } - private boolean reserveRetry() { - if (cancelled.get() || retriesLeft == 0) { - return false; - } - retriesLeft--; - retryAttempt++; - return true; + static final class OkHttpUfcHttpClient implements UfcHttpClient { + private final OkHttpClient httpClient; + private final long pollIntervalMillis; + private final RetrySleeper retrySleeper; + private final DoubleSupplier jitter; + private final AtomicBoolean fetching = new AtomicBoolean(); + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final AtomicReference activeCall = new AtomicReference<>(); + + OkHttpUfcHttpClient(final OkHttpClient httpClient) { + this( + httpClient, + TimeUnit.SECONDS.toMillis(30), + TimeUnit.MILLISECONDS::sleep, + () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); + } + + OkHttpUfcHttpClient( + final OkHttpClient httpClient, + final long pollIntervalMillis, + final RetrySleeper retrySleeper, + final DoubleSupplier jitter) { + this.httpClient = httpClient; + this.pollIntervalMillis = pollIntervalMillis; + this.retrySleeper = retrySleeper; + this.jitter = jitter; + } + + @Override + public UfcHttpResponse fetch(final HttpUrl endpoint, final Config config, final String etag) + throws IOException { + final Map headers = new HashMap<>(); + if (etag != null) { + headers.put("If-None-Match", etag); + } + // Leave Accept-Encoding unset so OkHttp negotiates gzip and transparently decompresses it. + final Request request = prepareRequest( + endpoint, headers, config, isDatadogManagedEndpoint(endpoint, config)) + .get() + .build(); + if (!fetching.compareAndSet(false, true)) { + throw new IllegalStateException("Feature Flagging HTTP request already in flight"); + } + if (cancelled.get()) { + fetching.set(false); + throw new InterruptedIOException("Feature Flagging HTTP client is closed"); + } + try { + final HttpRetryPolicy.Factory retryPolicyFactory = new HttpRetryPolicy.Factory(0, 0, 0) { + @Override + public HttpRetryPolicy create() { + return new AgentlessRetryPolicy(cancelled, pollIntervalMillis, retrySleeper, jitter); + } + }; + final Call.Factory callFactory = retryRequest -> { + final Call call = httpClient.newCall(retryRequest); + activeCall.set(call); + if (cancelled.get()) { + call.cancel(); + } + return call; + }; + return sendWithRetries(callFactory, retryPolicyFactory, request, response -> { + final int status = response.code(); + final String responseEtag = response.header("ETag"); + try (ResponseBody responseBody = response.body()) { + final byte[] body = responseBody != null ? responseBody.bytes() : null; + return new UfcHttpResponse(status, responseEtag, body); + } + }); + } finally { + activeCall.set(null); + fetching.set(false); + } + } + + @Override + public void cancel() { + cancelled.set(true); + final Call call = activeCall.get(); + if (call != null) { + call.cancel(); + } + } + + private static boolean isDatadogManagedEndpoint(final HttpUrl endpoint, final Config config) { + return config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() == null + && endpoint.isHttps() + && endpoint.host().equalsIgnoreCase("ufc-server.ff-cdn." + config.getSite()); + } } - @Override - public void backoff() throws IOException { - if (cancelled.get()) { - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - retrySleeper.sleep( - retryDelayMillis(pollIntervalMillis, retryAttempt, jitter.getAsDouble())); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new InterruptedIOException("Feature Flagging retry interrupted"); - } + @SuppressFBWarnings( + value = "AT_NONATOMIC_OPERATIONS_ON_SHARED_VARIABLE", + justification = "Each retry policy belongs to one synchronous HTTP request and is confined to one thread") + static final class AgentlessRetryPolicy extends HttpRetryPolicy { + private final AtomicBoolean cancelled; + private final long pollIntervalMillis; + private final RetrySleeper retrySleeper; + private final DoubleSupplier jitter; + private int retriesLeft = MAX_ATTEMPTS - 1; + private int retryAttempt; + + AgentlessRetryPolicy( + final AtomicBoolean cancelled, + final long pollIntervalMillis, + final RetrySleeper retrySleeper, + final DoubleSupplier jitter) { + super(0, 0, 0, false); + this.cancelled = cancelled; + this.pollIntervalMillis = pollIntervalMillis; + this.retrySleeper = retrySleeper; + this.jitter = jitter; + } + + @Override + public boolean shouldRetry(final Exception exception) { + return exception instanceof IOException && !Thread.currentThread().isInterrupted() && reserveRetry(); + } + + @Override + public boolean shouldRetry(@Nullable final Response response) { + return response != null && isRetryableStatus(response.code()) && reserveRetry(); + } + + private boolean reserveRetry() { + if (cancelled.get() || retriesLeft == 0) { + return false; + } + retriesLeft--; + retryAttempt++; + return true; + } + + @Override + public void backoff() throws IOException { + if (cancelled.get()) { + throw new InterruptedIOException("Feature Flagging HTTP client is closed"); + } + try { + retrySleeper.sleep(retryDelayMillis(pollIntervalMillis, retryAttempt, jitter.getAsDouble())); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Feature Flagging retry interrupted"); + } + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java index 769ebfd1dd1..fcbf588ba33 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java @@ -16,86 +16,81 @@ /** Sends Feature Flag events through a local EVP proxy, with a safe direct intake fallback. */ final class AgentlessFeatureFlagBackendApi implements BackendApi { - private static final Logger LOGGER = - LoggerFactory.getLogger(AgentlessFeatureFlagBackendApi.class); + private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessFeatureFlagBackendApi.class); - private final BackendApi proxyApi; - private final Supplier directApiSupplier; - private final String eventType; - private volatile BackendApi activeApi; - private volatile boolean directApiCreationAttempted; + private final BackendApi proxyApi; + private final Supplier directApiSupplier; + private final String eventType; + private volatile BackendApi activeApi; + private volatile boolean directApiCreationAttempted; - AgentlessFeatureFlagBackendApi( - final BackendApi proxyApi, - final Supplier directApiSupplier, - final String eventType) { - this.proxyApi = proxyApi; - this.directApiSupplier = directApiSupplier; - this.eventType = eventType; - this.activeApi = proxyApi; - } + AgentlessFeatureFlagBackendApi( + final BackendApi proxyApi, final Supplier directApiSupplier, final String eventType) { + this.proxyApi = proxyApi; + this.directApiSupplier = directApiSupplier; + this.eventType = eventType; + this.activeApi = proxyApi; + } - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - final BackendApi selectedApi = activeApi; - try { - return selectedApi.post( - uri, requestBody, responseParser, requestListener, requestCompression); - } catch (final IOException exception) { - if (selectedApi != proxyApi || !isDefinitiveRejection(exception)) { - throw exception; - } + @Override + public T post( + final String uri, + final RequestBody requestBody, + final IOThrowingFunction responseParser, + @Nullable final OkHttpUtils.CustomListener requestListener, + final boolean requestCompression) + throws IOException { + final BackendApi selectedApi = activeApi; + try { + return selectedApi.post(uri, requestBody, responseParser, requestListener, requestCompression); + } catch (final IOException exception) { + if (selectedApi != proxyApi || !isDefinitiveRejection(exception)) { + throw exception; + } - final BackendApi directApi = getOrCreateDirectApi(); - if (directApi == null) { - throw exception; - } - return directApi.post(uri, requestBody, responseParser, requestListener, requestCompression); + final BackendApi directApi = getOrCreateDirectApi(); + if (directApi == null) { + throw exception; + } + return directApi.post(uri, requestBody, responseParser, requestListener, requestCompression); + } } - } - @Nullable - private BackendApi getOrCreateDirectApi() { - final BackendApi selectedApi = activeApi; - if (selectedApi != proxyApi) { - return selectedApi; - } + @Nullable + private BackendApi getOrCreateDirectApi() { + final BackendApi selectedApi = activeApi; + if (selectedApi != proxyApi) { + return selectedApi; + } - synchronized (this) { - final BackendApi currentApi = activeApi; - if (currentApi != proxyApi) { - return currentApi; - } - if (directApiCreationAttempted) { - return null; - } + synchronized (this) { + final BackendApi currentApi = activeApi; + if (currentApi != proxyApi) { + return currentApi; + } + if (directApiCreationAttempted) { + return null; + } - final BackendApi directApi = directApiSupplier.get(); - if (directApi != null) { - LOGGER.debug( - "Switching Feature Flagging {} delivery from the local EVP proxy to direct intake", - eventType); - activeApi = directApi; - } - directApiCreationAttempted = true; - return directApi; + final BackendApi directApi = directApiSupplier.get(); + if (directApi != null) { + LOGGER.debug( + "Switching Feature Flagging {} delivery from the local EVP proxy to direct intake", eventType); + activeApi = directApi; + } + directApiCreationAttempted = true; + return directApi; + } } - } - private static boolean isDefinitiveRejection(final IOException exception) { - if (exception instanceof ConnectException) { - return true; - } - if (exception instanceof HttpResponseException) { - final int statusCode = ((HttpResponseException) exception).getStatusCode(); - return statusCode == 403 || statusCode == 404 || statusCode == 405; + private static boolean isDefinitiveRejection(final IOException exception) { + if (exception instanceof ConnectException) { + return true; + } + if (exception instanceof HttpResponseException) { + final int statusCode = ((HttpResponseException) exception).getStatusCode(); + return statusCode == 403 || statusCode == 404 || statusCode == 405; + } + return false; } - return false; - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java index 83495545717..f23116f9ba1 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java @@ -4,7 +4,7 @@ public interface ConfigurationSourceService extends Closeable { - void init(); + void init(); - void close(); + void close(); } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java index 7125d009a69..d3904379e85 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java @@ -5,61 +5,61 @@ public interface ExposureCache { - boolean add(ExposureEvent event); + boolean add(ExposureEvent event); - Value get(Key key); + Value get(Key key); - int size(); + int size(); - final class Key { - public final String flag; - public final String subject; + final class Key { + public final String flag; + public final String subject; - public Key(final ExposureEvent event) { - this.flag = event.flag == null ? null : event.flag.key; - this.subject = event.subject == null ? null : event.subject.id; - } + public Key(final ExposureEvent event) { + this.flag = event.flag == null ? null : event.flag.key; + this.subject = event.subject == null ? null : event.subject.id; + } - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Key key = (Key) o; - return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); - } + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Key key = (Key) o; + return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); + } - @Override - public int hashCode() { - return Objects.hash(flag, subject); + @Override + public int hashCode() { + return Objects.hash(flag, subject); + } } - } - final class Value { - public final String variant; - public final String allocation; - public final Integer serialId; + final class Value { + public final String variant; + public final String allocation; + public final Integer serialId; - public Value(final ExposureEvent event) { - this.variant = event.variant == null ? null : event.variant.key; - this.allocation = event.allocation == null ? null : event.allocation.key; - this.serialId = event.serial_id; - } + public Value(final ExposureEvent event) { + this.variant = event.variant == null ? null : event.variant.key; + this.allocation = event.allocation == null ? null : event.allocation.key; + this.serialId = event.serial_id; + } - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Value value = (Value) o; - return Objects.equals(variant, value.variant) - && Objects.equals(allocation, value.allocation) - && Objects.equals(serialId, value.serialId); - } + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Value value = (Value) o; + return Objects.equals(variant, value.variant) + && Objects.equals(allocation, value.allocation) + && Objects.equals(serialId, value.serialId); + } - @Override - public int hashCode() { - return Objects.hash(variant, allocation, serialId); + @Override + public int hashCode() { + return Objects.hash(variant, allocation, serialId); + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java index 4c9fa80ea0d..de3f3c49358 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java @@ -8,7 +8,7 @@ */ public interface ExposureWriter extends AutoCloseable, ExposureListener { - void init(); + void init(); - void close(); + void close(); } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index fcd50e5dc34..4a65d24fe34 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -22,189 +22,182 @@ public class ExposureWriterImpl implements ExposureWriter { - private static final Logger LOGGER = LoggerFactory.getLogger(ExposureWriterImpl.class); - private static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements - private static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; - private static final int FLUSH_THRESHOLD = 100; - private static final String EXPOSURES_ROUTE = "exposures"; - - private final MessagePassingBlockingQueue queue; - private final Thread serializerThread; - - public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config config) { - this(DEFAULT_CAPACITY, DEFAULT_FLUSH_INTERVAL_IN_SECONDS, SECONDS, sco, config); - } - - ExposureWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final SharedCommunicationObjects sco, - final Config config) { - this( - capacity, - flushInterval, - timeUnit, - new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.EXPOSURE), - config); - } - - ExposureWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final FeatureFlagBackendApiFactory backendApiFactory, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); - final ExposureSerializingHandler serializer = - new ExposureSerializingHandler( - backendApiFactory, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - this::close); - this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); - } - - @Override - public void init() { - FeatureFlaggingGateway.addExposureListener(this); - this.serializerThread.start(); - } - - @Override - public void close() { - FeatureFlaggingGateway.removeExposureListener(this); - if (this.serializerThread.isAlive()) { - this.serializerThread.interrupt(); - } - } + private static final Logger LOGGER = LoggerFactory.getLogger(ExposureWriterImpl.class); + private static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements + private static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; + private static final int FLUSH_THRESHOLD = 100; + private static final String EXPOSURES_ROUTE = "exposures"; - @Override - public void accept(final ExposureEvent event) { - queue.offer(event); - } + private final MessagePassingBlockingQueue queue; + private final Thread serializerThread; - @VisibleForTesting - boolean isSerializerThreadAlive() { - return serializerThread.isAlive(); - } + public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config config) { + this(DEFAULT_CAPACITY, DEFAULT_FLUSH_INTERVAL_IN_SECONDS, SECONDS, sco, config); + } - @VisibleForTesting - int queueSize() { - return queue.size(); - } + ExposureWriterImpl( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final SharedCommunicationObjects sco, + final Config config) { + this( + capacity, + flushInterval, + timeUnit, + new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.EXPOSURE), + config); + } - private static class ExposureSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; - private final long ticksRequiredToFlush; - private long lastTicks; - - private final FeatureFlagEvpPublisher evpPublisher; - private final Map context; - private final ExposureCache cache; - - private final List buffer = new ArrayList<>(); - private final Runnable errorCallback; - - ExposureSerializingHandler( - final FeatureFlagBackendApiFactory backendApiFactory, - final MessagePassingBlockingQueue queue, - final long flushInterval, - final TimeUnit timeUnit, - final Map context, - final Runnable errorCallback) { - this.queue = queue; - this.cache = new LRUExposureCache(queue.capacity()); - this.evpPublisher = - new FeatureFlagEvpPublisher<>(backendApiFactory::create, ExposuresRequest.class); - this.context = context; - - this.lastTicks = System.nanoTime(); - this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - - this.errorCallback = errorCallback; - - LOGGER.debug("starting exposure serializer"); + ExposureWriterImpl( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final FeatureFlagBackendApiFactory backendApiFactory, + final Config config) { + this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); + final ExposureSerializingHandler serializer = new ExposureSerializingHandler( + backendApiFactory, queue, flushInterval, timeUnit, FeatureFlagEvpContext.from(config), this::close); + this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); } @Override - public void run() { - if (!evpPublisher.start()) { - errorCallback.run(); - LOGGER.warn("Feature Flagging exposure delivery is disabled"); - return; - } - try { - runDutyCycle(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - LOGGER.debug("exposure processor worker exited. submitting exposures stopped."); + public void init() { + FeatureFlaggingGateway.addExposureListener(this); + this.serializerThread.start(); } - private void runDutyCycle() throws InterruptedException { - final Thread thread = Thread.currentThread(); - while (!thread.isInterrupted()) { - ExposureEvent event; - while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { - if (addToBuffer(event)) { - consumeBatch(); - break; - } + @Override + public void close() { + FeatureFlaggingGateway.removeExposureListener(this); + if (this.serializerThread.isAlive()) { + this.serializerThread.interrupt(); } - flushIfNecessary(); - } } - private void consumeBatch() { - queue.drain(this::addToBuffer, queue.size()); + @Override + public void accept(final ExposureEvent event) { + queue.offer(event); } - /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ - private boolean addToBuffer(final ExposureEvent event) { - if (cache.add(event)) { - buffer.add(event); - return true; - } - return false; + @VisibleForTesting + boolean isSerializerThreadAlive() { + return serializerThread.isAlive(); } - protected void flushIfNecessary() { - if (buffer.isEmpty()) { - return; - } - if (shouldFlush()) { - final byte[] payload; - try { - final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); - payload = evpPublisher.serialize(exposures); - } catch (RuntimeException e) { - LOGGER.error(EXCLUDE_TELEMETRY, "Could not serialize exposures; dropping batch", e); - this.buffer.clear(); - return; + @VisibleForTesting + int queueSize() { + return queue.size(); + } + + private static class ExposureSerializingHandler implements Runnable { + private final MessagePassingBlockingQueue queue; + private final long ticksRequiredToFlush; + private long lastTicks; + + private final FeatureFlagEvpPublisher evpPublisher; + private final Map context; + private final ExposureCache cache; + + private final List buffer = new ArrayList<>(); + private final Runnable errorCallback; + + ExposureSerializingHandler( + final FeatureFlagBackendApiFactory backendApiFactory, + final MessagePassingBlockingQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context, + final Runnable errorCallback) { + this.queue = queue; + this.cache = new LRUExposureCache(queue.capacity()); + this.evpPublisher = new FeatureFlagEvpPublisher<>(backendApiFactory::create, ExposuresRequest.class); + this.context = context; + + this.lastTicks = System.nanoTime(); + this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); + + this.errorCallback = errorCallback; + + LOGGER.debug("starting exposure serializer"); } - try { - evpPublisher.post(EXPOSURES_ROUTE, payload); - } catch (Exception e) { - LOGGER.debug("Could not submit exposures", e); - } finally { - // Best-effort delivery must not retry an ambiguously accepted batch. A later definitive - // proxy rejection could otherwise replay the same exposures through direct intake. - this.buffer.clear(); + + @Override + public void run() { + if (!evpPublisher.start()) { + errorCallback.run(); + LOGGER.warn("Feature Flagging exposure delivery is disabled"); + return; + } + try { + runDutyCycle(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + LOGGER.debug("exposure processor worker exited. submitting exposures stopped."); } - } - } - private boolean shouldFlush() { - long nanoTime = System.nanoTime(); - long ticks = nanoTime - lastTicks; - if (ticks > ticksRequiredToFlush || queue.size() >= FLUSH_THRESHOLD) { - lastTicks = nanoTime; - return true; - } - return false; + private void runDutyCycle() throws InterruptedException { + final Thread thread = Thread.currentThread(); + while (!thread.isInterrupted()) { + ExposureEvent event; + while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { + if (addToBuffer(event)) { + consumeBatch(); + break; + } + } + flushIfNecessary(); + } + } + + private void consumeBatch() { + queue.drain(this::addToBuffer, queue.size()); + } + + /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ + private boolean addToBuffer(final ExposureEvent event) { + if (cache.add(event)) { + buffer.add(event); + return true; + } + return false; + } + + protected void flushIfNecessary() { + if (buffer.isEmpty()) { + return; + } + if (shouldFlush()) { + final byte[] payload; + try { + final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); + payload = evpPublisher.serialize(exposures); + } catch (RuntimeException e) { + LOGGER.error(EXCLUDE_TELEMETRY, "Could not serialize exposures; dropping batch", e); + this.buffer.clear(); + return; + } + try { + evpPublisher.post(EXPOSURES_ROUTE, payload); + } catch (Exception e) { + LOGGER.debug("Could not submit exposures", e); + } finally { + // Best-effort delivery must not retry an ambiguously accepted batch. A later definitive + // proxy rejection could otherwise replay the same exposures through direct intake. + this.buffer.clear(); + } + } + } + + private boolean shouldFlush() { + long nanoTime = System.nanoTime(); + long ticks = nanoTime - lastTicks; + if (ticks > ticksRequiredToFlush || queue.size() >= FLUSH_THRESHOLD) { + lastTicks = nanoTime; + return true; + } + return false; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java index 451b903ecc7..5c3bd66ccc5 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java @@ -15,86 +15,80 @@ /** Selects the transport for Feature Flagging events. */ final class FeatureFlagBackendApiFactory { - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagBackendApiFactory.class); + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagBackendApiFactory.class); - private final Config config; - private final BackendApiFactory backendApiFactory; - private final FeatureFlagEventType eventType; + private final Config config; + private final BackendApiFactory backendApiFactory; + private final FeatureFlagEventType eventType; - FeatureFlagBackendApiFactory( - final Config config, - final SharedCommunicationObjects sharedCommunicationObjects, - final FeatureFlagEventType eventType) { - this(config, new BackendApiFactory(config, sharedCommunicationObjects), eventType); - } - - FeatureFlagBackendApiFactory( - final Config config, - final BackendApiFactory backendApiFactory, - final FeatureFlagEventType eventType) { - this.config = config; - this.backendApiFactory = backendApiFactory; - this.eventType = eventType; - } - - @Nullable - BackendApi create() { - final boolean directFallbackAvailable = - CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) - && hasDirectCredentials(); - final BackendApi proxyApi = - directFallbackAvailable - ? backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, - eventType.responseCompressionEnabled(), - HttpRetryPolicy.Factory.NEVER_RETRY) - : backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); - if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - if (proxyApi == null) { - LOGGER.warn( - "Feature Flagging {} delivery is disabled because the local Agent does not support the EVP proxy", - eventType.logName()); - } - return proxyApi; + FeatureFlagBackendApiFactory( + final Config config, + final SharedCommunicationObjects sharedCommunicationObjects, + final FeatureFlagEventType eventType) { + this(config, new BackendApiFactory(config, sharedCommunicationObjects), eventType); } - if (proxyApi != null) { - if (directFallbackAvailable) { - return new AgentlessFeatureFlagBackendApi( - proxyApi, this::createDirectApi, eventType.logName()); - } - return proxyApi; + FeatureFlagBackendApiFactory( + final Config config, final BackendApiFactory backendApiFactory, final FeatureFlagEventType eventType) { + this.config = config; + this.backendApiFactory = backendApiFactory; + this.eventType = eventType; } - final BackendApi directApi = createDirectApi(); - if (directApi != null) { - return directApi; - } + @Nullable + BackendApi create() { + final boolean directFallbackAvailable = + CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) + && hasDirectCredentials(); + final BackendApi proxyApi = directFallbackAvailable + ? backendApiFactory.createEvpProxyApi( + Intake.EVENT_PLATFORM, + eventType.responseCompressionEnabled(), + HttpRetryPolicy.Factory.NEVER_RETRY) + : backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); + if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { + if (proxyApi == null) { + LOGGER.warn( + "Feature Flagging {} delivery is disabled because the local Agent does not support the EVP proxy", + eventType.logName()); + } + return proxyApi; + } - LOGGER.warn( - "Feature Flagging {} delivery is disabled because no compatible local EVP proxy or direct intake credentials are available", - eventType.logName()); - return null; - } + if (proxyApi != null) { + if (directFallbackAvailable) { + return new AgentlessFeatureFlagBackendApi(proxyApi, this::createDirectApi, eventType.logName()); + } + return proxyApi; + } - private boolean hasDirectCredentials() { - final String apiKey = config.getApiKey(); - return apiKey != null && !apiKey.isEmpty(); - } + final BackendApi directApi = createDirectApi(); + if (directApi != null) { + return directApi; + } - @Nullable - private BackendApi createDirectApi() { - if (!hasDirectCredentials()) { - return null; + LOGGER.warn( + "Feature Flagging {} delivery is disabled because no compatible local EVP proxy or direct intake credentials are available", + eventType.logName()); + return null; + } + + private boolean hasDirectCredentials() { + final String apiKey = config.getApiKey(); + return apiKey != null && !apiKey.isEmpty(); } - try { - return backendApiFactory.createDirectIntakeApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled(), false); - } catch (final IllegalArgumentException exception) { - LOGGER.debug( - "Cannot configure direct Feature Flagging {} delivery", eventType.logName(), exception); - return null; + + @Nullable + private BackendApi createDirectApi() { + if (!hasDirectCredentials()) { + return null; + } + try { + return backendApiFactory.createDirectIntakeApi( + Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled(), false); + } catch (final IllegalArgumentException exception) { + LOGGER.debug("Cannot configure direct Feature Flagging {} delivery", eventType.logName(), exception); + return null; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java index dee8b424fa3..58ac329991c 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java @@ -2,25 +2,25 @@ /** Defines event-specific transport behavior for Feature Flag delivery. */ enum FeatureFlagEventType { - // Keep the established exposure transport behavior for compatibility. - EXPOSURE("exposure", true), + // Keep the established exposure transport behavior for compatibility. + EXPOSURE("exposure", true), - // Flag evaluation writers ignore successful response bodies, so gzip negotiation adds no value. - FLAG_EVALUATION("flag evaluation", false); + // Flag evaluation writers ignore successful response bodies, so gzip negotiation adds no value. + FLAG_EVALUATION("flag evaluation", false); - private final String logName; - private final boolean responseCompressionEnabled; + private final String logName; + private final boolean responseCompressionEnabled; - FeatureFlagEventType(final String logName, final boolean responseCompressionEnabled) { - this.logName = logName; - this.responseCompressionEnabled = responseCompressionEnabled; - } + FeatureFlagEventType(final String logName, final boolean responseCompressionEnabled) { + this.logName = logName; + this.responseCompressionEnabled = responseCompressionEnabled; + } - String logName() { - return logName; - } + String logName() { + return logName; + } - boolean responseCompressionEnabled() { - return responseCompressionEnabled; - } + boolean responseCompressionEnabled() { + return responseCompressionEnabled; + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java index c964efa6c7f..ccea00a7619 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java @@ -6,17 +6,17 @@ final class FeatureFlagEvpContext { - private FeatureFlagEvpContext() {} + private FeatureFlagEvpContext() {} - static Map from(final Config config) { - final Map context = new HashMap<>(4); - context.put("service", config.getServiceName() == null ? "unknown" : config.getServiceName()); - if (config.getEnv() != null) { - context.put("env", config.getEnv()); + static Map from(final Config config) { + final Map context = new HashMap<>(4); + context.put("service", config.getServiceName() == null ? "unknown" : config.getServiceName()); + if (config.getEnv() != null) { + context.put("env", config.getEnv()); + } + if (config.getVersion() != null) { + context.put("version", config.getVersion()); + } + return context; } - if (config.getVersion() != null) { - context.put("version", config.getVersion()); - } - return context; - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java index 8a024b52f43..d85c039428f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java @@ -13,59 +13,54 @@ final class FeatureFlagEvpPublisher { - private static final MediaType JSON = MediaType.parse("application/json"); + private static final MediaType JSON = MediaType.parse("application/json"); - private final Supplier backendApiSupplier; - private final JsonAdapter jsonAdapter; - private BackendApi evp; + private final Supplier backendApiSupplier; + private final JsonAdapter jsonAdapter; + private BackendApi evp; - FeatureFlagEvpPublisher(final BackendApiFactory backendApiFactory, final Class requestType) { - this(backendApiFactory, requestType, true); - } + FeatureFlagEvpPublisher(final BackendApiFactory backendApiFactory, final Class requestType) { + this(backendApiFactory, requestType, true); + } - FeatureFlagEvpPublisher( - final BackendApiFactory backendApiFactory, - final Class requestType, - final boolean responseCompression) { - this( - () -> backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM, responseCompression), - requestType); - } + FeatureFlagEvpPublisher( + final BackendApiFactory backendApiFactory, final Class requestType, final boolean responseCompression) { + this(() -> backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM, responseCompression), requestType); + } - FeatureFlagEvpPublisher( - final Supplier backendApiSupplier, final Class requestType) { - this.backendApiSupplier = backendApiSupplier; - this.jsonAdapter = new Moshi.Builder().build().adapter(requestType); - } + FeatureFlagEvpPublisher(final Supplier backendApiSupplier, final Class requestType) { + this.backendApiSupplier = backendApiSupplier; + this.jsonAdapter = new Moshi.Builder().build().adapter(requestType); + } - boolean start() { - if (evp == null) { - evp = backendApiSupplier.get(); + boolean start() { + if (evp == null) { + evp = backendApiSupplier.get(); + } + return evp != null; } - return evp != null; - } - void post(final String route, final T request) throws IOException { - post(route, serialize(request)); - } + void post(final String route, final T request) throws IOException { + post(route, serialize(request)); + } - byte[] serialize(final T request) { - return utf8Bytes(jsonAdapter.toJson(request)); - } + byte[] serialize(final T request) { + return utf8Bytes(jsonAdapter.toJson(request)); + } - void post(final String route, final byte[] json) throws IOException { - if (!start()) { - throw new IllegalStateException("EVP Proxy not available"); + void post(final String route, final byte[] json) throws IOException { + if (!start()) { + throw new IllegalStateException("EVP Proxy not available"); + } + final RequestBody requestBody = RequestBody.create(JSON, json); + evp.post(route, requestBody, stream -> null, null, false); } - final RequestBody requestBody = RequestBody.create(JSON, json); - evp.post(route, requestBody, stream -> null, null, false); - } - static byte[] utf8Bytes(final String json) { - try { - return json.getBytes("UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new AssertionError("UTF-8 must be available", e); + static byte[] utf8Bytes(final String json) { + try { + return json.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new AssertionError("UTF-8 must be available", e); + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java index 5d0eeaa0e82..ab6db189f7f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java @@ -13,442 +13,425 @@ final class FlagEvaluationAggregator { - // Design assumptions — document the scale we sized for - static final int EXPECTED_FLAG_COUNT = 2_500; - static final int EXPECTED_FULL_BUCKETS_PER_FLAG = 50; - static final int EXPECTED_USERS_PER_FLAG = 1_000; - static final int PER_FLAG_HEADROOM_MULTIPLIER = 10; - static final int EXPECTED_DEGRADED_BUCKETS_PER_FLAG = 10; - - // Derived sizing — show the math behind the bucket caps below - static final int FULL_BUCKET_SIZING_BASIS = - EXPECTED_FLAG_COUNT * EXPECTED_FULL_BUCKETS_PER_FLAG; // 125_000 - static final int PER_FLAG_BUCKET_SIZING_BASIS = - PER_FLAG_HEADROOM_MULTIPLIER * EXPECTED_USERS_PER_FLAG; // 10_000 - static final int DEGRADED_BUCKET_SIZING_BASIS = - EXPECTED_FLAG_COUNT * EXPECTED_DEGRADED_BUCKETS_PER_FLAG; // 25_000 - - // Enforced bucket caps - static final int GLOBAL_CAP = 131_072; // nearest power of two above FULL_BUCKET_SIZING_BASIS - static final int PER_FLAG_CAP = PER_FLAG_BUCKET_SIZING_BASIS; - static final int DEGRADED_CAP = 32_768; // nearest power of two above DEGRADED_BUCKET_SIZING_BASIS - - private static final byte CTX_TAG_STRING = 's'; - private static final byte CTX_TAG_BOOL = 'b'; - private static final byte CTX_TAG_INT = 'i'; - private static final byte CTX_TAG_LONG = 'l'; - private static final byte CTX_TAG_FLOAT = 'f'; - private static final byte CTX_TAG_DOUBLE = 'd'; - private static final byte CTX_TAG_OTHER = 'o'; - - final Map fullTier = new HashMap<>(); - final Map degradedTier = new HashMap<>(); - final Map perFlagCount = new HashMap<>(); - final AtomicLong droppedDegradedOverflow = new AtomicLong(0); - final AtomicInteger globalFullCount = new AtomicInteger(0); - - void aggregate(final FlagEvalEvent event) { - final boolean isDefault = event.variant == null; - final boolean observeFullEvaluationData = event.observeFullEvaluationData; - // On the protected path the context is dropped on emit, so it must not fragment buckets or be - // stored — otherwise a high-cardinality field (request_id, timestamp) blows out PER_FLAG_CAP - // and spills every subsequent evaluation into the degraded tier. - final Map prunedAttrs = observeFullEvaluationData ? event.attrs : null; - final String ctxKey = observeFullEvaluationData ? canonicalContextKey(prunedAttrs) : ""; - final FullKey fullKey = buildFullKey(event, ctxKey); - - EvalBucket bucket = fullTier.get(fullKey); - if (bucket != null) { - bucket.merge(event.evalTimeMs, isDefault); - bucket.observeFullEvaluationData &= observeFullEvaluationData; - return; + // Design assumptions — document the scale we sized for + static final int EXPECTED_FLAG_COUNT = 2_500; + static final int EXPECTED_FULL_BUCKETS_PER_FLAG = 50; + static final int EXPECTED_USERS_PER_FLAG = 1_000; + static final int PER_FLAG_HEADROOM_MULTIPLIER = 10; + static final int EXPECTED_DEGRADED_BUCKETS_PER_FLAG = 10; + + // Derived sizing — show the math behind the bucket caps below + static final int FULL_BUCKET_SIZING_BASIS = EXPECTED_FLAG_COUNT * EXPECTED_FULL_BUCKETS_PER_FLAG; // 125_000 + static final int PER_FLAG_BUCKET_SIZING_BASIS = PER_FLAG_HEADROOM_MULTIPLIER * EXPECTED_USERS_PER_FLAG; // 10_000 + static final int DEGRADED_BUCKET_SIZING_BASIS = EXPECTED_FLAG_COUNT * EXPECTED_DEGRADED_BUCKETS_PER_FLAG; // 25_000 + + // Enforced bucket caps + static final int GLOBAL_CAP = 131_072; // nearest power of two above FULL_BUCKET_SIZING_BASIS + static final int PER_FLAG_CAP = PER_FLAG_BUCKET_SIZING_BASIS; + static final int DEGRADED_CAP = 32_768; // nearest power of two above DEGRADED_BUCKET_SIZING_BASIS + + private static final byte CTX_TAG_STRING = 's'; + private static final byte CTX_TAG_BOOL = 'b'; + private static final byte CTX_TAG_INT = 'i'; + private static final byte CTX_TAG_LONG = 'l'; + private static final byte CTX_TAG_FLOAT = 'f'; + private static final byte CTX_TAG_DOUBLE = 'd'; + private static final byte CTX_TAG_OTHER = 'o'; + + final Map fullTier = new HashMap<>(); + final Map degradedTier = new HashMap<>(); + final Map perFlagCount = new HashMap<>(); + final AtomicLong droppedDegradedOverflow = new AtomicLong(0); + final AtomicInteger globalFullCount = new AtomicInteger(0); + + void aggregate(final FlagEvalEvent event) { + final boolean isDefault = event.variant == null; + final boolean observeFullEvaluationData = event.observeFullEvaluationData; + // On the protected path the context is dropped on emit, so it must not fragment buckets or be + // stored — otherwise a high-cardinality field (request_id, timestamp) blows out PER_FLAG_CAP + // and spills every subsequent evaluation into the degraded tier. + final Map prunedAttrs = observeFullEvaluationData ? event.attrs : null; + final String ctxKey = observeFullEvaluationData ? canonicalContextKey(prunedAttrs) : ""; + final FullKey fullKey = buildFullKey(event, ctxKey); + + EvalBucket bucket = fullTier.get(fullKey); + if (bucket != null) { + bucket.merge(event.evalTimeMs, isDefault); + bucket.observeFullEvaluationData &= observeFullEvaluationData; + return; + } + + final int flagCount = perFlagCount.getOrDefault(event.flagKey, 0); + if (globalFullCount.get() < GLOBAL_CAP && flagCount < PER_FLAG_CAP) { + fullTier.put( + fullKey, + new EvalBucket( + event.flagKey, + event.variant, + event.allocationKey, + event.targetingKey, + event.errorMessage, + event.evalTimeMs, + isDefault, + prunedAttrs, + observeFullEvaluationData)); + globalFullCount.incrementAndGet(); + perFlagCount.put(event.flagKey, flagCount + 1); + return; + } + + final DegradedKey degradedKey = buildDegradedKey(event); + bucket = degradedTier.get(degradedKey); + if (bucket != null) { + bucket.merge(event.evalTimeMs, isDefault); + bucket.observeFullEvaluationData &= observeFullEvaluationData; + return; + } + + if (degradedTier.size() < DEGRADED_CAP) { + degradedTier.put( + degradedKey, + new EvalBucket( + event.flagKey, + event.variant, + event.allocationKey, + null, + event.errorMessage, + event.evalTimeMs, + isDefault, + null, + observeFullEvaluationData)); + return; + } + + droppedDegradedOverflow.incrementAndGet(); } - final int flagCount = perFlagCount.getOrDefault(event.flagKey, 0); - if (globalFullCount.get() < GLOBAL_CAP && flagCount < PER_FLAG_CAP) { - fullTier.put( - fullKey, - new EvalBucket( - event.flagKey, - event.variant, - event.allocationKey, - event.targetingKey, - event.errorMessage, - event.evalTimeMs, - isDefault, - prunedAttrs, - observeFullEvaluationData)); - globalFullCount.incrementAndGet(); - perFlagCount.put(event.flagKey, flagCount + 1); - return; + boolean isEmpty() { + return fullTier.isEmpty() && degradedTier.isEmpty(); } - final DegradedKey degradedKey = buildDegradedKey(event); - bucket = degradedTier.get(degradedKey); - if (bucket != null) { - bucket.merge(event.evalTimeMs, isDefault); - bucket.observeFullEvaluationData &= observeFullEvaluationData; - return; + int fullTierSize() { + return fullTier.size(); } - if (degradedTier.size() < DEGRADED_CAP) { - degradedTier.put( - degradedKey, - new EvalBucket( - event.flagKey, - event.variant, - event.allocationKey, - null, - event.errorMessage, - event.evalTimeMs, - isDefault, - null, - observeFullEvaluationData)); - return; + long degradedEvaluationCount() { + long count = 0; + for (final EvalBucket bucket : degradedTier.values()) { + count += bucket.count; + } + return count; } - droppedDegradedOverflow.incrementAndGet(); - } - - boolean isEmpty() { - return fullTier.isEmpty() && degradedTier.isEmpty(); - } - - int fullTierSize() { - return fullTier.size(); - } + int bucketCount() { + return fullTier.size() + degradedTier.size(); + } - long degradedEvaluationCount() { - long count = 0; - for (final EvalBucket bucket : degradedTier.values()) { - count += bucket.count; + Iterable fullBuckets() { + return fullTier.values(); } - return count; - } - - int bucketCount() { - return fullTier.size() + degradedTier.size(); - } - - Iterable fullBuckets() { - return fullTier.values(); - } - - Iterable degradedBuckets() { - return degradedTier.values(); - } - - void clear() { - fullTier.clear(); - degradedTier.clear(); - perFlagCount.clear(); - globalFullCount.set(0); - } - - AggregatedState snapshot() { - return new AggregatedState( - new HashMap<>(fullTier), new HashMap<>(degradedTier), droppedDegradedOverflow.get()); - } - - void simulateFullTierAtCap() { - for (int i = globalFullCount.get(); i < GLOBAL_CAP; i++) { - final String key = "synthetic-full-" + i; - fullTier.put( - new FullKey(key, "on", "alloc", false, null, null, "", false), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); - globalFullCount.incrementAndGet(); - perFlagCount.merge(key, 1, Integer::sum); + + Iterable degradedBuckets() { + return degradedTier.values(); } - } - - void simulateDegradedTierAtCap() { - for (int i = degradedTier.size(); i < DEGRADED_CAP; i++) { - final String key = "synthetic-dg-" + i; - degradedTier.put( - new DegradedKey(key, "on", "alloc", false, null), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + + void clear() { + fullTier.clear(); + degradedTier.clear(); + perFlagCount.clear(); + globalFullCount.set(0); } - } - - void addDegradedBucketForTest( - final String flagKey, - final String variant, - final String allocationKey, - final String errorMessage, - final long evalTimeMs) { - degradedTier.put( - new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage), - new EvalBucket( - flagKey, - variant, - allocationKey, - null, - errorMessage, - evalTimeMs, - variant == null, - null, - false)); - } - - private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxKey) { - return new FullKey( - event.flagKey, - event.variant, - event.allocationKey, - event.variant == null, - event.errorMessage, - event.targetingKey, - ctxKey, - event.observeFullEvaluationData); - } - - private static DegradedKey buildDegradedKey(final FlagEvalEvent event) { - return new DegradedKey( - event.flagKey, - event.variant, - event.allocationKey, - event.variant == null, - event.errorMessage); - } - - static String canonicalContextKey(final Map prunedAttrs) { - if (prunedAttrs == null || prunedAttrs.isEmpty()) { - return ""; + + AggregatedState snapshot() { + return new AggregatedState(new HashMap<>(fullTier), new HashMap<>(degradedTier), droppedDegradedOverflow.get()); } - final Map sorted = - (prunedAttrs instanceof TreeMap) ? prunedAttrs : new TreeMap<>(prunedAttrs); - final StringBuilder sb = new StringBuilder(); - for (final Map.Entry entry : sorted.entrySet()) { - appendLengthDelimited(sb, entry.getKey()); - appendContextValue(sb, entry.getValue()); + + void simulateFullTierAtCap() { + for (int i = globalFullCount.get(); i < GLOBAL_CAP; i++) { + final String key = "synthetic-full-" + i; + fullTier.put( + new FullKey(key, "on", "alloc", false, null, null, "", false), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + globalFullCount.incrementAndGet(); + perFlagCount.merge(key, 1, Integer::sum); + } } - return sb.toString(); - } - - private static final String HEX_ZEROS = "00000000"; - - private static void appendLengthDelimited(final StringBuilder sb, final String s) { - // 8-char zero-padded hex length prefix, allocation-lean (no String.format on the hot path). - final String hexLength = Integer.toHexString(s.length()); - sb.append(HEX_ZEROS, 0, 8 - hexLength.length()); - sb.append(hexLength); - sb.append(s); - } - - private static void appendContextValue(final StringBuilder sb, final Object v) { - if (v instanceof Boolean) { - sb.append((char) CTX_TAG_BOOL); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Long) { - sb.append((char) CTX_TAG_LONG); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Integer) { - sb.append((char) CTX_TAG_INT); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Float) { - sb.append((char) CTX_TAG_FLOAT); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof Double) { - sb.append((char) CTX_TAG_DOUBLE); - appendLengthDelimited(sb, v.toString()); - } else if (v instanceof String) { - sb.append((char) CTX_TAG_STRING); - appendLengthDelimited(sb, (String) v); - } else { - sb.append((char) CTX_TAG_OTHER); - appendLengthDelimited(sb, v == null ? "" : v.toString()); + + void simulateDegradedTierAtCap() { + for (int i = degradedTier.size(); i < DEGRADED_CAP; i++) { + final String key = "synthetic-dg-" + i; + degradedTier.put( + new DegradedKey(key, "on", "alloc", false, null), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + } } - } - - static class EvalBucket { - long count; - long firstEvalMs; - long lastEvalMs; - boolean runtimeDefaultUsed; - String flagKey; - String variant; - String allocationKey; - String targetingKey; - String errorMessage; - Map prunedAttrs; - // Consent to emit raw PII. For full-tier buckets this is uniform (consent is a FullKey - // dimension) and the AND-fold on merge is defensive. For degraded-tier buckets consent is NOT - // a key dimension — mixed-consent events merge here — so the AND-fold produces false whenever - // any consent-off event lands in the bucket. That's benign because the degraded wire path - // drops the targeting key and context regardless of consent, so this field has no downstream - // effect for degraded rows. - boolean observeFullEvaluationData; - - EvalBucket( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final String errorMessage, - final long evalTimeMs, - final boolean runtimeDefaultUsed, - final Map prunedAttrs, - final boolean observeFullEvaluationData) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.targetingKey = targetingKey; - this.errorMessage = errorMessage; - this.firstEvalMs = evalTimeMs; - this.lastEvalMs = evalTimeMs; - this.count = 1; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.prunedAttrs = prunedAttrs; - this.observeFullEvaluationData = observeFullEvaluationData; + + void addDegradedBucketForTest( + final String flagKey, + final String variant, + final String allocationKey, + final String errorMessage, + final long evalTimeMs) { + degradedTier.put( + new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage), + new EvalBucket( + flagKey, variant, allocationKey, null, errorMessage, evalTimeMs, variant == null, null, false)); } - int prunedContextFieldCount() { - return prunedAttrs == null ? 0 : prunedAttrs.size(); + private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxKey) { + return new FullKey( + event.flagKey, + event.variant, + event.allocationKey, + event.variant == null, + event.errorMessage, + event.targetingKey, + ctxKey, + event.observeFullEvaluationData); } - void merge(final long evalTimeMs, final boolean isDefault) { - count++; - if (evalTimeMs < firstEvalMs) { - firstEvalMs = evalTimeMs; - } - if (evalTimeMs > lastEvalMs) { - lastEvalMs = evalTimeMs; - } - if (isDefault) { - runtimeDefaultUsed = true; - } + private static DegradedKey buildDegradedKey(final FlagEvalEvent event) { + return new DegradedKey( + event.flagKey, event.variant, event.allocationKey, event.variant == null, event.errorMessage); } - } - - static final class FullKey { - private final String flagKey; - private final String variant; - private final String allocationKey; - private final boolean runtimeDefaultUsed; - private final String errorMessage; - private final String targetingKey; - private final String contextKey; - // Part of the key so consent-on and consent-off evaluations never share a bucket. The - // serializer branches on this to hash the targeting key and drop the context, so events with - // different consent produce different wire rows and belong in different buckets. - private final boolean observeFullEvaluationData; - - FullKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final String targetingKey, - final String contextKey, - final boolean observeFullEvaluationData) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.errorMessage = errorMessage; - this.targetingKey = targetingKey; - this.contextKey = contextKey; - this.observeFullEvaluationData = observeFullEvaluationData; + + static String canonicalContextKey(final Map prunedAttrs) { + if (prunedAttrs == null || prunedAttrs.isEmpty()) { + return ""; + } + final Map sorted = (prunedAttrs instanceof TreeMap) ? prunedAttrs : new TreeMap<>(prunedAttrs); + final StringBuilder sb = new StringBuilder(); + for (final Map.Entry entry : sorted.entrySet()) { + appendLengthDelimited(sb, entry.getKey()); + appendContextValue(sb, entry.getValue()); + } + return sb.toString(); } - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof FullKey)) { - return false; - } - final FullKey fullKey = (FullKey) o; - return runtimeDefaultUsed == fullKey.runtimeDefaultUsed - && observeFullEvaluationData == fullKey.observeFullEvaluationData - && Objects.equals(flagKey, fullKey.flagKey) - && Objects.equals(variant, fullKey.variant) - && Objects.equals(allocationKey, fullKey.allocationKey) - && Objects.equals(errorMessage, fullKey.errorMessage) - && Objects.equals(targetingKey, fullKey.targetingKey) - && Objects.equals(contextKey, fullKey.contextKey); + private static final String HEX_ZEROS = "00000000"; + + private static void appendLengthDelimited(final StringBuilder sb, final String s) { + // 8-char zero-padded hex length prefix, allocation-lean (no String.format on the hot path). + final String hexLength = Integer.toHexString(s.length()); + sb.append(HEX_ZEROS, 0, 8 - hexLength.length()); + sb.append(hexLength); + sb.append(s); } - @Override - public int hashCode() { - // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs - // on this hot bucket-lookup path. - int result = hash(flagKey, variant, allocationKey); - result = addToHash(result, runtimeDefaultUsed); - result = addToHash(result, errorMessage); - result = addToHash(result, targetingKey); - result = addToHash(result, contextKey); - return addToHash(result, observeFullEvaluationData); + private static void appendContextValue(final StringBuilder sb, final Object v) { + if (v instanceof Boolean) { + sb.append((char) CTX_TAG_BOOL); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Long) { + sb.append((char) CTX_TAG_LONG); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Integer) { + sb.append((char) CTX_TAG_INT); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Float) { + sb.append((char) CTX_TAG_FLOAT); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Double) { + sb.append((char) CTX_TAG_DOUBLE); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof String) { + sb.append((char) CTX_TAG_STRING); + appendLengthDelimited(sb, (String) v); + } else { + sb.append((char) CTX_TAG_OTHER); + appendLengthDelimited(sb, v == null ? "" : v.toString()); + } } - } - - static final class DegradedKey { - // Unlike FullKey, consent is NOT a bucket dimension here: the wire serializer for degraded rows - // (FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket with isFullTier=false) drops the - // targeting key and context unconditionally, so two degraded buckets differing only in consent - // would emit byte-identical JSON with evaluation_count split — halving effective DEGRADED_CAP - // for zero wire fidelity. Mixed-consent events merge into one bucket; the AND-fold on - // EvalBucket.observeFullEvaluationData still runs but has no downstream effect for degraded - // rows. - private final String flagKey; - private final String variant; - private final String allocationKey; - private final boolean runtimeDefaultUsed; - private final String errorMessage; - - DegradedKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage) { - this.flagKey = flagKey; - this.variant = variant; - this.allocationKey = allocationKey; - this.runtimeDefaultUsed = runtimeDefaultUsed; - this.errorMessage = errorMessage; + + static class EvalBucket { + long count; + long firstEvalMs; + long lastEvalMs; + boolean runtimeDefaultUsed; + String flagKey; + String variant; + String allocationKey; + String targetingKey; + String errorMessage; + Map prunedAttrs; + // Consent to emit raw PII. For full-tier buckets this is uniform (consent is a FullKey + // dimension) and the AND-fold on merge is defensive. For degraded-tier buckets consent is NOT + // a key dimension — mixed-consent events merge here — so the AND-fold produces false whenever + // any consent-off event lands in the bucket. That's benign because the degraded wire path + // drops the targeting key and context regardless of consent, so this field has no downstream + // effect for degraded rows. + boolean observeFullEvaluationData; + + EvalBucket( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final boolean runtimeDefaultUsed, + final Map prunedAttrs, + final boolean observeFullEvaluationData) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.targetingKey = targetingKey; + this.errorMessage = errorMessage; + this.firstEvalMs = evalTimeMs; + this.lastEvalMs = evalTimeMs; + this.count = 1; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.prunedAttrs = prunedAttrs; + this.observeFullEvaluationData = observeFullEvaluationData; + } + + int prunedContextFieldCount() { + return prunedAttrs == null ? 0 : prunedAttrs.size(); + } + + void merge(final long evalTimeMs, final boolean isDefault) { + count++; + if (evalTimeMs < firstEvalMs) { + firstEvalMs = evalTimeMs; + } + if (evalTimeMs > lastEvalMs) { + lastEvalMs = evalTimeMs; + } + if (isDefault) { + runtimeDefaultUsed = true; + } + } } - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof DegradedKey)) { - return false; - } - final DegradedKey that = (DegradedKey) o; - return runtimeDefaultUsed == that.runtimeDefaultUsed - && Objects.equals(flagKey, that.flagKey) - && Objects.equals(variant, that.variant) - && Objects.equals(allocationKey, that.allocationKey) - && Objects.equals(errorMessage, that.errorMessage); + static final class FullKey { + private final String flagKey; + private final String variant; + private final String allocationKey; + private final boolean runtimeDefaultUsed; + private final String errorMessage; + private final String targetingKey; + private final String contextKey; + // Part of the key so consent-on and consent-off evaluations never share a bucket. The + // serializer branches on this to hash the targeting key and drop the context, so events with + // different consent produce different wire rows and belong in different buckets. + private final boolean observeFullEvaluationData; + + FullKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final String targetingKey, + final String contextKey, + final boolean observeFullEvaluationData) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.errorMessage = errorMessage; + this.targetingKey = targetingKey; + this.contextKey = contextKey; + this.observeFullEvaluationData = observeFullEvaluationData; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FullKey)) { + return false; + } + final FullKey fullKey = (FullKey) o; + return runtimeDefaultUsed == fullKey.runtimeDefaultUsed + && observeFullEvaluationData == fullKey.observeFullEvaluationData + && Objects.equals(flagKey, fullKey.flagKey) + && Objects.equals(variant, fullKey.variant) + && Objects.equals(allocationKey, fullKey.allocationKey) + && Objects.equals(errorMessage, fullKey.errorMessage) + && Objects.equals(targetingKey, fullKey.targetingKey) + && Objects.equals(contextKey, fullKey.contextKey); + } + + @Override + public int hashCode() { + // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs + // on this hot bucket-lookup path. + int result = hash(flagKey, variant, allocationKey); + result = addToHash(result, runtimeDefaultUsed); + result = addToHash(result, errorMessage); + result = addToHash(result, targetingKey); + result = addToHash(result, contextKey); + return addToHash(result, observeFullEvaluationData); + } } - @Override - public int hashCode() { - // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs - // on this hot bucket-lookup path. - int result = hash(flagKey, variant, allocationKey); - result = addToHash(result, runtimeDefaultUsed); - return addToHash(result, errorMessage); + static final class DegradedKey { + // Unlike FullKey, consent is NOT a bucket dimension here: the wire serializer for degraded rows + // (FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket with isFullTier=false) drops the + // targeting key and context unconditionally, so two degraded buckets differing only in consent + // would emit byte-identical JSON with evaluation_count split — halving effective DEGRADED_CAP + // for zero wire fidelity. Mixed-consent events merge into one bucket; the AND-fold on + // EvalBucket.observeFullEvaluationData still runs but has no downstream effect for degraded + // rows. + private final String flagKey; + private final String variant; + private final String allocationKey; + private final boolean runtimeDefaultUsed; + private final String errorMessage; + + DegradedKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.errorMessage = errorMessage; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DegradedKey)) { + return false; + } + final DegradedKey that = (DegradedKey) o; + return runtimeDefaultUsed == that.runtimeDefaultUsed + && Objects.equals(flagKey, that.flagKey) + && Objects.equals(variant, that.variant) + && Objects.equals(allocationKey, that.allocationKey) + && Objects.equals(errorMessage, that.errorMessage); + } + + @Override + public int hashCode() { + // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs + // on this hot bucket-lookup path. + int result = hash(flagKey, variant, allocationKey); + result = addToHash(result, runtimeDefaultUsed); + return addToHash(result, errorMessage); + } } - } - - static class AggregatedState { - final Map fullTier; - final Map degradedTier; - final long droppedDegradedOverflow; - - AggregatedState( - final Map fullTier, - final Map degradedTier, - final long droppedDegradedOverflow) { - this.fullTier = fullTier; - this.degradedTier = degradedTier; - this.droppedDegradedOverflow = droppedDegradedOverflow; + + static class AggregatedState { + final Map fullTier; + final Map degradedTier; + final long droppedDegradedOverflow; + + AggregatedState( + final Map fullTier, + final Map degradedTier, + final long droppedDegradedOverflow) { + this.fullTier = fullTier; + this.degradedTier = degradedTier; + this.droppedDegradedOverflow = droppedDegradedOverflow; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java index 3a8734fd516..e15ed623a34 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java @@ -11,284 +11,275 @@ final class FlagEvaluationPayloads { - private static final byte[] PAYLOAD_SUFFIX = FeatureFlagEvpPublisher.utf8Bytes("]}"); - private static final byte[] JSON_COMMA = FeatureFlagEvpPublisher.utf8Bytes(","); - - /** - * Wire prefix identifying a privacy-preserving, hashed targeting key. Emitted for full-tier rows - * when {@code observeFullEvaluationData} is off. The suffix is the lower-case hex SHA-256 of the - * UTF-8 targeting key (see {@link ULeb128Encoder#hashTargetingKey}). This is a cross-SDK wire - * contract - keep it in sync with the other server SDKs and the UFC/EVP spec. - */ - private static final String HASHED_TARGETING_KEY_PREFIX = "sha256_"; - - private static final JsonAdapter EVENT_JSON_ADAPTER; - private static final JsonAdapter> CONTEXT_JSON_ADAPTER; - - static { - final Moshi moshi = new Moshi.Builder().build(); - EVENT_JSON_ADAPTER = moshi.adapter(FlagEvaluationEvent.class); - final Type contextType = Types.newParameterizedType(Map.class, String.class, String.class); - CONTEXT_JSON_ADAPTER = moshi.adapter(contextType); - } - - private FlagEvaluationPayloads() {} - - static class FlagEvaluationsRequest { - public final Map context; - public final List flagEvaluations; - - FlagEvaluationsRequest( - final Map context, final List flagEvaluations) { - this.context = context; - this.flagEvaluations = flagEvaluations; + private static final byte[] PAYLOAD_SUFFIX = FeatureFlagEvpPublisher.utf8Bytes("]}"); + private static final byte[] JSON_COMMA = FeatureFlagEvpPublisher.utf8Bytes(","); + + /** + * Wire prefix identifying a privacy-preserving, hashed targeting key. Emitted for full-tier rows + * when {@code observeFullEvaluationData} is off. The suffix is the lower-case hex SHA-256 of the + * UTF-8 targeting key (see {@link ULeb128Encoder#hashTargetingKey}). This is a cross-SDK wire + * contract - keep it in sync with the other server SDKs and the UFC/EVP spec. + */ + private static final String HASHED_TARGETING_KEY_PREFIX = "sha256_"; + + private static final JsonAdapter EVENT_JSON_ADAPTER; + private static final JsonAdapter> CONTEXT_JSON_ADAPTER; + + static { + final Moshi moshi = new Moshi.Builder().build(); + EVENT_JSON_ADAPTER = moshi.adapter(FlagEvaluationEvent.class); + final Type contextType = Types.newParameterizedType(Map.class, String.class, String.class); + CONTEXT_JSON_ADAPTER = moshi.adapter(contextType); } - } - - static EncodedPayloads buildPayloads( - final List events, - final Map context, - final int payloadSizeLimitBytes) { - final byte[] prefix = payloadPrefix(context); - EncodedPayloadBuilder current = new EncodedPayloadBuilder(prefix); - final List payloads = new ArrayList<>(); - long droppedPayloadLimit = 0; - long degradedPayloadLimit = 0; - - for (final FlagEvaluationEvent event : events) { - byte[] eventBytes = encodeEvent(event); - - if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { - payloads.add(current.toByteArray()); - current = new EncodedPayloadBuilder(prefix); - } - - if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { - current.add(eventBytes); - continue; - } - - final FlagEvaluationEvent degraded = event.withoutTargetingKeyAndContext(); - if (degraded != null) { - eventBytes = encodeEvent(degraded); - if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { - payloads.add(current.toByteArray()); - current = new EncodedPayloadBuilder(prefix); + + private FlagEvaluationPayloads() {} + + static class FlagEvaluationsRequest { + public final Map context; + public final List flagEvaluations; + + FlagEvaluationsRequest(final Map context, final List flagEvaluations) { + this.context = context; + this.flagEvaluations = flagEvaluations; } - if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { - current.add(eventBytes); - degradedPayloadLimit += event.evaluation_count; - continue; + } + + static EncodedPayloads buildPayloads( + final List events, + final Map context, + final int payloadSizeLimitBytes) { + final byte[] prefix = payloadPrefix(context); + EncodedPayloadBuilder current = new EncodedPayloadBuilder(prefix); + final List payloads = new ArrayList<>(); + long droppedPayloadLimit = 0; + long degradedPayloadLimit = 0; + + for (final FlagEvaluationEvent event : events) { + byte[] eventBytes = encodeEvent(event); + + if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { + payloads.add(current.toByteArray()); + current = new EncodedPayloadBuilder(prefix); + } + + if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { + current.add(eventBytes); + continue; + } + + final FlagEvaluationEvent degraded = event.withoutTargetingKeyAndContext(); + if (degraded != null) { + eventBytes = encodeEvent(degraded); + if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { + payloads.add(current.toByteArray()); + current = new EncodedPayloadBuilder(prefix); + } + if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { + current.add(eventBytes); + degradedPayloadLimit += event.evaluation_count; + continue; + } + } + + droppedPayloadLimit += event.evaluation_count; } - } - droppedPayloadLimit += event.evaluation_count; + if (!current.isEmpty()) { + payloads.add(current.toByteArray()); + } + return new EncodedPayloads(payloads, droppedPayloadLimit, degradedPayloadLimit); } - if (!current.isEmpty()) { - payloads.add(current.toByteArray()); + private static byte[] payloadPrefix(final Map context) { + return FeatureFlagEvpPublisher.utf8Bytes( + "{\"context\":" + CONTEXT_JSON_ADAPTER.toJson(context) + ",\"flagEvaluations\":["); } - return new EncodedPayloads(payloads, droppedPayloadLimit, degradedPayloadLimit); - } - - private static byte[] payloadPrefix(final Map context) { - return FeatureFlagEvpPublisher.utf8Bytes( - "{\"context\":" + CONTEXT_JSON_ADAPTER.toJson(context) + ",\"flagEvaluations\":["); - } - - private static byte[] encodeEvent(final FlagEvaluationEvent event) { - return FeatureFlagEvpPublisher.utf8Bytes(EVENT_JSON_ADAPTER.toJson(event)); - } - - static final class EncodedPayloads { - final List bodies; - final long droppedPayloadLimit; - final long degradedPayloadLimit; - - private EncodedPayloads( - final List bodies, - final long droppedPayloadLimit, - final long degradedPayloadLimit) { - this.bodies = bodies; - this.droppedPayloadLimit = droppedPayloadLimit; - this.degradedPayloadLimit = degradedPayloadLimit; + + private static byte[] encodeEvent(final FlagEvaluationEvent event) { + return FeatureFlagEvpPublisher.utf8Bytes(EVENT_JSON_ADAPTER.toJson(event)); } - } - private static final class EncodedPayloadBuilder { - private final byte[] prefix; - private final List events = new ArrayList<>(); - private int eventBytes; + static final class EncodedPayloads { + final List bodies; + final long droppedPayloadLimit; + final long degradedPayloadLimit; - private EncodedPayloadBuilder(final byte[] prefix) { - this.prefix = prefix; + private EncodedPayloads( + final List bodies, final long droppedPayloadLimit, final long degradedPayloadLimit) { + this.bodies = bodies; + this.droppedPayloadLimit = droppedPayloadLimit; + this.degradedPayloadLimit = degradedPayloadLimit; + } } - private boolean isEmpty() { - return events.isEmpty(); - } + private static final class EncodedPayloadBuilder { + private final byte[] prefix; + private final List events = new ArrayList<>(); + private int eventBytes; - private boolean canAdd(final byte[] event, final int payloadSizeLimitBytes) { - return sizeWith(event) <= payloadSizeLimitBytes; - } + private EncodedPayloadBuilder(final byte[] prefix) { + this.prefix = prefix; + } - private int sizeWith(final byte[] event) { - return prefix.length + PAYLOAD_SUFFIX.length + eventBytes + event.length + events.size(); - } + private boolean isEmpty() { + return events.isEmpty(); + } - private void add(final byte[] event) { - events.add(event); - eventBytes += event.length; - } + private boolean canAdd(final byte[] event, final int payloadSizeLimitBytes) { + return sizeWith(event) <= payloadSizeLimitBytes; + } - private byte[] toByteArray() { - final int size = prefix.length + PAYLOAD_SUFFIX.length + eventBytes; - final ByteArrayOutputStream out = new ByteArrayOutputStream(size + events.size()); - out.write(prefix, 0, prefix.length); - for (int i = 0; i < events.size(); i++) { - if (i > 0) { - out.write(JSON_COMMA, 0, JSON_COMMA.length); + private int sizeWith(final byte[] event) { + return prefix.length + PAYLOAD_SUFFIX.length + eventBytes + event.length + events.size(); } - final byte[] event = events.get(i); - out.write(event, 0, event.length); - } - out.write(PAYLOAD_SUFFIX, 0, PAYLOAD_SUFFIX.length); - return out.toByteArray(); - } - } - - static class FlagEvaluationEvent { - public final long timestamp; - public final FlagKeyObject flag; - public final long first_evaluation; - public final long last_evaluation; - public final long evaluation_count; - public final KeyObject variant; - public final KeyObject allocation; - public final String targeting_key; - public final Boolean runtime_default_used; - public final EventContext context; - public final ErrorObject error; - - FlagEvaluationEvent( - final long timestamp, - final String flagKey, - final long firstEvalMs, - final long lastEvalMs, - final long count, - final String variant, - final String allocation, - final String targetingKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final Map evaluationAttrs) { - this.timestamp = timestamp; - this.flag = new FlagKeyObject(flagKey); - this.first_evaluation = firstEvalMs; - this.last_evaluation = lastEvalMs; - this.evaluation_count = count; - this.variant = (variant != null && !variant.isEmpty()) ? new KeyObject(variant) : null; - this.allocation = - (allocation != null && !allocation.isEmpty()) ? new KeyObject(allocation) : null; - this.targeting_key = targetingKey; - this.runtime_default_used = runtimeDefaultUsed ? Boolean.TRUE : null; - this.context = - (evaluationAttrs != null && !evaluationAttrs.isEmpty()) - ? new EventContext(evaluationAttrs) - : null; - this.error = - (errorMessage != null && !errorMessage.isEmpty()) ? new ErrorObject(errorMessage) : null; - } - static FlagEvaluationEvent fromBucket( - final FlagEvaluationAggregator.EvalBucket bucket, - final boolean isFullTier, - final boolean observeFullEvaluationData, - final long flushTimeMs) { - final boolean includeRawContext = isFullTier && observeFullEvaluationData; - return new FlagEvaluationEvent( - flushTimeMs, - bucket.flagKey, - bucket.firstEvalMs, - bucket.lastEvalMs, - bucket.count, - bucket.variant, - bucket.allocationKey, - resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData), - bucket.runtimeDefaultUsed, - bucket.errorMessage, - includeRawContext ? bucket.prunedAttrs : null); - } + private void add(final byte[] event) { + events.add(event); + eventBytes += event.length; + } - private static String resolveTargetingKey( - final String rawTargetingKey, - final boolean isFullTier, - final boolean observeFullEvaluationData) { - if (!isFullTier || rawTargetingKey == null) { - return null; - } - if (observeFullEvaluationData) { - return rawTargetingKey; - } - return HASHED_TARGETING_KEY_PREFIX + ULeb128Encoder.hashTargetingKey(rawTargetingKey); + private byte[] toByteArray() { + final int size = prefix.length + PAYLOAD_SUFFIX.length + eventBytes; + final ByteArrayOutputStream out = new ByteArrayOutputStream(size + events.size()); + out.write(prefix, 0, prefix.length); + for (int i = 0; i < events.size(); i++) { + if (i > 0) { + out.write(JSON_COMMA, 0, JSON_COMMA.length); + } + final byte[] event = events.get(i); + out.write(event, 0, event.length); + } + out.write(PAYLOAD_SUFFIX, 0, PAYLOAD_SUFFIX.length); + return out.toByteArray(); + } } - FlagEvaluationEvent withoutTargetingKeyAndContext() { - if (targeting_key == null && context == null) { - return null; - } - return new FlagEvaluationEvent( - timestamp, - flag.key, - first_evaluation, - last_evaluation, - evaluation_count, - keyOf(variant), - keyOf(allocation), - null, - Boolean.TRUE.equals(runtime_default_used), - messageOf(error), - null); + static class FlagEvaluationEvent { + public final long timestamp; + public final FlagKeyObject flag; + public final long first_evaluation; + public final long last_evaluation; + public final long evaluation_count; + public final KeyObject variant; + public final KeyObject allocation; + public final String targeting_key; + public final Boolean runtime_default_used; + public final EventContext context; + public final ErrorObject error; + + FlagEvaluationEvent( + final long timestamp, + final String flagKey, + final long firstEvalMs, + final long lastEvalMs, + final long count, + final String variant, + final String allocation, + final String targetingKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final Map evaluationAttrs) { + this.timestamp = timestamp; + this.flag = new FlagKeyObject(flagKey); + this.first_evaluation = firstEvalMs; + this.last_evaluation = lastEvalMs; + this.evaluation_count = count; + this.variant = (variant != null && !variant.isEmpty()) ? new KeyObject(variant) : null; + this.allocation = (allocation != null && !allocation.isEmpty()) ? new KeyObject(allocation) : null; + this.targeting_key = targetingKey; + this.runtime_default_used = runtimeDefaultUsed ? Boolean.TRUE : null; + this.context = + (evaluationAttrs != null && !evaluationAttrs.isEmpty()) ? new EventContext(evaluationAttrs) : null; + this.error = (errorMessage != null && !errorMessage.isEmpty()) ? new ErrorObject(errorMessage) : null; + } + + static FlagEvaluationEvent fromBucket( + final FlagEvaluationAggregator.EvalBucket bucket, + final boolean isFullTier, + final boolean observeFullEvaluationData, + final long flushTimeMs) { + final boolean includeRawContext = isFullTier && observeFullEvaluationData; + return new FlagEvaluationEvent( + flushTimeMs, + bucket.flagKey, + bucket.firstEvalMs, + bucket.lastEvalMs, + bucket.count, + bucket.variant, + bucket.allocationKey, + resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData), + bucket.runtimeDefaultUsed, + bucket.errorMessage, + includeRawContext ? bucket.prunedAttrs : null); + } + + private static String resolveTargetingKey( + final String rawTargetingKey, final boolean isFullTier, final boolean observeFullEvaluationData) { + if (!isFullTier || rawTargetingKey == null) { + return null; + } + if (observeFullEvaluationData) { + return rawTargetingKey; + } + return HASHED_TARGETING_KEY_PREFIX + ULeb128Encoder.hashTargetingKey(rawTargetingKey); + } + + FlagEvaluationEvent withoutTargetingKeyAndContext() { + if (targeting_key == null && context == null) { + return null; + } + return new FlagEvaluationEvent( + timestamp, + flag.key, + first_evaluation, + last_evaluation, + evaluation_count, + keyOf(variant), + keyOf(allocation), + null, + Boolean.TRUE.equals(runtime_default_used), + messageOf(error), + null); + } } - } - private static String keyOf(final KeyObject object) { - return object == null ? null : object.key; - } + private static String keyOf(final KeyObject object) { + return object == null ? null : object.key; + } - private static String messageOf(final ErrorObject object) { - return object == null ? null : object.message; - } + private static String messageOf(final ErrorObject object) { + return object == null ? null : object.message; + } - static class KeyObject { - public final String key; + static class KeyObject { + public final String key; - KeyObject(final String key) { - this.key = key; + KeyObject(final String key) { + this.key = key; + } } - } - static class FlagKeyObject { - public final String key; + static class FlagKeyObject { + public final String key; - FlagKeyObject(final String key) { - this.key = key; + FlagKeyObject(final String key) { + this.key = key; + } } - } - static class ErrorObject { - public final String message; + static class ErrorObject { + public final String message; - ErrorObject(final String message) { - this.message = message; + ErrorObject(final String message) { + this.message = message; + } } - } - static class EventContext { - public final Map evaluation; + static class EventContext { + public final Map evaluation; - EventContext(final Map evaluation) { - this.evaluation = evaluation; + EventContext(final Map evaluation) { + this.evaluation = evaluation; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java index 2da8ffe0256..450f5db0eb7 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java @@ -53,556 +53,541 @@ */ public class FlagEvaluationWriterImpl implements FlagEvaluationWriter { - private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationWriterImpl.class); - - static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC - static final int FLUSH_INTERVAL_SECONDS = 10; - - static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = EvpProxy.PAYLOAD_SIZE_LIMIT_BYTES; - static final String FLAG_EVALUATION_DROPPED_METRIC = "flagevaluation.rows.dropped"; - static final String FLAG_EVALUATION_DEGRADED_METRIC = "flagevaluation.rows.degraded"; - static final String FLAG_EVALUATION_SPLITS_METRIC = "flagevaluation.payload.splits"; - static final String FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC = "flagevaluation.context.truncated"; - static final String DROP_REASON_QUEUE_OVERFLOW = "queue_overflow"; - static final String DROP_REASON_CLOSED = "closed"; - static final String DROP_REASON_DEGRADED_CAP = "degraded_cap"; - static final String DROP_REASON_PAYLOAD_LIMIT = "payload_limit"; - static final String DEGRADED_REASON_CARDINALITY_CAP = "cardinality_cap"; - static final String DEGRADED_REASON_PAYLOAD_LIMIT = "payload_limit"; - private static final String FLAG_EVALUATION_ROUTE = "flagevaluation"; - private static final CoreMetricCollector CORE_METRICS = CoreMetricCollector.getInstance(); - - private final MessagePassingBlockingQueue queue; - private final FlagEvaluationSerializingHandler serializer; - private final Thread serializerThread; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean closed = new AtomicBoolean(false); - - private static void countMetric(final String metricName, final long value, final String reason) { - if (value <= 0) { - return; - } - CORE_METRICS.count(metricName, value, reason == null ? null : "reason:" + reason); - } - - /** - * Observable counter for events dropped because the bounded hand-off queue was full when the hook - * tried to enqueue (backpressure). Incremented on the hook thread, surfaced on flush. - */ - private final AtomicLong droppedQueueOverflow = new AtomicLong(0); - - /** - * Per-reason-tag counters for evaluations whose context was truncated by copyPrunedContext. Keyed - * by the sorted comma-separated reason string (e.g. "max_key_length,max_value_length"). - * Incremented on the hook thread, drained and emitted on flush. - */ - private final ConcurrentHashMap contextTruncatedCounts = - new ConcurrentHashMap<>(); - - public FlagEvaluationWriterImpl(final SharedCommunicationObjects sco, final Config config) { - this( - DEFAULT_CAPACITY, - FLUSH_INTERVAL_SECONDS, - SECONDS, - new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.FLAG_EVALUATION)::create, - config); - } - - FlagEvaluationWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final Supplier backendApiSupplier, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); - this.serializer = - new FlagEvaluationSerializingHandler( - backendApiSupplier, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - droppedQueueOverflow, - contextTruncatedCounts, - this::close, - FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - this.serializerThread = newAgentThread(FEATURE_FLAG_EVALUATION_PROCESSOR, serializer); - } - - @Override - public void start() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - // Register with the gateway so FlagEvalLoggingHook can route evaluations to this writer - FeatureFlaggingGateway.setFlagEvalWriter(this); - this.serializerThread.start(); - } - } - - /** Test seam: starts the worker thread WITHOUT registering with the global gateway. */ - void startForTest() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - this.serializerThread.start(); - } - } - - /** Test seam: current full-tier bucket count in the worker's aggregator. */ - int aggregatorFullTierSizeForTest() { - return serializer.aggregator.fullTierSize(); - } - - @Override - public void close() { - final boolean workerRunning; - synchronized (lifecycleLock) { - if (!closed.compareAndSet(false, true)) { - return; - } - // Disable and deregister from the gateway so no new events are enqueued. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); - workerRunning = this.serializerThread.isAlive(); - if (workerRunning) { - // Ask the worker to drain the queue and final-flush, then interrupt to wake it from poll(). - serializer.requestShutdown(); - this.serializerThread.interrupt(); - } - } - if (Thread.currentThread() == this.serializerThread) { - return; - } - if (workerRunning) { - try { - // Bounded wait for the worker's final flush so queued events are not lost on shutdown. - this.serializerThread.join(TimeUnit.SECONDS.toMillis(5)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationWriterImpl.class); + + static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC + static final int FLUSH_INTERVAL_SECONDS = 10; + + static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = EvpProxy.PAYLOAD_SIZE_LIMIT_BYTES; + static final String FLAG_EVALUATION_DROPPED_METRIC = "flagevaluation.rows.dropped"; + static final String FLAG_EVALUATION_DEGRADED_METRIC = "flagevaluation.rows.degraded"; + static final String FLAG_EVALUATION_SPLITS_METRIC = "flagevaluation.payload.splits"; + static final String FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC = "flagevaluation.context.truncated"; + static final String DROP_REASON_QUEUE_OVERFLOW = "queue_overflow"; + static final String DROP_REASON_CLOSED = "closed"; + static final String DROP_REASON_DEGRADED_CAP = "degraded_cap"; + static final String DROP_REASON_PAYLOAD_LIMIT = "payload_limit"; + static final String DEGRADED_REASON_CARDINALITY_CAP = "cardinality_cap"; + static final String DEGRADED_REASON_PAYLOAD_LIMIT = "payload_limit"; + private static final String FLAG_EVALUATION_ROUTE = "flagevaluation"; + private static final CoreMetricCollector CORE_METRICS = CoreMetricCollector.getInstance(); + + private final MessagePassingBlockingQueue queue; + private final FlagEvaluationSerializingHandler serializer; + private final Thread serializerThread; + private final Object lifecycleLock = new Object(); + private final AtomicBoolean closed = new AtomicBoolean(false); + + private static void countMetric(final String metricName, final long value, final String reason) { + if (value <= 0) { + return; + } + CORE_METRICS.count(metricName, value, reason == null ? null : "reason:" + reason); } - // enqueue() is lock-free, so a producer that passed the closed check before close() ran can - // still land an event after the worker's final drain. Sweep the remainder so that loss is - // counted rather than silently stranded in a queue nobody polls again. Only safe once the - // worker is gone: the queue is single-consumer, so sweeping alongside a live worker would - // break that contract. If join() timed out the worker is still draining, so skip the sweep. - if (!this.serializerThread.isAlive()) { - sweepAndCountResidualEvents(); + + /** + * Observable counter for events dropped because the bounded hand-off queue was full when the hook + * tried to enqueue (backpressure). Incremented on the hook thread, surfaced on flush. + */ + private final AtomicLong droppedQueueOverflow = new AtomicLong(0); + + /** + * Per-reason-tag counters for evaluations whose context was truncated by copyPrunedContext. Keyed + * by the sorted comma-separated reason string (e.g. "max_key_length,max_value_length"). + * Incremented on the hook thread, drained and emitted on flush. + */ + private final ConcurrentHashMap contextTruncatedCounts = new ConcurrentHashMap<>(); + + public FlagEvaluationWriterImpl(final SharedCommunicationObjects sco, final Config config) { + this( + DEFAULT_CAPACITY, + FLUSH_INTERVAL_SECONDS, + SECONDS, + new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.FLAG_EVALUATION)::create, + config); } - } - - /** - * Counts events left in the queue after the worker has exited. Must only be called when the - * serializer thread is not alive - the queue permits a single consumer. - */ - private void sweepAndCountResidualEvents() { - long residual = 0; - while (queue.poll() != null) { - residual++; + + FlagEvaluationWriterImpl( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final Supplier backendApiSupplier, + final Config config) { + this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); + this.serializer = new FlagEvaluationSerializingHandler( + backendApiSupplier, + queue, + flushInterval, + timeUnit, + FeatureFlagEvpContext.from(config), + droppedQueueOverflow, + contextTruncatedCounts, + this::close, + FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + this.serializerThread = newAgentThread(FEATURE_FLAG_EVALUATION_PROCESSOR, serializer); } - countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); - } - @Override - public void enqueue(final FlagEvalEvent event) { - if (event == null) { - return; + @Override + public void start() { + synchronized (lifecycleLock) { + if (closed.get()) { + return; + } + // Register with the gateway so FlagEvalLoggingHook can route evaluations to this writer + FeatureFlaggingGateway.setFlagEvalWriter(this); + this.serializerThread.start(); + } } - if (isClosedOrEnqueueDisabled()) { - countClosedDrop(); - return; + + /** Test seam: starts the worker thread WITHOUT registering with the global gateway. */ + void startForTest() { + synchronized (lifecycleLock) { + if (closed.get()) { + return; + } + this.serializerThread.start(); + } } - // Deliberately lock-free: the hand-off queue is MPSC by design, so serializing producers on a - // monitor here would negate that and turn every evaluation in every application thread into - // contention on one lock. A producer that passed the check above can still offer after close() - // has started; that residue is accounted for by the worker's bounded post-drain passes and by - // close()'s post-join sweep, so shutdown loss stays observable. - // - // Safe publication of the event (including the context snapshot built by the hook) comes from - // the queue's own offer/poll ordering, not from any monitor held here. - // - // Non-blocking offer. Count overflow so loss is observable rather than silent; the count is - // surfaced on the next flush. The hook's pre-queue guard (see FlagEvalLoggingHook) samples the - // queue depth before doing any context-copy work, so a saturated queue costs an - // AtomicInteger.get(); the offer here still races with the worker and can legitimately fail. - if (!queue.offer(event)) { - droppedQueueOverflow.incrementAndGet(); + + /** Test seam: current full-tier bucket count in the worker's aggregator. */ + int aggregatorFullTierSizeForTest() { + return serializer.aggregator.fullTierSize(); } - } - - /** - * Reports whether the async hand-off queue currently has room for another event. Producers - * consult this before performing any expensive context-copy work so a saturated queue is observed - * as an O(1) read rather than a full snapshot followed by a discarded offer. Best-effort only: - * the worker can drain (or a peer producer can fill) between this check and the subsequent - * enqueue, so callers must still tolerate offer failure. - */ - public boolean hasCapacityForEnqueue() { - return queue.size() < queue.capacity(); - } - - /** Counts one queue-overflow drop without offering an event. */ - public void countPreQueueOverflow() { - droppedQueueOverflow.incrementAndGet(); - } - - @Override - public void countContextTruncated(final String reason) { - contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); - } - - private boolean isClosedOrEnqueueDisabled() { - return closed.get() || !FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled(); - } - - private void countClosedDrop() { - // Count the drop for any early exit from enqueue, whether closed=true or the gate was disabled - // by the surrounding subsystem. FeatureFlaggingSystem.stop() flips the gate before this - // writer's close() runs, so an in-flight enqueue could race that flip and see gate=false while - // closed=false. Counting on either condition keeps shutdown-loss observable. - countMetric(FLAG_EVALUATION_DROPPED_METRIC, 1, DROP_REASON_CLOSED); - } - - /** Returns the count of events dropped due to queue-overflow backpressure (observable). */ - long droppedQueueOverflow() { - return droppedQueueOverflow.get(); - } - - /** Test seam: returns one queued event without starting the worker. */ - FlagEvalEvent pollQueuedEventForTest() { - return queue.poll(); - } - - /** Test seam: flushes serializer state without starting the worker. */ - void flushForTest() { - serializer.flush(); - } - - // ---- Serializing handler (background thread logic) ---- - - static class FlagEvaluationSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; - private final long ticksRequiredToFlush; - - @SuppressFBWarnings( - value = "AT_NONATOMIC_64BIT_PRIMITIVE", - justification = "the field is confined to the single serializer thread") - private long lastTicks; - - private final FeatureFlagEvpPublisher - evpPublisher; - final Map context; - private final AtomicLong droppedQueueOverflow; - private final ConcurrentHashMap contextTruncatedCounts; - private final Runnable errorCallback; - private final int payloadSizeLimitBytes; - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - // Shutdown coordination: set by close(), drives a final drain+flush before the worker exits. - private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); - private final CountDownLatch finalFlushDone = new CountDownLatch(1); - - FlagEvaluationSerializingHandler( - final Supplier backendApiSupplier, - final MessagePassingBlockingQueue queue, - final long flushInterval, - final TimeUnit timeUnit, - final Map context, - final AtomicLong droppedQueueOverflow, - final ConcurrentHashMap contextTruncatedCounts, - final Runnable errorCallback, - final int payloadSizeLimitBytes) { - this.queue = queue; - this.evpPublisher = - new FeatureFlagEvpPublisher<>( - backendApiSupplier, FlagEvaluationPayloads.FlagEvaluationsRequest.class); - this.context = context; - this.droppedQueueOverflow = droppedQueueOverflow; - this.contextTruncatedCounts = contextTruncatedCounts; - this.payloadSizeLimitBytes = payloadSizeLimitBytes; - this.lastTicks = System.nanoTime(); - this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - this.errorCallback = errorCallback; - LOGGER.debug("starting flag evaluation serializer"); + + @Override + public void close() { + final boolean workerRunning; + synchronized (lifecycleLock) { + if (!closed.compareAndSet(false, true)) { + return; + } + // Disable and deregister from the gateway so no new events are enqueued. + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); + FeatureFlaggingGateway.setFlagEvalWriter(null); + workerRunning = this.serializerThread.isAlive(); + if (workerRunning) { + // Ask the worker to drain the queue and final-flush, then interrupt to wake it from poll(). + serializer.requestShutdown(); + this.serializerThread.interrupt(); + } + } + if (Thread.currentThread() == this.serializerThread) { + return; + } + if (workerRunning) { + try { + // Bounded wait for the worker's final flush so queued events are not lost on shutdown. + this.serializerThread.join(TimeUnit.SECONDS.toMillis(5)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + // enqueue() is lock-free, so a producer that passed the closed check before close() ran can + // still land an event after the worker's final drain. Sweep the remainder so that loss is + // counted rather than silently stranded in a queue nobody polls again. Only safe once the + // worker is gone: the queue is single-consumer, so sweeping alongside a live worker would + // break that contract. If join() timed out the worker is still draining, so skip the sweep. + if (!this.serializerThread.isAlive()) { + sweepAndCountResidualEvents(); + } } - /** Signals the worker to drain the queue and perform a final flush before exiting. */ - void requestShutdown() { - shutdownRequested.set(true); + /** + * Counts events left in the queue after the worker has exited. Must only be called when the + * serializer thread is not alive - the queue permits a single consumer. + */ + private void sweepAndCountResidualEvents() { + long residual = 0; + while (queue.poll() != null) { + residual++; + } + countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); } @Override - public void run() { - if (!evpPublisher.start()) { - finalFlushDone.countDown(); - errorCallback.run(); - throw new IllegalArgumentException("EVP Proxy not available"); - } - try { - runDutyCycle(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - // On exit (interrupt or shutdown request), drain everything still buffered and flush it so - // queued events are not lost on shutdown. + public void enqueue(final FlagEvalEvent event) { + if (event == null) { + return; + } + if (isClosedOrEnqueueDisabled()) { + countClosedDrop(); + return; + } + // Deliberately lock-free: the hand-off queue is MPSC by design, so serializing producers on a + // monitor here would negate that and turn every evaluation in every application thread into + // contention on one lock. A producer that passed the check above can still offer after close() + // has started; that residue is accounted for by the worker's bounded post-drain passes and by + // close()'s post-join sweep, so shutdown loss stays observable. + // + // Safe publication of the event (including the context snapshot built by the hook) comes from + // the queue's own offer/poll ordering, not from any monitor held here. // - // close() interrupts this thread to break it out of poll(). The final flush does socket - // I/O, and OkHttp fails fast on a thread whose interrupt flag is set, so clear the flag - // for the duration of the drain and restore it afterwards. Without this the flush that - // close() exists to guarantee throws IOException, the aggregated rows are discarded by - // the clear() in flush(), and the loss is invisible to the drop counters because those - // rows already left the queue. - final boolean wasInterrupted = Thread.interrupted(); - try { - drainAndFlush(); - } finally { - finalFlushDone.countDown(); - if (wasInterrupted) { - Thread.currentThread().interrupt(); - } + // Non-blocking offer. Count overflow so loss is observable rather than silent; the count is + // surfaced on the next flush. The hook's pre-queue guard (see FlagEvalLoggingHook) samples the + // queue depth before doing any context-copy work, so a saturated queue costs an + // AtomicInteger.get(); the offer here still races with the worker and can legitimately fail. + if (!queue.offer(event)) { + droppedQueueOverflow.incrementAndGet(); } - } - LOGGER.debug("flag evaluation processor worker exited."); } - private void runDutyCycle() throws InterruptedException { - final Thread thread = Thread.currentThread(); - while (!thread.isInterrupted() && !shutdownRequested.get()) { - final FlagEvalEvent event = queue.poll(100, TimeUnit.MILLISECONDS); - if (event != null) { - aggregateEvent(event); - } - flushIfNecessary(); - } + /** + * Reports whether the async hand-off queue currently has room for another event. Producers + * consult this before performing any expensive context-copy work so a saturated queue is observed + * as an O(1) read rather than a full snapshot followed by a discarded offer. Best-effort only: + * the worker can drain (or a peer producer can fill) between this check and the subsequent + * enqueue, so callers must still tolerate offer failure. + */ + public boolean hasCapacityForEnqueue() { + return queue.size() < queue.capacity(); } - void drainAndFlush() { - FlagEvalEvent event; - while ((event = queue.poll()) != null) { - aggregateEvent(event); - } - flush(); + /** Counts one queue-overflow drop without offering an event. */ + public void countPreQueueOverflow() { + droppedQueueOverflow.incrementAndGet(); } - // ---- Aggregation logic ---- + @Override + public void countContextTruncated(final String reason) { + contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); + } - /** Routes an event into the full tier or degraded tier, or drops and counts on overflow. */ - void aggregateEvent(final FlagEvalEvent event) { - try { - aggregator.aggregate(event); - } catch (LinkageError | RuntimeException e) { - LOGGER.debug("Could not aggregate flag evaluation event", e); - } + private boolean isClosedOrEnqueueDisabled() { + return closed.get() || !FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled(); } - // ---- Flush logic ---- + private void countClosedDrop() { + // Count the drop for any early exit from enqueue, whether closed=true or the gate was disabled + // by the surrounding subsystem. FeatureFlaggingSystem.stop() flips the gate before this + // writer's close() runs, so an in-flight enqueue could race that flip and see gate=false while + // closed=false. Counting on either condition keeps shutdown-loss observable. + countMetric(FLAG_EVALUATION_DROPPED_METRIC, 1, DROP_REASON_CLOSED); + } - void flushIfNecessary() { - if (shouldFlush()) { - flush(); - } + /** Returns the count of events dropped due to queue-overflow backpressure (observable). */ + long droppedQueueOverflow() { + return droppedQueueOverflow.get(); } - void flush() { - // Surface backpressure (queue-overflow) drops as an observable warning even when there is - // nothing else to flush. - final long qDrops = droppedQueueOverflow.getAndSet(0); - countMetric(FLAG_EVALUATION_DROPPED_METRIC, qDrops, DROP_REASON_QUEUE_OVERFLOW); - if (qDrops > 0) { - LOGGER.warn( - "flag evaluation queue full - dropped {} evaluation(s) under backpressure" - + " (best-effort telemetry)", - qDrops); - } - final long dgDrops = aggregator.droppedDegradedOverflow.getAndSet(0); - countMetric(FLAG_EVALUATION_DROPPED_METRIC, dgDrops, DROP_REASON_DEGRADED_CAP); - if (dgDrops > 0) { - LOGGER.warn( - "degraded aggregation tier full - dropped {} evaluation(s); raise degraded cap" - + " (best-effort telemetry)", - dgDrops); - } - - // Drain per-reason context-truncation counters and emit one metric per unique reason tag. - for (final Map.Entry entry : contextTruncatedCounts.entrySet()) { - final long count = entry.getValue().getAndSet(0); - if (count > 0) { - countMetric(FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, count, entry.getKey()); - } - } - - if (aggregator.isEmpty()) { - return; - } - try { - countMetric( - FLAG_EVALUATION_DEGRADED_METRIC, - aggregator.degradedEvaluationCount(), - DEGRADED_REASON_CARDINALITY_CAP); - final List events = buildEventList(); - if (events.isEmpty()) { - return; - } - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(events, context, payloadSizeLimitBytes); - countMetric( - FLAG_EVALUATION_DROPPED_METRIC, - payloads.droppedPayloadLimit, - DROP_REASON_PAYLOAD_LIMIT); - countMetric( - FLAG_EVALUATION_DEGRADED_METRIC, - payloads.degradedPayloadLimit, - DEGRADED_REASON_PAYLOAD_LIMIT); - if (payloads.bodies.size() > 1) { - countMetric(FLAG_EVALUATION_SPLITS_METRIC, payloads.bodies.size() - 1, null); + /** Test seam: returns one queued event without starting the worker. */ + FlagEvalEvent pollQueuedEventForTest() { + return queue.poll(); + } + + /** Test seam: flushes serializer state without starting the worker. */ + void flushForTest() { + serializer.flush(); + } + + // ---- Serializing handler (background thread logic) ---- + + static class FlagEvaluationSerializingHandler implements Runnable { + private final MessagePassingBlockingQueue queue; + private final long ticksRequiredToFlush; + + @SuppressFBWarnings( + value = "AT_NONATOMIC_64BIT_PRIMITIVE", + justification = "the field is confined to the single serializer thread") + private long lastTicks; + + private final FeatureFlagEvpPublisher evpPublisher; + final Map context; + private final AtomicLong droppedQueueOverflow; + private final ConcurrentHashMap contextTruncatedCounts; + private final Runnable errorCallback; + private final int payloadSizeLimitBytes; + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + // Shutdown coordination: set by close(), drives a final drain+flush before the worker exits. + private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); + private final CountDownLatch finalFlushDone = new CountDownLatch(1); + + FlagEvaluationSerializingHandler( + final Supplier backendApiSupplier, + final MessagePassingBlockingQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context, + final AtomicLong droppedQueueOverflow, + final ConcurrentHashMap contextTruncatedCounts, + final Runnable errorCallback, + final int payloadSizeLimitBytes) { + this.queue = queue; + this.evpPublisher = new FeatureFlagEvpPublisher<>( + backendApiSupplier, FlagEvaluationPayloads.FlagEvaluationsRequest.class); + this.context = context; + this.droppedQueueOverflow = droppedQueueOverflow; + this.contextTruncatedCounts = contextTruncatedCounts; + this.payloadSizeLimitBytes = payloadSizeLimitBytes; + this.lastTicks = System.nanoTime(); + this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); + this.errorCallback = errorCallback; + LOGGER.debug("starting flag evaluation serializer"); } - if (payloads.droppedPayloadLimit > 0) { - LOGGER.warn( - "flag evaluation payload too large - dropped {} evaluation(s)" - + " (best-effort telemetry)", - payloads.droppedPayloadLimit); + + /** Signals the worker to drain the queue and perform a final flush before exiting. */ + void requestShutdown() { + shutdownRequested.set(true); } - for (final byte[] payload : payloads.bodies) { - evpPublisher.post(FLAG_EVALUATION_ROUTE, payload); + + @Override + public void run() { + if (!evpPublisher.start()) { + finalFlushDone.countDown(); + errorCallback.run(); + throw new IllegalArgumentException("EVP Proxy not available"); + } + try { + runDutyCycle(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + // On exit (interrupt or shutdown request), drain everything still buffered and flush it so + // queued events are not lost on shutdown. + // + // close() interrupts this thread to break it out of poll(). The final flush does socket + // I/O, and OkHttp fails fast on a thread whose interrupt flag is set, so clear the flag + // for the duration of the drain and restore it afterwards. Without this the flush that + // close() exists to guarantee throws IOException, the aggregated rows are discarded by + // the clear() in flush(), and the loss is invisible to the drop counters because those + // rows already left the queue. + final boolean wasInterrupted = Thread.interrupted(); + try { + drainAndFlush(); + } finally { + finalFlushDone.countDown(); + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } + } + LOGGER.debug("flag evaluation processor worker exited."); } - } catch (Exception e) { - LOGGER.error("Could not submit flag evaluations", e); - } finally { - // Best-effort: always clear the aggregator after a flush attempt. Retaining buckets across - // flushes on encode failure would let one unserializable value (for example a NaN Double a - // customer put in the context) permanently block every subsequent flush. - aggregator.clear(); - lastTicks = System.nanoTime(); - } - } - private List buildEventList() { - final long flushTimeMs = System.currentTimeMillis(); - // Consent is read per bucket from the value each event snapshotted at evaluation time, not - // from the gateway here: CURRENT_CONFIG may have been overwritten by a later RC update since - // these evaluations happened, and reading it at flush would apply the wrong config's consent. - final List events = - new ArrayList<>(aggregator.bucketCount()); - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullBuckets()) { - events.add( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, bucket.observeFullEvaluationData, flushTimeMs)); - } - for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedBuckets()) { - events.add( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, false, bucket.observeFullEvaluationData, flushTimeMs)); - } - return events; - } + private void runDutyCycle() throws InterruptedException { + final Thread thread = Thread.currentThread(); + while (!thread.isInterrupted() && !shutdownRequested.get()) { + final FlagEvalEvent event = queue.poll(100, TimeUnit.MILLISECONDS); + if (event != null) { + aggregateEvent(event); + } + flushIfNecessary(); + } + } - private boolean shouldFlush() { - if (aggregator.isEmpty() && droppedQueueOverflow.get() == 0) { - return false; - } - final long nanoTime = System.nanoTime(); - final long ticks = nanoTime - lastTicks; - if (ticks > ticksRequiredToFlush) { - lastTicks = nanoTime; - return true; - } - return false; - } - } + void drainAndFlush() { + FlagEvalEvent event; + while ((event = queue.poll()) != null) { + aggregateEvent(event); + } + flush(); + } - // ---- Test-seam inner class (package-private) ---- + // ---- Aggregation logic ---- - /** - * Test-accessible handler that exposes {@link #drainAndAggregate()} and {@link #flush()} without - * starting a real background thread. - */ - static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { + /** Routes an event into the full tier or degraded tier, or drops and counts on overflow. */ + void aggregateEvent(final FlagEvalEvent event) { + try { + aggregator.aggregate(event); + } catch (LinkageError | RuntimeException e) { + LOGGER.debug("Could not aggregate flag evaluation event", e); + } + } - SerializingHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - this(backendApiSupplier, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - } + // ---- Flush logic ---- - SerializingHandlerForTest( - final Supplier backendApiSupplier, - final Map context, - final int payloadSizeLimitBytes) { - super( - backendApiSupplier, - Queues.mpscBlockingConsumerArrayQueue(DEFAULT_CAPACITY), - Long.MAX_VALUE, // effectively never auto-flush - TimeUnit.NANOSECONDS, - context, - new AtomicLong(0), - new ConcurrentHashMap<>(), - () -> {}, - payloadSizeLimitBytes); - } + void flushIfNecessary() { + if (shouldFlush()) { + flush(); + } + } - private final List staged = new ArrayList<>(); + void flush() { + // Surface backpressure (queue-overflow) drops as an observable warning even when there is + // nothing else to flush. + final long qDrops = droppedQueueOverflow.getAndSet(0); + countMetric(FLAG_EVALUATION_DROPPED_METRIC, qDrops, DROP_REASON_QUEUE_OVERFLOW); + if (qDrops > 0) { + LOGGER.warn( + "flag evaluation queue full - dropped {} evaluation(s) under backpressure" + + " (best-effort telemetry)", + qDrops); + } + final long dgDrops = aggregator.droppedDegradedOverflow.getAndSet(0); + countMetric(FLAG_EVALUATION_DROPPED_METRIC, dgDrops, DROP_REASON_DEGRADED_CAP); + if (dgDrops > 0) { + LOGGER.warn( + "degraded aggregation tier full - dropped {} evaluation(s); raise degraded cap" + + " (best-effort telemetry)", + dgDrops); + } + + // Drain per-reason context-truncation counters and emit one metric per unique reason tag. + for (final Map.Entry entry : contextTruncatedCounts.entrySet()) { + final long count = entry.getValue().getAndSet(0); + if (count > 0) { + countMetric(FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, count, entry.getKey()); + } + } + + if (aggregator.isEmpty()) { + return; + } + try { + countMetric( + FLAG_EVALUATION_DEGRADED_METRIC, + aggregator.degradedEvaluationCount(), + DEGRADED_REASON_CARDINALITY_CAP); + final List events = buildEventList(); + if (events.isEmpty()) { + return; + } + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(events, context, payloadSizeLimitBytes); + countMetric(FLAG_EVALUATION_DROPPED_METRIC, payloads.droppedPayloadLimit, DROP_REASON_PAYLOAD_LIMIT); + countMetric( + FLAG_EVALUATION_DEGRADED_METRIC, payloads.degradedPayloadLimit, DEGRADED_REASON_PAYLOAD_LIMIT); + if (payloads.bodies.size() > 1) { + countMetric(FLAG_EVALUATION_SPLITS_METRIC, payloads.bodies.size() - 1, null); + } + if (payloads.droppedPayloadLimit > 0) { + LOGGER.warn( + "flag evaluation payload too large - dropped {} evaluation(s)" + " (best-effort telemetry)", + payloads.droppedPayloadLimit); + } + for (final byte[] payload : payloads.bodies) { + evpPublisher.post(FLAG_EVALUATION_ROUTE, payload); + } + } catch (Exception e) { + LOGGER.error("Could not submit flag evaluations", e); + } finally { + // Best-effort: always clear the aggregator after a flush attempt. Retaining buckets across + // flushes on encode failure would let one unserializable value (for example a NaN Double a + // customer put in the context) permanently block every subsequent flush. + aggregator.clear(); + lastTicks = System.nanoTime(); + } + } - /** Adds an event to the staged list (simulates hook enqueue). */ - void add(final FlagEvalEvent event) { - staged.add(event); - } + private List buildEventList() { + final long flushTimeMs = System.currentTimeMillis(); + // Consent is read per bucket from the value each event snapshotted at evaluation time, not + // from the gateway here: CURRENT_CONFIG may have been overwritten by a later RC update since + // these evaluations happened, and reading it at flush would apply the wrong config's consent. + final List events = new ArrayList<>(aggregator.bucketCount()); + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullBuckets()) { + events.add(FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, bucket.observeFullEvaluationData, flushTimeMs)); + } + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedBuckets()) { + events.add(FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, false, bucket.observeFullEvaluationData, flushTimeMs)); + } + return events; + } - /** Aggregates all staged events and returns the current aggregation state. */ - FlagEvaluationAggregator.AggregatedState drainAndAggregate() { - for (final FlagEvalEvent e : staged) { - aggregateEvent(e); - } - staged.clear(); - return aggregator.snapshot(); + private boolean shouldFlush() { + if (aggregator.isEmpty() && droppedQueueOverflow.get() == 0) { + return false; + } + final long nanoTime = System.nanoTime(); + final long ticks = nanoTime - lastTicks; + if (ticks > ticksRequiredToFlush) { + lastTicks = nanoTime; + return true; + } + return false; + } } - /** Simulates filling the full tier to GLOBAL_CAP by injecting synthetic distinct buckets. */ - void simulateFullTierAtCap() { - aggregator.simulateFullTierAtCap(); - } + // ---- Test-seam inner class (package-private) ---- /** - * Simulates filling the degraded tier to DEGRADED_CAP by injecting synthetic distinct buckets. + * Test-accessible handler that exposes {@link #drainAndAggregate()} and {@link #flush()} without + * starting a real background thread. */ - void simulateDegradedTierAtCap() { - aggregator.simulateDegradedTierAtCap(); - } + static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { - void addDroppedDegradedOverflowForTest(final long count) { - aggregator.droppedDegradedOverflow.addAndGet(count); - } + SerializingHandlerForTest(final Supplier backendApiSupplier, final Map context) { + this(backendApiSupplier, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + } + + SerializingHandlerForTest( + final Supplier backendApiSupplier, + final Map context, + final int payloadSizeLimitBytes) { + super( + backendApiSupplier, + Queues.mpscBlockingConsumerArrayQueue(DEFAULT_CAPACITY), + Long.MAX_VALUE, // effectively never auto-flush + TimeUnit.NANOSECONDS, + context, + new AtomicLong(0), + new ConcurrentHashMap<>(), + () -> {}, + payloadSizeLimitBytes); + } + + private final List staged = new ArrayList<>(); + + /** Adds an event to the staged list (simulates hook enqueue). */ + void add(final FlagEvalEvent event) { + staged.add(event); + } + + /** Aggregates all staged events and returns the current aggregation state. */ + FlagEvaluationAggregator.AggregatedState drainAndAggregate() { + for (final FlagEvalEvent e : staged) { + aggregateEvent(e); + } + staged.clear(); + return aggregator.snapshot(); + } + + /** Simulates filling the full tier to GLOBAL_CAP by injecting synthetic distinct buckets. */ + void simulateFullTierAtCap() { + aggregator.simulateFullTierAtCap(); + } + + /** + * Simulates filling the degraded tier to DEGRADED_CAP by injecting synthetic distinct buckets. + */ + void simulateDegradedTierAtCap() { + aggregator.simulateDegradedTierAtCap(); + } + + void addDroppedDegradedOverflowForTest(final long count) { + aggregator.droppedDegradedOverflow.addAndGet(count); + } - void addDegradedBucketForTest( - final String flagKey, - final String variant, - final String allocationKey, - final String errorMessage, - final long evalTimeMs) { - aggregator.addDegradedBucketForTest( - flagKey, variant, allocationKey, errorMessage, evalTimeMs); + void addDegradedBucketForTest( + final String flagKey, + final String variant, + final String allocationKey, + final String errorMessage, + final long evalTimeMs) { + aggregator.addDegradedBucketForTest(flagKey, variant, allocationKey, errorMessage, evalTimeMs); + } + + void clearAggregationForTest() { + aggregator.clear(); + } + + int fullTierSizeForTest() { + return aggregator.fullTierSize(); + } } - void clearAggregationForTest() { - aggregator.clear(); + /** Factory method for test use - creates a SerializingHandlerForTest. */ + static SerializingHandlerForTest createHandlerForTest( + final Supplier backendApiSupplier, final Map context) { + return new SerializingHandlerForTest(backendApiSupplier, context); } - int fullTierSizeForTest() { - return aggregator.fullTierSize(); + static SerializingHandlerForTest createHandlerForTest( + final Supplier backendApiSupplier, + final Map context, + final int payloadSizeLimitBytes) { + return new SerializingHandlerForTest(backendApiSupplier, context, payloadSizeLimitBytes); } - } - - /** Factory method for test use - creates a SerializingHandlerForTest. */ - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - return new SerializingHandlerForTest(backendApiSupplier, context); - } - - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, - final Map context, - final int payloadSizeLimitBytes) { - return new SerializingHandlerForTest(backendApiSupplier, context, payloadSizeLimitBytes); - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java index 0a47d316075..f808efd2ef9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java @@ -10,83 +10,79 @@ final class JsonApiUfcResponseParser { - private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; - private static final JsonReader.Options RESPONSE_FIELDS = JsonReader.Options.of("data"); - private static final JsonReader.Options DATA_FIELDS = JsonReader.Options.of("type", "attributes"); + private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; + private static final JsonReader.Options RESPONSE_FIELDS = JsonReader.Options.of("data"); + private static final JsonReader.Options DATA_FIELDS = JsonReader.Options.of("type", "attributes"); - static final JsonApiUfcResponseParser INSTANCE = - new JsonApiUfcResponseParser(UniversalFlagConfigParser.INSTANCE); + static final JsonApiUfcResponseParser INSTANCE = new JsonApiUfcResponseParser(UniversalFlagConfigParser.INSTANCE); - private final UniversalFlagConfigParser ufcParser; + private final UniversalFlagConfigParser ufcParser; - JsonApiUfcResponseParser(final UniversalFlagConfigParser ufcParser) { - this.ufcParser = ufcParser; - } + JsonApiUfcResponseParser(final UniversalFlagConfigParser ufcParser) { + this.ufcParser = ufcParser; + } - @Nullable - ServerConfiguration parse(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - if (reader.selectName(RESPONSE_FIELDS) == 0) { - configuration = parseData(reader); - } else { - reader.skipName(); - reader.skipValue(); + @Nullable + ServerConfiguration parse(final byte[] content) throws IOException { + try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { + final JsonReader reader = JsonReader.of(source); + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue(); + return null; + } + ServerConfiguration configuration = null; + reader.beginObject(); + while (reader.hasNext()) { + if (reader.selectName(RESPONSE_FIELDS) == 0) { + configuration = parseData(reader); + } else { + reader.skipName(); + reader.skipValue(); + } + } + reader.endObject(); + requireEndOfDocument(reader); + return configuration; } - } - reader.endObject(); - requireEndOfDocument(reader); - return configuration; } - } - @Nullable - private ServerConfiguration parseData(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String type = null; - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - switch (reader.selectName(DATA_FIELDS)) { - case 0: - if (reader.peek() == JsonReader.Token.STRING) { - type = reader.nextString(); - } else { + @Nullable + private ServerConfiguration parseData(final JsonReader reader) throws IOException { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { reader.skipValue(); - } - break; - case 1: - configuration = ufcParser.parse(reader); - break; - default: - reader.skipName(); - reader.skipValue(); - } + return null; + } + String type = null; + ServerConfiguration configuration = null; + reader.beginObject(); + while (reader.hasNext()) { + switch (reader.selectName(DATA_FIELDS)) { + case 0: + if (reader.peek() == JsonReader.Token.STRING) { + type = reader.nextString(); + } else { + reader.skipValue(); + } + break; + case 1: + configuration = ufcParser.parse(reader); + break; + default: + reader.skipName(); + reader.skipValue(); + } + } + reader.endObject(); + return UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(type) ? validConfiguration(configuration) : null; } - reader.endObject(); - return UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(type) - ? validConfiguration(configuration) - : null; - } - @Nullable - private static ServerConfiguration validConfiguration( - @Nullable final ServerConfiguration configuration) { - return configuration != null && configuration.flags != null ? configuration : null; - } + @Nullable + private static ServerConfiguration validConfiguration(@Nullable final ServerConfiguration configuration) { + return configuration != null && configuration.flags != null ? configuration : null; + } - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } + private static void requireEndOfDocument(final JsonReader reader) throws IOException { + // A strict JsonReader throws if another top-level value follows the parsed document. + reader.peek(); + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java index e21752cc529..4a9e1482fd0 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java @@ -10,27 +10,27 @@ */ public class LRUExposureCache implements ExposureCache { - private final Map cache; + private final Map cache; - public LRUExposureCache(final int capacity) { - cache = new LRUCache<>(capacity); - } + public LRUExposureCache(final int capacity) { + cache = new LRUCache<>(capacity); + } - @Override - public boolean add(final ExposureEvent event) { - final Key key = new Key(event); - final Value value = new Value(event); - final Value oldValue = cache.put(key, value); - return oldValue == null || !oldValue.equals(value); - } + @Override + public boolean add(final ExposureEvent event) { + final Key key = new Key(event); + final Value value = new Value(event); + final Value oldValue = cache.put(key, value); + return oldValue == null || !oldValue.equals(value); + } - @Override - public Value get(final Key key) { - return cache.get(key); - } + @Override + public Value get(final Key key) { + return cache.get(key); + } - @Override - public int size() { - return cache.size(); - } + @Override + public int size() { + return cache.size(); + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index e66a6231990..8b06548b4cc 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -12,33 +12,33 @@ import javax.annotation.Nullable; public class RemoteConfigServiceImpl - implements ConfigurationSourceService, ConfigurationChangesTypedListener { + implements ConfigurationSourceService, ConfigurationChangesTypedListener { - private final ConfigurationPoller configurationPoller; + private final ConfigurationPoller configurationPoller; - public RemoteConfigServiceImpl(final SharedCommunicationObjects sco, final Config config) { - configurationPoller = sco.configurationPoller(config); - } + public RemoteConfigServiceImpl(final SharedCommunicationObjects sco, final Config config) { + configurationPoller = sco.configurationPoller(config); + } - @Override - public void init() { - configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - configurationPoller.addListener(Product.FFE_FLAGS, UniversalFlagConfigParser.INSTANCE, this); - configurationPoller.start(); - } + @Override + public void init() { + configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + configurationPoller.addListener(Product.FFE_FLAGS, UniversalFlagConfigParser.INSTANCE, this); + configurationPoller.start(); + } - @Override - public void close() { - configurationPoller.removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - configurationPoller.removeListeners(Product.FFE_FLAGS); - configurationPoller.stop(); - } + @Override + public void close() { + configurationPoller.removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + configurationPoller.removeListeners(Product.FFE_FLAGS); + configurationPoller.stop(); + } - @Override - public void accept( - final String configKey, - @Nullable final ServerConfiguration configuration, - final PollingRateHinter pollingRateHinter) { - FeatureFlaggingGateway.dispatch(configuration); - } + @Override + public void accept( + final String configKey, + @Nullable final ServerConfiguration configuration, + final PollingRateHinter pollingRateHinter) { + FeatureFlaggingGateway.dispatch(configuration); + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java index a14274432b0..0408ec149b9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java @@ -34,224 +34,226 @@ */ final class SpanEnrichmentAccumulator { - static final int MAX_SERIAL_IDS = 200; - static final int MAX_SUBJECTS = 10; - static final int MAX_EXPERIMENTS_PER_SUBJECT = 20; - static final int MAX_DEFAULTS = 5; - static final int MAX_DEFAULT_VALUE_LENGTH = 64; + static final int MAX_SERIAL_IDS = 200; + static final int MAX_SUBJECTS = 10; + static final int MAX_EXPERIMENTS_PER_SUBJECT = 20; + static final int MAX_DEFAULTS = 5; + static final int MAX_DEFAULT_VALUE_LENGTH = 64; - static final String TAG_FLAGS_ENC = "ffe_flags_enc"; - static final String TAG_SUBJECTS_ENC = "ffe_subjects_enc"; - static final String TAG_RUNTIME_DEFAULTS = "ffe_runtime_defaults"; + static final String TAG_FLAGS_ENC = "ffe_flags_enc"; + static final String TAG_SUBJECTS_ENC = "ffe_subjects_enc"; + static final String TAG_RUNTIME_DEFAULTS = "ffe_runtime_defaults"; - // dedupe is structural (a Set); sorted for deterministic encoding. - private final TreeSet serialIds = new TreeSet<>(); - // sha256hex(targetingKey) -> serial ids. LinkedHashMap for stable iteration order. - private final Map> subjects = new LinkedHashMap<>(); - // flagKey -> value string (first-wins, truncated to MAX_DEFAULT_VALUE_LENGTH). - private final Map defaults = new LinkedHashMap<>(); + // dedupe is structural (a Set); sorted for deterministic encoding. + private final TreeSet serialIds = new TreeSet<>(); + // sha256hex(targetingKey) -> serial ids. LinkedHashMap for stable iteration order. + private final Map> subjects = new LinkedHashMap<>(); + // flagKey -> value string (first-wins, truncated to MAX_DEFAULT_VALUE_LENGTH). + private final Map defaults = new LinkedHashMap<>(); - /** Adds a serial id, dropping silently once {@link #MAX_SERIAL_IDS} is reached. */ - synchronized void addSerialId(final int id) { - if (serialIds.size() >= MAX_SERIAL_IDS && !serialIds.contains(id)) { - return; + /** Adds a serial id, dropping silently once {@link #MAX_SERIAL_IDS} is reached. */ + synchronized void addSerialId(final int id) { + if (serialIds.size() >= MAX_SERIAL_IDS && !serialIds.contains(id)) { + return; + } + serialIds.add(id); } - serialIds.add(id); - } - /** - * Records that the given targeting key was exposed to the experiment identified by {@code id}. - * The targeting key is SHA-256-hashed before storage. Enforces both the subject cap ({@link - * #MAX_SUBJECTS}) and the per-subject experiment cap ({@link #MAX_EXPERIMENTS_PER_SUBJECT}). - */ - synchronized void addSubject(final String targetingKey, final int id) { - if (targetingKey == null) { - return; + /** + * Records that the given targeting key was exposed to the experiment identified by {@code id}. + * The targeting key is SHA-256-hashed before storage. Enforces both the subject cap ({@link + * #MAX_SUBJECTS}) and the per-subject experiment cap ({@link #MAX_EXPERIMENTS_PER_SUBJECT}). + */ + synchronized void addSubject(final String targetingKey, final int id) { + if (targetingKey == null) { + return; + } + final String hashed = ULeb128Encoder.hashTargetingKey(targetingKey); + final TreeSet existing = subjects.get(hashed); + if (existing != null) { + if (existing.size() >= MAX_EXPERIMENTS_PER_SUBJECT && !existing.contains(id)) { + return; + } + existing.add(id); + return; + } + if (subjects.size() >= MAX_SUBJECTS) { + return; + } + final TreeSet ids = new TreeSet<>(); + ids.add(id); + subjects.put(hashed, ids); } - final String hashed = ULeb128Encoder.hashTargetingKey(targetingKey); - final TreeSet existing = subjects.get(hashed); - if (existing != null) { - if (existing.size() >= MAX_EXPERIMENTS_PER_SUBJECT && !existing.contains(id)) { - return; - } - existing.add(id); - return; - } - if (subjects.size() >= MAX_SUBJECTS) { - return; - } - final TreeSet ids = new TreeSet<>(); - ids.add(id); - subjects.put(hashed, ids); - } - /** - * Records a runtime-default value for {@code flagKey} (first-wins). Structured values (Map/List) - * are serialized to JSON (NOT {@code toString()}); the result is truncated to {@link - * #MAX_DEFAULT_VALUE_LENGTH}. - */ - synchronized void addDefault(final String flagKey, final Object value) { - if (flagKey == null) { - return; - } - if (defaults.containsKey(flagKey)) { - return; // first-wins - } - if (defaults.size() >= MAX_DEFAULTS) { - return; + /** + * Records a runtime-default value for {@code flagKey} (first-wins). Structured values (Map/List) + * are serialized to JSON (NOT {@code toString()}); the result is truncated to {@link + * #MAX_DEFAULT_VALUE_LENGTH}. + */ + synchronized void addDefault(final String flagKey, final Object value) { + if (flagKey == null) { + return; + } + if (defaults.containsKey(flagKey)) { + return; // first-wins + } + if (defaults.size() >= MAX_DEFAULTS) { + return; + } + String valueStr = stringifyDefault(value); + if (valueStr.length() > MAX_DEFAULT_VALUE_LENGTH) { + valueStr = utf8SafeTruncate(valueStr, MAX_DEFAULT_VALUE_LENGTH); + } + defaults.put(flagKey, valueStr); } - String valueStr = stringifyDefault(value); - if (valueStr.length() > MAX_DEFAULT_VALUE_LENGTH) { - valueStr = utf8SafeTruncate(valueStr, MAX_DEFAULT_VALUE_LENGTH); - } - defaults.put(flagKey, valueStr); - } - - /** - * @return true when there is at least one serial id or runtime default to write. Subjects are not - * checked because a subject is never recorded without its serial id. - */ - synchronized boolean hasData() { - return !serialIds.isEmpty() || !defaults.isEmpty(); - } - /** - * Builds the {@code ffe_*} span tags from the accumulated state. Empty groups are omitted. - * - * @return a map of tag name to tag value (a subset of {@code ffe_flags_enc}, {@code - * ffe_subjects_enc}, {@code ffe_runtime_defaults}) - */ - synchronized Map toSpanTags() { - final Map tags = new LinkedHashMap<>(); - if (!serialIds.isEmpty()) { - final String encoded = ULeb128Encoder.encodeDeltaVarint(serialIds); - if (!encoded.isEmpty()) { - tags.put(TAG_FLAGS_ENC, encoded); - } - } - if (!subjects.isEmpty()) { - final Map encodedSubjects = new LinkedHashMap<>(); - for (final Map.Entry> entry : subjects.entrySet()) { - encodedSubjects.put(entry.getKey(), ULeb128Encoder.encodeDeltaVarint(entry.getValue())); - } - tags.put(TAG_SUBJECTS_ENC, toJsonObject(encodedSubjects)); + /** + * @return true when there is at least one serial id or runtime default to write. Subjects are not + * checked because a subject is never recorded without its serial id. + */ + synchronized boolean hasData() { + return !serialIds.isEmpty() || !defaults.isEmpty(); } - if (!defaults.isEmpty()) { - tags.put(TAG_RUNTIME_DEFAULTS, toJsonObject(defaults)); + + /** + * Builds the {@code ffe_*} span tags from the accumulated state. Empty groups are omitted. + * + * @return a map of tag name to tag value (a subset of {@code ffe_flags_enc}, {@code + * ffe_subjects_enc}, {@code ffe_runtime_defaults}) + */ + synchronized Map toSpanTags() { + final Map tags = new LinkedHashMap<>(); + if (!serialIds.isEmpty()) { + final String encoded = ULeb128Encoder.encodeDeltaVarint(serialIds); + if (!encoded.isEmpty()) { + tags.put(TAG_FLAGS_ENC, encoded); + } + } + if (!subjects.isEmpty()) { + final Map encodedSubjects = new LinkedHashMap<>(); + for (final Map.Entry> entry : subjects.entrySet()) { + encodedSubjects.put(entry.getKey(), ULeb128Encoder.encodeDeltaVarint(entry.getValue())); + } + tags.put(TAG_SUBJECTS_ENC, toJsonObject(encodedSubjects)); + } + if (!defaults.isEmpty()) { + tags.put(TAG_RUNTIME_DEFAULTS, toJsonObject(defaults)); + } + return tags; } - return tags; - } - // ---- helpers (visible for tests) ---- + // ---- helpers (visible for tests) ---- - /** - * Mirrors the Node {@code (typeof value === 'object' && value !== null) ? JSON.stringify(value) : - * String(value)} rule: structured values (Map/List/array) are JSON-stringified; scalars use their - * string form; {@code null} becomes the bare {@code null}. - * - *

The value has already been unwrapped to a native Java type by the capture side (any - * OpenFeature {@code Value} is converted to Map/List/scalar before the seam), so no OpenFeature - * type ever reaches here. - */ - static String stringifyDefault(final Object value) { - if (value == null) { - return "null"; - } - if (value instanceof Map || value instanceof Iterable || value.getClass().isArray()) { - return toJsonValue(value); + /** + * Mirrors the Node {@code (typeof value === 'object' && value !== null) ? JSON.stringify(value) : + * String(value)} rule: structured values (Map/List/array) are JSON-stringified; scalars use their + * string form; {@code null} becomes the bare {@code null}. + * + *

The value has already been unwrapped to a native Java type by the capture side (any + * OpenFeature {@code Value} is converted to Map/List/scalar before the seam), so no OpenFeature + * type ever reaches here. + */ + static String stringifyDefault(final Object value) { + if (value == null) { + return "null"; + } + if (value instanceof Map + || value instanceof Iterable + || value.getClass().isArray()) { + return toJsonValue(value); + } + if (value instanceof CharSequence || value instanceof Character) { + return value.toString(); + } + // Numbers / booleans — their string form matches what Node's String(value) emits for these + // scalar cases. + return String.valueOf(value); } - if (value instanceof CharSequence || value instanceof Character) { - return value.toString(); - } - // Numbers / booleans — their string form matches what Node's String(value) emits for these - // scalar cases. - return String.valueOf(value); - } - /** UTF-8-safe truncation: never split a surrogate pair at the {@code maxChars} boundary. */ - static String utf8SafeTruncate(final String value, final int maxChars) { - if (value.length() <= maxChars) { - return value; - } - int end = maxChars; - if (Character.isHighSurrogate(value.charAt(end - 1))) { - end--; // drop the dangling high surrogate rather than emit a broken pair + /** UTF-8-safe truncation: never split a surrogate pair at the {@code maxChars} boundary. */ + static String utf8SafeTruncate(final String value, final int maxChars) { + if (value.length() <= maxChars) { + return value; + } + int end = maxChars; + if (Character.isHighSurrogate(value.charAt(end - 1))) { + end--; // drop the dangling high surrogate rather than emit a broken pair + } + return value.substring(0, end); } - return value.substring(0, end); - } - /** - * Serializes a String->String map to a compact JSON object string using the platform {@link - * JsonWriter}. - * - *

Consumers of these tags parse them as JSON (the backend enricher via Jackson, the parametric - * system-tests via {@code json.loads}), so the writer's escaping (e.g. {@code /} → {@code \/}, - * non-ASCII → {@code \\uXXXX}) is round-trip-equivalent and byte-parity with the JS reference is - * not required. - */ - static String toJsonObject(final Map map) { - try (JsonWriter writer = new JsonWriter()) { - writer.beginObject(); - for (final Map.Entry entry : map.entrySet()) { - writer.name(entry.getKey()).value(entry.getValue()); - } - writer.endObject(); - return writer.toString(); + /** + * Serializes a String->String map to a compact JSON object string using the platform {@link + * JsonWriter}. + * + *

Consumers of these tags parse them as JSON (the backend enricher via Jackson, the parametric + * system-tests via {@code json.loads}), so the writer's escaping (e.g. {@code /} → {@code \/}, + * non-ASCII → {@code \\uXXXX}) is round-trip-equivalent and byte-parity with the JS reference is + * not required. + */ + static String toJsonObject(final Map map) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + for (final Map.Entry entry : map.entrySet()) { + writer.name(entry.getKey()).value(entry.getValue()); + } + writer.endObject(); + return writer.toString(); + } } - } - private static String toJsonValue(final Object value) { - try (JsonWriter writer = new JsonWriter()) { - writeJsonValue(writer, value); - return writer.toString(); + private static String toJsonValue(final Object value) { + try (JsonWriter writer = new JsonWriter()) { + writeJsonValue(writer, value); + return writer.toString(); + } } - } - @SuppressWarnings("unchecked") - private static void writeJsonValue(final JsonWriter writer, final Object value) { - // Callers pass values already unwrapped to native form by the capture side, so no OpenFeature - // Value ever reaches here. - if (value == null) { - writer.nullValue(); - } else if (value instanceof Map) { - writer.beginObject(); - for (final Map.Entry entry : ((Map) value).entrySet()) { - writer.name(String.valueOf(entry.getKey())); - writeJsonValue(writer, entry.getValue()); - } - writer.endObject(); - } else if (value instanceof Iterable) { - writer.beginArray(); - for (final Object element : (Iterable) value) { - writeJsonValue(writer, element); - } - writer.endArray(); - } else if (value instanceof Boolean) { - writer.value((Boolean) value); - } else if (value instanceof Integer - || value instanceof Long - || value instanceof Short - || value instanceof Byte) { - writer.value(((Number) value).longValue()); - } else if (value instanceof Number) { - writer.value(((Number) value).doubleValue()); - } else { - // CharSequence / Character / anything else → string form. - writer.value(value.toString()); + @SuppressWarnings("unchecked") + private static void writeJsonValue(final JsonWriter writer, final Object value) { + // Callers pass values already unwrapped to native form by the capture side, so no OpenFeature + // Value ever reaches here. + if (value == null) { + writer.nullValue(); + } else if (value instanceof Map) { + writer.beginObject(); + for (final Map.Entry entry : ((Map) value).entrySet()) { + writer.name(String.valueOf(entry.getKey())); + writeJsonValue(writer, entry.getValue()); + } + writer.endObject(); + } else if (value instanceof Iterable) { + writer.beginArray(); + for (final Object element : (Iterable) value) { + writeJsonValue(writer, element); + } + writer.endArray(); + } else if (value instanceof Boolean) { + writer.value((Boolean) value); + } else if (value instanceof Integer + || value instanceof Long + || value instanceof Short + || value instanceof Byte) { + writer.value(((Number) value).longValue()); + } else if (value instanceof Number) { + writer.value(((Number) value).doubleValue()); + } else { + // CharSequence / Character / anything else → string form. + writer.value(value.toString()); + } } - } - // ---- test-only accessors ---- + // ---- test-only accessors ---- - synchronized Set serialIdsView() { - return new TreeSet<>(serialIds); - } + synchronized Set serialIdsView() { + return new TreeSet<>(serialIds); + } - synchronized int subjectCount() { - return subjects.size(); - } + synchronized int subjectCount() { + return subjects.size(); + } - synchronized int defaultCount() { - return defaults.size(); - } + synchronized int defaultCount() { + return defaults.size(); + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java index a82e194e248..ccd4efb8c1c 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java @@ -46,88 +46,86 @@ */ final class SpanEnrichmentInterceptor implements TraceInterceptor { - private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentInterceptor.class); + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentInterceptor.class); - /** - * Unique priority in the "trace data enrichment" band, after {@code GIT_METADATA} (3) and before - * the custom-sampling band ({@code Integer.MAX_VALUE - 2}). Distinct from every value in {@code - * AbstractTraceInterceptor.Priority} and from the CI Visibility interceptors. - */ - static final int PRIORITY = 4; + /** + * Unique priority in the "trace data enrichment" band, after {@code GIT_METADATA} (3) and before + * the custom-sampling band ({@code Integer.MAX_VALUE - 2}). Distinct from every value in {@code + * AbstractTraceInterceptor.Priority} and from the CI Visibility interceptors. + */ + static final int PRIORITY = 4; - private final SpanEnrichmentStates states; + private final SpanEnrichmentStates states; - SpanEnrichmentInterceptor(final SpanEnrichmentStates states) { - this.states = states; - } + SpanEnrichmentInterceptor(final SpanEnrichmentStates states) { + this.states = states; + } - @Override - public Collection onTraceComplete( - final Collection trace) { - try { - // Fast path: no accumulated state at all → skip the per-flush scan + lock entirely. This is - // the common case for services that never evaluate a flag on a given trace. - if (trace == null || trace.isEmpty() || states.isEmpty()) { - return trace; - } - // Resolve the local root for this fragment, then require that the root is actually PRESENT in - // this collection. A partial flush excludes the still-open root, so its absence means "not - // the final write" — keep the accumulator and bail. - final MutableSpan localRoot = findLocalRootInFragment(trace); - if (!(localRoot instanceof AgentSpan)) { - return trace; // partial flush, or no resolvable in-fragment root: keep state untouched - } - // Key by the local-root span object to match the capture-side keying. - final SpanEnrichmentAccumulator state = states.remove((AgentSpan) localRoot); - if (state == null || !state.hasData()) { + @Override + public Collection onTraceComplete(final Collection trace) { + try { + // Fast path: no accumulated state at all → skip the per-flush scan + lock entirely. This is + // the common case for services that never evaluate a flag on a given trace. + if (trace == null || trace.isEmpty() || states.isEmpty()) { + return trace; + } + // Resolve the local root for this fragment, then require that the root is actually PRESENT in + // this collection. A partial flush excludes the still-open root, so its absence means "not + // the final write" — keep the accumulator and bail. + final MutableSpan localRoot = findLocalRootInFragment(trace); + if (!(localRoot instanceof AgentSpan)) { + return trace; // partial flush, or no resolvable in-fragment root: keep state untouched + } + // Key by the local-root span object to match the capture-side keying. + final SpanEnrichmentAccumulator state = states.remove((AgentSpan) localRoot); + if (state == null || !state.hasData()) { + return trace; + } + // toSpanTags() only ever returns present, non-empty values, so write them directly. + for (final Map.Entry tag : state.toSpanTags().entrySet()) { + localRoot.setTag(tag.getKey(), tag.getValue()); + } + } catch (final Throwable t) { + // Never let span enrichment break trace finish; a debug line aids diagnosis if it does. + log.debug("Span-enrichment tag write failed", t); + } return trace; - } - // toSpanTags() only ever returns present, non-empty values, so write them directly. - for (final Map.Entry tag : state.toSpanTags().entrySet()) { - localRoot.setTag(tag.getKey(), tag.getValue()); - } - } catch (final Throwable t) { - // Never let span enrichment break trace finish; a debug line aids diagnosis if it does. - log.debug("Span-enrichment tag write failed", t); } - return trace; - } - /** - * Resolves the local root span for this fragment and returns it ONLY if it is actually present in - * the fragment by reference identity. Returns {@code null} when the root is not in the collection - * (a partial flush excludes the still-open root) or when no root can be safely identified. - * - *

We never guess: a non-root span is never returned. If the first span reports a non-null - * local root, we accept it only after confirming that exact object is in the fragment; otherwise - * we look for a span that is provably its own local root and present. - */ - private static MutableSpan findLocalRootInFragment( - final Collection trace) { - final MutableSpan first = trace.iterator().next(); - final MutableSpan candidate = first.getLocalRootSpan(); - if (candidate != null) { - // Accept the reported local root only if it is genuinely part of THIS fragment. On a partial - // flush the root is reachable by reference but NOT in the collection → reject (keep state). - for (final MutableSpan span : trace) { - if (span == candidate) { - return candidate; + /** + * Resolves the local root span for this fragment and returns it ONLY if it is actually present in + * the fragment by reference identity. Returns {@code null} when the root is not in the collection + * (a partial flush excludes the still-open root) or when no root can be safely identified. + * + *

We never guess: a non-root span is never returned. If the first span reports a non-null + * local root, we accept it only after confirming that exact object is in the fragment; otherwise + * we look for a span that is provably its own local root and present. + */ + private static MutableSpan findLocalRootInFragment(final Collection trace) { + final MutableSpan first = trace.iterator().next(); + final MutableSpan candidate = first.getLocalRootSpan(); + if (candidate != null) { + // Accept the reported local root only if it is genuinely part of THIS fragment. On a partial + // flush the root is reachable by reference but NOT in the collection → reject (keep state). + for (final MutableSpan span : trace) { + if (span == candidate) { + return candidate; + } + } + return null; // root excluded from this fragment → partial flush, do not flush/remove } - } - return null; // root excluded from this fragment → partial flush, do not flush/remove - } - // Local root unknown for the first span: only accept a span that is provably its own local root - // and present here. Never fall back to an arbitrary span. - for (final MutableSpan span : trace) { - if (span.getLocalRootSpan() == span) { - return span; - } + // Local root unknown for the first span: only accept a span that is provably its own local root + // and present here. Never fall back to an arbitrary span. + for (final MutableSpan span : trace) { + if (span.getLocalRootSpan() == span) { + return span; + } + } + return null; } - return null; - } - @Override - public int priority() { - return PRIORITY; - } + @Override + public int priority() { + return PRIORITY; + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java index 7231466cd1b..953bb06b0fc 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java @@ -28,42 +28,42 @@ */ final class SpanEnrichmentStates { - // Weak keys: the accumulator is GC'd with its local-root span, so a trace that never completes - // cannot leak. Identity-keyed by the local-root AgentSpan object. guarded by 'this'. - private final Map states = new WeakHashMap<>(); + // Weak keys: the accumulator is GC'd with its local-root span, so a trace that never completes + // cannot leak. Identity-keyed by the local-root AgentSpan object. guarded by 'this'. + private final Map states = new WeakHashMap<>(); - /** Returns the accumulator for {@code root}, creating (and inserting) it if absent. */ - synchronized SpanEnrichmentAccumulator getOrCreate(final AgentSpan root) { - SpanEnrichmentAccumulator existing = states.get(root); - if (existing != null) { - return existing; + /** Returns the accumulator for {@code root}, creating (and inserting) it if absent. */ + synchronized SpanEnrichmentAccumulator getOrCreate(final AgentSpan root) { + SpanEnrichmentAccumulator existing = states.get(root); + if (existing != null) { + return existing; + } + final SpanEnrichmentAccumulator created = new SpanEnrichmentAccumulator(); + states.put(root, created); + return created; } - final SpanEnrichmentAccumulator created = new SpanEnrichmentAccumulator(); - states.put(root, created); - return created; - } - /** Removes and returns the accumulator for {@code root}, or {@code null} if absent. */ - synchronized SpanEnrichmentAccumulator remove(final AgentSpan root) { - return states.remove(root); - } + /** Removes and returns the accumulator for {@code root}, or {@code null} if absent. */ + synchronized SpanEnrichmentAccumulator remove(final AgentSpan root) { + return states.remove(root); + } - /** Clears all tracked state (cleanup on shutdown). */ - synchronized void clear() { - states.clear(); - } + /** Clears all tracked state (cleanup on shutdown). */ + synchronized void clear() { + states.clear(); + } - synchronized int size() { - return states.size(); - } + synchronized int size() { + return states.size(); + } - synchronized boolean isEmpty() { - return states.isEmpty(); - } + synchronized boolean isEmpty() { + return states.isEmpty(); + } - // ---- test-only accessor ---- + // ---- test-only accessor ---- - synchronized SpanEnrichmentAccumulator peek(final AgentSpan root) { - return states.get(root); - } + synchronized SpanEnrichmentAccumulator peek(final AgentSpan root) { + return states.get(root); + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java index f97259d9368..ae889d0506f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java @@ -32,166 +32,163 @@ * *

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. */ -public final class SpanEnrichmentWriter - implements FeatureFlaggingGateway.SpanEnrichmentListener, AutoCloseable { - - private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentWriter.class); - - public static SpanEnrichmentWriter getInstance() { - return SingletonHolder.INSTANCE; - } - - /** - * Resolves the local-root span for the active trace. Injectable so tests need no static mocks. - */ - interface RootSpanResolver { - AgentSpan activeLocalRoot(); - } - - /** - * Registers the interceptor with the tracer, returning {@code true} when accepted. Injectable so - * tests are deterministic without a globally-installed tracer. - */ - interface InterceptorRegistrar { - boolean register(SpanEnrichmentInterceptor interceptor); - } - - private static final RootSpanResolver DEFAULT_RESOLVER = - () -> resolveLocalRoot(AgentTracer.activeSpan()); - - // Visible for tests: the local-root resolution, decoupled from the static AgentTracer so it can - // be exercised without a live tracer. - static AgentSpan resolveLocalRoot(final AgentSpan active) { - if (active == null) { - return null; - } - final AgentSpan localRoot = active.getLocalRootSpan(); - return localRoot != null ? localRoot : active; - } - - private static final InterceptorRegistrar DEFAULT_REGISTRAR = - interceptor -> GlobalTracer.get().addTraceInterceptor(interceptor); - - private static final class SingletonHolder { - // Persisting this instance across FeatureFlaggingSystem start/stop keeps the single registered - // interceptor (and its state) alive, so a restart never re-registers. - private static final SpanEnrichmentWriter INSTANCE = new SpanEnrichmentWriter(); - } - - private final RootSpanResolver rootSpanResolver; - private final InterceptorRegistrar registrar; - private final SpanEnrichmentStates states; - private final SpanEnrichmentInterceptor interceptor; - // Registered with the tracer at most once, lazily on the first enrichment event with an active - // span. Once true it stays true for the life of this instance, so a subsystem restart (which - // reuses the singleton) never attempts a second, doomed registration. - private final AtomicBoolean interceptorRegistered = new AtomicBoolean(false); - - private SpanEnrichmentWriter() { - this(DEFAULT_RESOLVER, DEFAULT_REGISTRAR); - } - - // Visible for tests: an isolated writer (own state + interceptor) whose interceptor registration - // is assumed to succeed, bypassing the shared INSTANCE and any globally-installed tracer. - SpanEnrichmentWriter(final RootSpanResolver rootSpanResolver) { - this(rootSpanResolver, interceptor -> true); - } - - // Visible for tests: also inject the registrar to exercise the not-registered path. - SpanEnrichmentWriter( - final RootSpanResolver rootSpanResolver, final InterceptorRegistrar registrar) { - this.rootSpanResolver = rootSpanResolver; - this.registrar = registrar; - this.states = new SpanEnrichmentStates(); - this.interceptor = new SpanEnrichmentInterceptor(states); - } - - /** Starts listening for enrichment events. Safe to call again after {@link #close()}. */ - public void init() { - FeatureFlaggingGateway.addSpanEnrichmentListener(this); - } - - /** - * Stops listening and drops any residual state. The interceptor stays registered with the tracer - * (it cannot be removed) but goes inert while the state is empty; a later {@link #init()} resumes - * enrichment on the same interceptor. - */ - public void close() { - FeatureFlaggingGateway.removeSpanEnrichmentListener(this); - states.clear(); - } - - @Override - public void accept(final SpanEnrichmentEvent event) { - if (event == null) { - return; - } - try { - final AgentSpan root = rootSpanResolver.activeLocalRoot(); - if (root == null) { - return; // no active span → nothing to enrich (and nothing to register the interceptor for) - } - if (!ensureInterceptorRegistered()) { - // The interceptor isn't registered (e.g. tracer absent), so nothing would ever flush this - // state — skip accumulating. A later event retries registration. - return; - } - final SpanEnrichmentAccumulator state = states.getOrCreate(root); - if (event.hasSerialId()) { - final int serialId = event.serialId(); - state.addSerialId(serialId); - if (event.doLog() && event.targetingKey() != null) { - state.addSubject(event.targetingKey(), serialId); +public final class SpanEnrichmentWriter implements FeatureFlaggingGateway.SpanEnrichmentListener, AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentWriter.class); + + public static SpanEnrichmentWriter getInstance() { + return SingletonHolder.INSTANCE; + } + + /** + * Resolves the local-root span for the active trace. Injectable so tests need no static mocks. + */ + interface RootSpanResolver { + AgentSpan activeLocalRoot(); + } + + /** + * Registers the interceptor with the tracer, returning {@code true} when accepted. Injectable so + * tests are deterministic without a globally-installed tracer. + */ + interface InterceptorRegistrar { + boolean register(SpanEnrichmentInterceptor interceptor); + } + + private static final RootSpanResolver DEFAULT_RESOLVER = () -> resolveLocalRoot(AgentTracer.activeSpan()); + + // Visible for tests: the local-root resolution, decoupled from the static AgentTracer so it can + // be exercised without a live tracer. + static AgentSpan resolveLocalRoot(final AgentSpan active) { + if (active == null) { + return null; } - } else if (event.flagKey() != null) { - state.addDefault(event.flagKey(), event.defaultValue()); - } - } catch (final Throwable t) { - // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. - log.debug("Span-enrichment accumulation failed", t); - } - } - - /** - * @return true once the interceptor is registered with the tracer. - */ - private boolean ensureInterceptorRegistered() { - if (interceptorRegistered.get()) { - return true; - } - synchronized (this) { - if (interceptorRegistered.get()) { - return true; - } - try { - // register() returns false (without throwing) when the tracer rejects it — e.g. the global - // tracer is still the no-op placeholder. Only latch on success so a later event retries; - // otherwise a transient false would permanently disable enrichment. - if (registrar.register(interceptor)) { - interceptorRegistered.set(true); + final AgentSpan localRoot = active.getLocalRootSpan(); + return localRoot != null ? localRoot : active; + } + + private static final InterceptorRegistrar DEFAULT_REGISTRAR = + interceptor -> GlobalTracer.get().addTraceInterceptor(interceptor); + + private static final class SingletonHolder { + // Persisting this instance across FeatureFlaggingSystem start/stop keeps the single registered + // interceptor (and its state) alive, so a restart never re-registers. + private static final SpanEnrichmentWriter INSTANCE = new SpanEnrichmentWriter(); + } + + private final RootSpanResolver rootSpanResolver; + private final InterceptorRegistrar registrar; + private final SpanEnrichmentStates states; + private final SpanEnrichmentInterceptor interceptor; + // Registered with the tracer at most once, lazily on the first enrichment event with an active + // span. Once true it stays true for the life of this instance, so a subsystem restart (which + // reuses the singleton) never attempts a second, doomed registration. + private final AtomicBoolean interceptorRegistered = new AtomicBoolean(false); + + private SpanEnrichmentWriter() { + this(DEFAULT_RESOLVER, DEFAULT_REGISTRAR); + } + + // Visible for tests: an isolated writer (own state + interceptor) whose interceptor registration + // is assumed to succeed, bypassing the shared INSTANCE and any globally-installed tracer. + SpanEnrichmentWriter(final RootSpanResolver rootSpanResolver) { + this(rootSpanResolver, interceptor -> true); + } + + // Visible for tests: also inject the registrar to exercise the not-registered path. + SpanEnrichmentWriter(final RootSpanResolver rootSpanResolver, final InterceptorRegistrar registrar) { + this.rootSpanResolver = rootSpanResolver; + this.registrar = registrar; + this.states = new SpanEnrichmentStates(); + this.interceptor = new SpanEnrichmentInterceptor(states); + } + + /** Starts listening for enrichment events. Safe to call again after {@link #close()}. */ + public void init() { + FeatureFlaggingGateway.addSpanEnrichmentListener(this); + } + + /** + * Stops listening and drops any residual state. The interceptor stays registered with the tracer + * (it cannot be removed) but goes inert while the state is empty; a later {@link #init()} resumes + * enrichment on the same interceptor. + */ + public void close() { + FeatureFlaggingGateway.removeSpanEnrichmentListener(this); + states.clear(); + } + + @Override + public void accept(final SpanEnrichmentEvent event) { + if (event == null) { + return; + } + try { + final AgentSpan root = rootSpanResolver.activeLocalRoot(); + if (root == null) { + return; // no active span → nothing to enrich (and nothing to register the interceptor for) + } + if (!ensureInterceptorRegistered()) { + // The interceptor isn't registered (e.g. tracer absent), so nothing would ever flush this + // state — skip accumulating. A later event retries registration. + return; + } + final SpanEnrichmentAccumulator state = states.getOrCreate(root); + if (event.hasSerialId()) { + final int serialId = event.serialId(); + state.addSerialId(serialId); + if (event.doLog() && event.targetingKey() != null) { + state.addSubject(event.targetingKey(), serialId); + } + } else if (event.flagKey() != null) { + state.addDefault(event.flagKey(), event.defaultValue()); + } + } catch (final Throwable t) { + // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. + log.debug("Span-enrichment accumulation failed", t); } - } catch (final Throwable t) { - // Leave unregistered; a later event retries. - } - return interceptorRegistered.get(); } - } - // ---- test-only accessors ---- + /** + * @return true once the interceptor is registered with the tracer. + */ + private boolean ensureInterceptorRegistered() { + if (interceptorRegistered.get()) { + return true; + } + synchronized (this) { + if (interceptorRegistered.get()) { + return true; + } + try { + // register() returns false (without throwing) when the tracer rejects it — e.g. the global + // tracer is still the no-op placeholder. Only latch on success so a later event retries; + // otherwise a transient false would permanently disable enrichment. + if (registrar.register(interceptor)) { + interceptorRegistered.set(true); + } + } catch (final Throwable t) { + // Leave unregistered; a later event retries. + } + return interceptorRegistered.get(); + } + } + + // ---- test-only accessors ---- - SpanEnrichmentStates states() { - return states; - } + SpanEnrichmentStates states() { + return states; + } - SpanEnrichmentInterceptor interceptor() { - return interceptor; - } + SpanEnrichmentInterceptor interceptor() { + return interceptor; + } - RootSpanResolver rootSpanResolver() { - return rootSpanResolver; - } + RootSpanResolver rootSpanResolver() { + return rootSpanResolver; + } - InterceptorRegistrar registrar() { - return registrar; - } + InterceptorRegistrar registrar() { + return registrar; + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java index 7566cf8698d..79bb133ea22 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java @@ -24,74 +24,71 @@ */ final class ULeb128Encoder { - private ULeb128Encoder() {} + private ULeb128Encoder() {} - /** - * ULEB128 delta-varint encodes the given serial ids into a bare base64 string. - * - * @param serialIds the serial ids to encode (deduped + sorted ascending internally) - * @return the base64-encoded delta-varint bytes, or the empty string when {@code serialIds} is - * empty or null - */ - static String encodeDeltaVarint(final Set serialIds) { - if (serialIds == null || serialIds.isEmpty()) { - // Empty set encodes to the empty string; the caller omits the tag. - return ""; - } - final SortedSet sorted = - serialIds instanceof SortedSet ? (SortedSet) serialIds : new TreeSet<>(serialIds); - // Worst case: 5 bytes per 32-bit varint. - final byte[] buffer = new byte[sorted.size() * 5]; - int length = 0; - int previous = 0; - for (final Integer id : sorted) { - long delta = - ((long) id) - previous; // long to stay safe; deltas are non-negative (sorted asc) - previous = id; - while (delta > 0x7FL) { - buffer[length++] = (byte) ((delta & 0x7FL) | 0x80L); - delta >>>= 7; - } - buffer[length++] = (byte) (delta & 0x7FL); + /** + * ULEB128 delta-varint encodes the given serial ids into a bare base64 string. + * + * @param serialIds the serial ids to encode (deduped + sorted ascending internally) + * @return the base64-encoded delta-varint bytes, or the empty string when {@code serialIds} is + * empty or null + */ + static String encodeDeltaVarint(final Set serialIds) { + if (serialIds == null || serialIds.isEmpty()) { + // Empty set encodes to the empty string; the caller omits the tag. + return ""; + } + final SortedSet sorted = + serialIds instanceof SortedSet ? (SortedSet) serialIds : new TreeSet<>(serialIds); + // Worst case: 5 bytes per 32-bit varint. + final byte[] buffer = new byte[sorted.size() * 5]; + int length = 0; + int previous = 0; + for (final Integer id : sorted) { + long delta = ((long) id) - previous; // long to stay safe; deltas are non-negative (sorted asc) + previous = id; + while (delta > 0x7FL) { + buffer[length++] = (byte) ((delta & 0x7FL) | 0x80L); + delta >>>= 7; + } + buffer[length++] = (byte) (delta & 0x7FL); + } + final byte[] out = new byte[length]; + System.arraycopy(buffer, 0, out, 0, length); + return Base64.getEncoder().encodeToString(out); } - final byte[] out = new byte[length]; - System.arraycopy(buffer, 0, out, 0, length); - return Base64.getEncoder().encodeToString(out); - } - /** - * Lower-case hex SHA-256 of the given string. Used to hash subject targeting keys before they are - * emitted (privacy: subject keys are never emitted in clear text). - * - * @param value the value to hash - * @return the lower-case hex SHA-256 digest - */ - static String hashTargetingKey(final String value) { - final MessageDigest digest = SHA_256.get(); - digest.reset(); - final byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - final StringBuilder hex = new StringBuilder(hash.length * 2); - for (final byte b : hash) { - final int v = b & 0xFF; - if (v < 0x10) { - hex.append('0'); - } - hex.append(Integer.toHexString(v)); + /** + * Lower-case hex SHA-256 of the given string. Used to hash subject targeting keys before they are + * emitted (privacy: subject keys are never emitted in clear text). + * + * @param value the value to hash + * @return the lower-case hex SHA-256 digest + */ + static String hashTargetingKey(final String value) { + final MessageDigest digest = SHA_256.get(); + digest.reset(); + final byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder(hash.length * 2); + for (final byte b : hash) { + final int v = b & 0xFF; + if (v < 0x10) { + hex.append('0'); + } + hex.append(Integer.toHexString(v)); + } + return hex.toString(); } - return hex.toString(); - } - // Per-thread SHA-256 instance: hashing runs on every doLog=true subject capture, so a - // ThreadLocal avoids a provider lookup + allocation per call on that hot path. digest() resets - // the instance after each hash; we also reset() defensively before use. - private static final ThreadLocal SHA_256 = - ThreadLocal.withInitial( - () -> { - try { - return MessageDigest.getInstance("SHA-256"); - } catch (final NoSuchAlgorithmException e) { - // SHA-256 is mandated by the JLS on every JVM; unreachable in practice. - throw new IllegalStateException("SHA-256 algorithm not available", e); - } - }); + // Per-thread SHA-256 instance: hashing runs on every doLog=true subject capture, so a + // ThreadLocal avoids a provider lookup + allocation per call on that hot path. digest() resets + // the instance after each hash; we also reset() defensively before use. + private static final ThreadLocal SHA_256 = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (final NoSuchAlgorithmException e) { + // SHA-256 is mandated by the JLS on every JVM; unreachable in practice. + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + }); } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java index 9778d980179..920ed8647b5 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java @@ -36,488 +36,469 @@ final class UniversalFlagConfigParser implements ConfigurationDeserializer { - private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); - - static final String INVALID_FLAG = "invalid_flag"; - static final String INVALID_SEMVER_COMPARAND = "invalid_semver_comparand"; - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; - - /** - * Side-channel for tracking flags that failed semver comparand validation during parsing. Cleared - * and populated by {@link #parse(JsonReader)} so that {@link ServerConfiguration#invalidFlags} - * can be set after the Moshi adapter returns. - */ - static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - - static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); - - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Instant.class, new InstantAdapter()) - .add(ShardAdapter.FACTORY) - .add(AllocationAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .add(LenientBooleanAdapter.FACTORY) - .build(); - private static final JsonAdapter V1_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - - private UniversalFlagConfigParser() {} - - @Override - public ServerConfiguration deserialize(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - final ServerConfiguration configuration = parse(reader); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - ServerConfiguration parse(final JsonReader reader) throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - final ServerConfiguration configuration = V1_ADAPTER.fromJson(reader); - if (configuration != null) { - final Map invalid = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalid.isEmpty()) { - configuration.invalidFlags = invalid; - } - } - INVALID_FLAGS_HOLDER.get().clear(); - return configuration; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } - - /** Converts a UFC uint32 wire value to its binary-compatible int representation. */ - private static int toUnsignedInt(@Nullable final Long value, final String fieldName) { - if (value == null) { - return 0; + private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); + + static final String INVALID_FLAG = "invalid_flag"; + static final String INVALID_SEMVER_COMPARAND = "invalid_semver_comparand"; + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + + /** + * Side-channel for tracking flags that failed semver comparand validation during parsing. Cleared + * and populated by {@link #parse(JsonReader)} so that {@link ServerConfiguration#invalidFlags} + * can be set after the Moshi adapter returns. + */ + static final ThreadLocal> INVALID_FLAGS_HOLDER = ThreadLocal.withInitial(HashMap::new); + + static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); + + private static final Moshi MOSHI = new Moshi.Builder() + .add(Instant.class, new InstantAdapter()) + .add(ShardAdapter.FACTORY) + .add(AllocationAdapter.FACTORY) + .add(FlagMapAdapter.FACTORY) + .add(LenientBooleanAdapter.FACTORY) + .build(); + private static final JsonAdapter V1_ADAPTER = MOSHI.adapter(ServerConfiguration.class); + + private UniversalFlagConfigParser() {} + + @Override + public ServerConfiguration deserialize(final byte[] content) throws IOException { + try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { + final JsonReader reader = JsonReader.of(source); + final ServerConfiguration configuration = parse(reader); + requireEndOfDocument(reader); + return configuration; + } } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new InvalidFlagException("flag contains an invalid " + fieldName + " value"); + + @Nullable + ServerConfiguration parse(final JsonReader reader) throws IOException { + INVALID_FLAGS_HOLDER.get().clear(); + final ServerConfiguration configuration = V1_ADAPTER.fromJson(reader); + if (configuration != null) { + final Map invalid = new HashMap<>(INVALID_FLAGS_HOLDER.get()); + if (!invalid.isEmpty()) { + configuration.invalidFlags = invalid; + } + } + INVALID_FLAGS_HOLDER.get().clear(); + return configuration; } - return value.intValue(); - } - /** Validates the required nested UFC fields and SemVer comparands for a flag. */ - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; + private static void requireEndOfDocument(final JsonReader reader) throws IOException { + // A strict JsonReader throws if another top-level value follows the parsed document. + reader.peek(); } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains a split with missing shards"); + + /** Converts a UFC uint32 wire value to its binary-compatible int representation. */ + private static int toUnsignedInt(@Nullable final Long value, final String fieldName) { + if (value == null) { + return 0; } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new InvalidFlagException("flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } + if (value < 0 || value > MAX_UNSIGNED_INT) { + throw new InvalidFlagException("flag contains an invalid " + fieldName + " value"); } - } + return value.intValue(); } - validateAndCacheSemverComparands(flagKey, flag); - } - private static void validateConditionOperands(final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; + /** Validates the required nested UFC fields and SemVer comparands for a flag. */ + private static void validateFlag(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-numeric condition"); + for (final Allocation allocation : flag.allocations) { + if (allocation == null) { + continue; } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new InvalidFlagException("flag \"" + flagKey + "\" has a non-list condition"); + validateConditionOperands(flagKey, allocation); + if (allocation.splits == null) { + continue; } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-boolean condition"); + for (final Split split : allocation.splits) { + if (split == null) { + continue; + } + if (split.shards == null) { + throw new InvalidFlagException("flag \"" + flagKey + "\" contains a split with missing shards"); + } + for (final Shard shard : split.shards) { + if (shard == null || Integer.toUnsignedLong(shard.totalShards) == 0 || shard.ranges == null) { + throw new InvalidFlagException("flag \"" + flagKey + "\" contains invalid shards"); + } + for (final ShardRange range : shard.ranges) { + if (range == null) { + throw new InvalidFlagException("flag \"" + flagKey + "\" contains an invalid shard range"); + } + } + } } - break; - default: - break; } - } + validateAndCacheSemverComparands(flagKey, flag); } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. Throws {@link - * InvalidSemverComparandException} if any condition has an invalid or non-string comparand value. - */ - private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; + + private static void validateConditionOperands(final String flagKey, final Allocation allocation) { + if (allocation.rules == null) { + return; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition == null || condition.operator == null) { + continue; + } + switch (condition.operator) { + case LT: + case LTE: + case GT: + case GTE: + if (!(condition.value instanceof Number)) { + throw new InvalidFlagException("flag \"" + flagKey + "\" has a non-numeric condition"); + } + break; + case ONE_OF: + case NOT_ONE_OF: + if (!(condition.value instanceof List)) { + throw new InvalidFlagException("flag \"" + flagKey + "\" has a non-list condition"); + } + break; + case IS_NULL: + if (!(condition.value instanceof Boolean)) { + throw new InvalidFlagException("flag \"" + flagKey + "\" has a non-boolean condition"); + } + break; + default: + break; + } + } + } } - for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { - final Allocation allocation = flag.allocations.get(allocIdx); - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; + + /** + * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. Throws {@link + * InvalidSemverComparandException} if any condition has an invalid or non-string comparand value. + */ + private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" that requires string value"); - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" and invalid semantic version \"" - + condition.value - + "\""); - } - condition.semverComparand = parsed; - break; - default: - // Non-semver operators are not validated here - break; - } + for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { + final Allocation allocation = flag.allocations.get(allocIdx); + if (allocation.rules == null) { + continue; + } + for (final Rule rule : allocation.rules) { + if (rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition.operator == null) { + continue; + } + switch (condition.operator) { + case SEMVER_EQ: + case SEMVER_NEQ: + case SEMVER_LT: + case SEMVER_LTE: + case SEMVER_GT: + case SEMVER_GTE: + if (!(condition.value instanceof String)) { + throw new InvalidSemverComparandException("flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" that requires string value"); + } + final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); + if (parsed == null) { + throw new InvalidSemverComparandException("flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" and invalid semantic version \"" + + condition.value + + "\""); + } + condition.semverComparand = parsed; + break; + default: + // Non-semver operators are not validated here + break; + } + } + } } - } } - } - /** Thrown when a flag has an invalid UFC shape. */ - static final class InvalidFlagException extends IllegalArgumentException { - InvalidFlagException(final String message) { - super(message); + /** Thrown when a flag has an invalid UFC shape. */ + static final class InvalidFlagException extends IllegalArgumentException { + InvalidFlagException(final String message) { + super(message); + } } - } - /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ - static final class InvalidSemverComparandException extends IllegalArgumentException { - InvalidSemverComparandException(final String message) { - super(message); + /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ + static final class InvalidSemverComparandException extends IllegalArgumentException { + InvalidSemverComparandException(final String message) { + super(message); + } } - } - - /** - * Reads UFC uint32 shard fields through Long first, then preserves the bootstrap model's int ABI - * by storing the accepted value as its two's-complement bit pattern. - */ - static final class ShardAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Shard.class) { - return null; + + /** + * Reads UFC uint32 shard fields through Long first, then preserves the bootstrap model's int ABI + * by storing the accepted value as its two's-complement bit pattern. + */ + static final class ShardAdapter extends JsonAdapter { + + static final Factory FACTORY = new Factory() { + @Nullable + @Override + public JsonAdapter create( + @Nonnull final Type type, + @Nonnull final Set annotations, + @Nonnull final Moshi moshi) { + if (!annotations.isEmpty() || type != Shard.class) { + return null; + } + return new ShardAdapter(moshi.adapter(ShardJson.class)); } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - } }; - private final JsonAdapter delegate; + private final JsonAdapter delegate; - ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } + ShardAdapter(final JsonAdapter delegate) { + this.delegate = delegate; + } - @Nullable - @Override - public Shard fromJson(@Nonnull final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); + @Nullable + @Override + public Shard fromJson(@Nonnull final JsonReader reader) throws IOException { + final ShardJson shard = delegate.fromJson(reader); + if (shard == null) { + return null; + } + final List ranges; + if (shard.ranges == null) { + ranges = null; + } else { + ranges = new ArrayList<>(); + for (final ShardRangeJson range : shard.ranges) { + ranges.add( + range == null + ? null + : new ShardRange( + toUnsignedInt(range.start, "range start"), + toUnsignedInt(range.end, "range end"))); + } + } + return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); + } + + @Override + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Shard value) { + throw new UnsupportedOperationException("Reading only adapter"); } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); } - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); + static final class ShardJson { + String salt; + List ranges; + Long totalShards; + } + + static final class ShardRangeJson { + Long start; + Long end; } - } - - static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - static final class ShardRangeJson { - Long start; - Long end; - } - - static final class FlagMapAdapter extends JsonAdapter> { - - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; + + static final class FlagMapAdapter extends JsonAdapter> { + + private static final Type FLAGS_TYPE = Types.newParameterizedType(Map.class, String.class, Flag.class); + + static final Factory FACTORY = new Factory() { + @Nullable + @Override + public JsonAdapter create( + @Nonnull final Type type, + @Nonnull final Set annotations, + @Nonnull final Moshi moshi) { + if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { + return null; + } + return new FlagMapAdapter(moshi.adapter(Flag.class)); } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - } }; - private final JsonAdapter flagAdapter; + private final JsonAdapter flagAdapter; - FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } + FlagMapAdapter(final JsonAdapter flagAdapter) { + this.flagAdapter = flagAdapter; + } - @Nullable - @Override - public Map fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException error) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_FLAG); - if (error instanceof InvalidSemverComparandException) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_SEMVER_COMPARAND); - } - LOGGER.warn( - "Dropping malformed FFE flag {} during remote config deserialization: {}", - flagKey, - error.toString()); + @Nullable + @Override + public Map fromJson(@Nonnull final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + final Map flags = new HashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + final String flagKey = reader.nextName(); + final Object rawFlag = reader.readJsonValue(); + try { + final Flag flag = flagAdapter.fromJsonValue(rawFlag); + if (flag != null) { + validateFlag(flagKey, flag); + flags.put(flagKey, flag); + } + } catch (JsonDataException | IllegalArgumentException error) { + INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_FLAG); + if (error instanceof InvalidSemverComparandException) { + INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_SEMVER_COMPARAND); + } + LOGGER.warn( + "Dropping malformed FFE flag {} during remote config deserialization: {}", + flagKey, + error.toString()); + } + } + reader.endObject(); + return flags; } - } - reader.endObject(); - return flags; - } - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); + @Override + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) + throws IOException { + throw new UnsupportedOperationException("Reading only adapter"); + } } - } - - /** - * Reads a boxed Boolean tolerantly: JSON null and wrong-typed values (string, number, object, - * array) map to null rather than aborting the enclosing object's parse. Downstream reads must use - * Boolean.TRUE.equals(field) so null resolves to the privacy-preserving default. - * - *

Only applies to Boolean.class (not primitive boolean), so mandatory primitive-boolean fields - * (e.g. Flag.enabled) keep their strict parse. - */ - static final class LenientBooleanAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Boolean.class) { - return null; + + /** + * Reads a boxed Boolean tolerantly: JSON null and wrong-typed values (string, number, object, + * array) map to null rather than aborting the enclosing object's parse. Downstream reads must use + * Boolean.TRUE.equals(field) so null resolves to the privacy-preserving default. + * + *

Only applies to Boolean.class (not primitive boolean), so mandatory primitive-boolean fields + * (e.g. Flag.enabled) keep their strict parse. + */ + static final class LenientBooleanAdapter extends JsonAdapter { + + static final Factory FACTORY = new Factory() { + @Nullable + @Override + public JsonAdapter create( + @Nonnull final Type type, + @Nonnull final Set annotations, + @Nonnull final Moshi moshi) { + if (!annotations.isEmpty() || type != Boolean.class) { + return null; + } + return new LenientBooleanAdapter(); } - return new LenientBooleanAdapter(); - } }; - @Nullable - @Override - public Boolean fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.BOOLEAN) { - return reader.nextBoolean(); - } - // null and every wrong-typed value collapse to null so the caller falls back to its default - // rather than the enclosing config being rejected wholesale. - reader.skipValue(); - return null; - } + @Nullable + @Override + public Boolean fromJson(@Nonnull final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.BOOLEAN) { + return reader.nextBoolean(); + } + // null and every wrong-typed value collapse to null so the caller falls back to its default + // rather than the enclosing config being rejected wholesale. + reader.skipValue(); + return null; + } - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Boolean value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); + @Override + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Boolean value) throws IOException { + throw new UnsupportedOperationException("Reading only adapter"); + } } - } - static final class InstantAdapter extends JsonAdapter { + static final class InstantAdapter extends JsonAdapter { - @Nullable - @Override - public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - return parseInstant(reader.nextString()); - } + @Nullable + @Override + public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + return parseInstant(reader.nextString()); + } - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); + @Override + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) throws IOException { + throw new UnsupportedOperationException("Reading only adapter"); + } } - } - - static final class AllocationAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, Allocation.class)) { - return null; + + static final class AllocationAdapter extends JsonAdapter { + + static final Factory FACTORY = new Factory() { + @Nullable + @Override + public JsonAdapter create( + @Nonnull final Type type, + @Nonnull final Set annotations, + @Nonnull final Moshi moshi) { + if (!annotations.isEmpty() || !Types.equals(type, Allocation.class)) { + return null; + } + return new AllocationAdapter(moshi.adapter(AllocationJson.class)); } - return new AllocationAdapter(moshi.adapter(AllocationJson.class)); - } }; - private final JsonAdapter delegate; + private final JsonAdapter delegate; - AllocationAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } + AllocationAdapter(final JsonAdapter delegate) { + this.delegate = delegate; + } - @Nullable - @Override - public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException { - final AllocationJson allocation = delegate.fromJson(reader); - if (allocation == null) { - return null; - } - return Allocation.fromInstants( - allocation.key, - allocation.rules, - allocation.startAt, - allocation.endAt, - allocation.splits, - allocation.doLog); + @Nullable + @Override + public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException { + final AllocationJson allocation = delegate.fromJson(reader); + if (allocation == null) { + return null; + } + return Allocation.fromInstants( + allocation.key, + allocation.rules, + allocation.startAt, + allocation.endAt, + allocation.splits, + allocation.doLog); + } + + @Override + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Allocation value) throws IOException { + throw new UnsupportedOperationException("Reading only adapter"); + } } - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Allocation value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); + static final class AllocationJson { + String key; + List rules; + Instant startAt; + Instant endAt; + List splits; + Boolean doLog; } - } - - static final class AllocationJson { - String key; - List rules; - Instant startAt; - Instant endAt; - List splits; - Boolean doLog; - } - - @Nullable - private static Instant parseInstant(final String date) { - try { - return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - } catch (Exception e) { - // ignore wrongly set dates - return null; + + @Nullable + private static Instant parseInstant(final String date) { + try { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); + } catch (Exception e) { + // ignore wrongly set dates + return null; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java index b02bd05642b..ff1afa726d9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java @@ -58,1306 +58,1175 @@ @ExtendWith(MockitoExtension.class) class AgentlessConfigurationSourceTest { - private static final String CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server"; - - @Mock private FeatureFlaggingGateway.ConfigListener listener; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - @Test - void derivesDatadogUfcCdnEndpointFromSiteAndEnv() { - final Config config = config("datad0g.com", "staging env"); - - assertEquals( - "https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging%20env", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void derivesDatadogUfcCdnEndpointWithoutEnv() { - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", "")).toString()); - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", null)).toString()); - } - - @Test - void appendsRulesBasedServerPathToConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080"); - - assertEquals( - "http://mock-backend:8080/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void usesConfiguredAgentlessEndpointWithPathUnchanged() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080/custom/ufc?tenant=test"); - - assertEquals( - "http://mock-backend:8080/custom/ufc?tenant=test", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void rejectsInvalidConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("not a URL"); - - assertThrows( - IllegalArgumentException.class, () -> AgentlessConfigurationSource.endpoint(config)); - } - - @Test - void rejectsInvalidDatadogUfcCdnEndpoint() { - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.endpoint(config("datadoghq.com:bad", ""))); - } - - @Test - void defaultConstructorBuildsHttpClientFromConfig() { - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource(config("datad0g.com", "staging")); - - service.close(); - } - - @Test - void realHttpClientDoesNotSendApiKeyOverHttp() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .addHeader("ETag", "etag-b") - .send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), "etag-a"); + private static final String CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server"; + + @Mock + private FeatureFlaggingGateway.ConfigListener listener; + + @AfterEach + void cleanup() { + FeatureFlaggingGateway.removeConfigListener(listener); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } + + @Test + void derivesDatadogUfcCdnEndpointFromSiteAndEnv() { + final Config config = config("datad0g.com", "staging env"); + + assertEquals( + "https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging%20env", + AgentlessConfigurationSource.endpoint(config).toString()); + } + + @Test + void derivesDatadogUfcCdnEndpointWithoutEnv() { + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", + AgentlessConfigurationSource.endpoint(config("datadoghq.com", "")) + .toString()); + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", + AgentlessConfigurationSource.endpoint(config("datadoghq.com", null)) + .toString()); + } + + @Test + void appendsRulesBasedServerPathToConfiguredAgentlessBaseUrl() { + final Config config = config(); + lenient() + .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) + .thenReturn("http://mock-backend:8080"); + + assertEquals( + "http://mock-backend:8080/api/v2/feature-flagging/config/rules-based/server", + AgentlessConfigurationSource.endpoint(config).toString()); + } + + @Test + void usesConfiguredAgentlessEndpointWithPathUnchanged() { + final Config config = config(); + lenient() + .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) + .thenReturn("http://mock-backend:8080/custom/ufc?tenant=test"); + + assertEquals( + "http://mock-backend:8080/custom/ufc?tenant=test", + AgentlessConfigurationSource.endpoint(config).toString()); + } + + @Test + void rejectsInvalidConfiguredAgentlessBaseUrl() { + final Config config = config(); + lenient() + .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) + .thenReturn("not a URL"); + + assertThrows(IllegalArgumentException.class, () -> AgentlessConfigurationSource.endpoint(config)); + } + + @Test + void rejectsInvalidDatadogUfcCdnEndpoint() { + assertThrows( + IllegalArgumentException.class, + () -> AgentlessConfigurationSource.endpoint(config("datadoghq.com:bad", ""))); + } + + @Test + void defaultConstructorBuildsHttpClientFromConfig() { + final AgentlessConfigurationSource service = new AgentlessConfigurationSource(config("datad0g.com", "staging")); + + service.close(); + } + + @Test + void realHttpClientDoesNotSendApiKeyOverHttp() throws Exception { + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> s.handlers(h -> h.get( + CONFIG_PATH, + api -> api.getResponse().addHeader("ETag", "etag-b").send(emptyConfig()))))) { + final OkHttpClient httpClient = new OkHttpClient.Builder().build(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); + + try { + final AgentlessConfigurationSource.UfcHttpResponse response = + client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), "etag-a"); + + assertEquals(HttpURLConnection.HTTP_OK, response.status); + assertEquals("etag-b", response.etag); + assertEquals(emptyConfig(), new String(response.body, UTF_8)); + assertNull(server.getLastRequest().getHeader("DD-API-KEY")); + assertEquals("etag-a", server.getLastRequest().getHeader("If-None-Match")); + assertEquals("java", server.getLastRequest().getHeader("Datadog-Meta-Lang")); + assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); + } finally { + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + + @Test + void sendsApiKeyToDefaultDatadogHttpsEndpoint() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); + final HttpUrl endpoint = HttpUrl.get("https://ufc-server.ff-cdn.datadoghq.com" + CONFIG_PATH); + + client.fetch(endpoint, config(), null); + + assertEquals("test-api-key", requests.get(0).header("DD-API-KEY")); + } + + @Test + void stripsApiKeyFromCustomHttpsEndpoint() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); + final Config config = config(); + lenient() + .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) + .thenReturn("https://flags.example.test/custom/ufc"); + final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); + + client.fetch(endpoint, config, null); + + assertNull(requests.get(0).header("DD-API-KEY")); + } + + @Test + void stripsApiKeyFromUnexpectedHttpsEndpoint() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); + final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); + + client.fetch(endpoint, config(), null); + + assertNull(requests.get(0).header("DD-API-KEY")); + } + + @Test + void handlesGzipAndKeepsLastKnownGoodWhenNextResponseIsTruncated() throws Exception { + final byte[] compressedConfig = gzip(emptyConfig()); + final byte[] truncatedConfig = Arrays.copyOf(compressedConfig, compressedConfig.length - 8); + final AtomicInteger responses = new AtomicInteger(); + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> s.handlers(h -> h.get(CONFIG_PATH, api -> { + final boolean firstResponse = responses.getAndIncrement() == 0; + final byte[] body = firstResponse ? compressedConfig : truncatedConfig; + api.getResponse() + .addHeader("Content-Encoding", "gzip") + .addHeader("ETag", firstResponse ? "etag-good" : "etag-bad") + .sendWithType("application/json", body); + })))) { + final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); + final OkHttpClient httpClient = new OkHttpClient.Builder().build(); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + endpoint, + config(), + 30_000, + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient, 30_000, delay -> {}, () -> 1.0), + Executors.newSingleThreadScheduledExecutor()); + final ArgumentCaptor configuration = + ArgumentCaptor.forClass(ServerConfiguration.class); + FeatureFlaggingGateway.addConfigListener(listener); + + try { + assertTrue(service.pollOnce()); + assertFalse(service.pollOnce()); + + verify(listener).accept(configuration.capture()); + assertEquals("Staging", configuration.getValue().environment.name); + assertEquals(4, responses.get()); + assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); + assertEquals("etag-good", server.getLastRequest().getHeader("If-None-Match")); + } finally { + service.close(); + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + + @Test + void downloadsAndAppliesLargeUfcWithoutPayloadLimit() throws Exception { + final int flagCount = 5_000; + final String largeConfig = largeConfig(flagCount); + assertTrue(largeConfig.getBytes(UTF_8).length > 500_000); + + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> + s.handlers(h -> h.get(CONFIG_PATH, api -> api.getResponse().send(largeConfig))))) { + final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); + final OkHttpClient httpClient = new OkHttpClient.Builder().build(); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + endpoint, + config(), + 30_000, + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient), + Executors.newSingleThreadScheduledExecutor()); + final ArgumentCaptor configuration = + ArgumentCaptor.forClass(ServerConfiguration.class); + FeatureFlaggingGateway.addConfigListener(listener); + + try { + assertTrue(service.pollOnce()); + + verify(listener).accept(configuration.capture()); + assertEquals(flagCount, configuration.getValue().flags.size()); + } finally { + service.close(); + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + + @Test + void realHttpClientAllowsMissingEtagAndEmptyResponseBody() throws Exception { + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> s.handlers(h -> h.get( + CONFIG_PATH, + api -> api.getResponse() + .status(HttpURLConnection.HTTP_NO_CONTENT) + .send())))) { + final OkHttpClient httpClient = new OkHttpClient.Builder().build(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); + + try { + final AgentlessConfigurationSource.UfcHttpResponse response = + client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null); + + assertEquals(HttpURLConnection.HTTP_NO_CONTENT, response.status); + assertNull(response.etag); + assertEquals(0, response.body.length); + assertNull(server.getLastRequest().getHeader("If-None-Match")); + } finally { + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + + @Test + void httpClientAdapterPreservesMissingResponseBody() throws Exception { + final OkHttpClient httpClient = mock(OkHttpClient.class); + final Call call = mock(Call.class); + final HttpUrl endpoint = HttpUrl.get("http://localhost"); + final okhttp3.Request request = + new okhttp3.Request.Builder().url(endpoint).build(); + final Response okHttpResponse = new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(HttpURLConnection.HTTP_OK) + .message("OK") + .build(); + when(httpClient.newCall(any())).thenReturn(call); + when(call.execute()).thenReturn(okHttpResponse); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); + + final AgentlessConfigurationSource.UfcHttpResponse response = client.fetch(endpoint, config(), null); assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertEquals("etag-b", response.etag); - assertEquals(emptyConfig(), new String(response.body, UTF_8)); - assertNull(server.getLastRequest().getHeader("DD-API-KEY")); - assertEquals("etag-a", server.getLastRequest().getHeader("If-None-Match")); - assertEquals("java", server.getLastRequest().getHeader("Datadog-Meta-Lang")); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void sendsApiKeyToDefaultDatadogHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://ufc-server.ff-cdn.datadoghq.com" + CONFIG_PATH); - - client.fetch(endpoint, config(), null); - - assertEquals("test-api-key", requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromCustomHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("https://flags.example.test/custom/ufc"); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config, null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromUnexpectedHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config(), null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void handlesGzipAndKeepsLastKnownGoodWhenNextResponseIsTruncated() throws Exception { - final byte[] compressedConfig = gzip(emptyConfig()); - final byte[] truncatedConfig = Arrays.copyOf(compressedConfig, compressedConfig.length - 8); - final AtomicInteger responses = new AtomicInteger(); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - final boolean firstResponse = responses.getAndIncrement() == 0; - final byte[] body = - firstResponse ? compressedConfig : truncatedConfig; - api.getResponse() - .addHeader("Content-Encoding", "gzip") - .addHeader("ETag", firstResponse ? "etag-good" : "etag-bad") - .sendWithType("application/json", body); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { + assertNull(response.etag); + assertNull(response.body); + } + + @Test + void realHttpClientCancellationInterruptsInFlightRequest() throws Exception { + final CountDownLatch requestStarted = new CountDownLatch(1); + final CountDownLatch releaseRequest = new CountDownLatch(1); + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> s.handlers(h -> h.get(CONFIG_PATH, api -> { + requestStarted.countDown(); + assertTrue(releaseRequest.await(1, TimeUnit.SECONDS)); + api.getResponse().send(emptyConfig()); + })))) { + final OkHttpClient httpClient = new OkHttpClient.Builder().build(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); + final ExecutorService runner = Executors.newSingleThreadExecutor(); + + try { + final Future response = runner.submit( + () -> client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); + assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); + assertThrows( + IllegalStateException.class, + () -> client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); + + client.cancel(); + + final ExecutionException failure = + assertThrows(ExecutionException.class, () -> response.get(1, TimeUnit.SECONDS)); + assertInstanceOf(IOException.class, failure.getCause()); + } finally { + releaseRequest.countDown(); + runner.shutdownNow(); + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + + @Test + void realHttpClientCancellationBeforeFetchPreventsRequest() throws Exception { + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> + s.handlers(h -> h.get(CONFIG_PATH, api -> api.getResponse().send(emptyConfig()))))) { + final OkHttpClient httpClient = new OkHttpClient.Builder().build(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); + + try { + client.cancel(); + + assertThrows( + IOException.class, + () -> client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); + } finally { + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + + @Test + void realHttpClientTimesOutDelayedResponse() throws Exception { + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> s.handlers(h -> h.get(CONFIG_PATH, api -> { + TimeUnit.MILLISECONDS.sleep(500); + api.getResponse().send(emptyConfig()); + })))) { + final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); + final OkHttpClient httpClient = OkHttpUtils.buildHttpClient(endpoint, 50); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient, 30_000, delay -> {}, () -> 1.0); + + try { + assertThrows(IOException.class, () -> client.fetch(endpoint, config(), null)); + } finally { + httpClient.dispatcher().executorService().shutdownNow(); + httpClient.connectionPool().evictAll(); + } + } + } + + @Test + void appliesAcceptedJsonApiUfcThroughGateway() throws Exception { + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + final ArgumentCaptor configuration = ArgumentCaptor.forClass(ServerConfiguration.class); + FeatureFlaggingGateway.addConfigListener(listener); + assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); verify(listener).accept(configuration.capture()); + assertEquals("2026-07-15T19:57:07.219869778Z", configuration.getValue().createdAt); + assertNull(configuration.getValue().format); assertEquals("Staging", configuration.getValue().environment.name); - assertEquals(4, responses.get()); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - assertEquals("etag-good", server.getLastRequest().getHeader("If-None-Match")); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void downloadsAndAppliesLargeUfcWithoutPayloadLimit() throws Exception { - final int flagCount = 5_000; - final String largeConfig = largeConfig(flagCount); - assertTrue(largeConfig.getBytes(UTF_8).length > 500_000); - - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> s.handlers(h -> h.get(CONFIG_PATH, api -> api.getResponse().send(largeConfig))))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { + assertTrue(configuration.getValue().flags.isEmpty()); + assertNull(client.requests.get(0).etag); + } + + @Test + void customEndpointRequiresJsonApiAndKeepsLastKnownGoodOnRawUfc() throws Exception { + final FakeClient client = new FakeClient( + response(200, "etag-good", emptyConfig()), response(200, "etag-raw", emptyConfigAttributes())); + final Config config = config(); + lenient() + .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) + .thenReturn("http://custom-backend/custom/ufc"); + final AgentlessConfigurationSource service = service(client, config); + final ArgumentCaptor configuration = ArgumentCaptor.forClass(ServerConfiguration.class); + FeatureFlaggingGateway.addConfigListener(listener); + assertTrue(service.pollOnce()); + assertFalse(service.pollOnce()); verify(listener).accept(configuration.capture()); - assertEquals(flagCount, configuration.getValue().flags.size()); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientAllowsMissingEtagAndEmptyResponseBody() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .status(HttpURLConnection.HTTP_NO_CONTENT) - .send())))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null); - - assertEquals(HttpURLConnection.HTTP_NO_CONTENT, response.status); - assertNull(response.etag); - assertEquals(0, response.body.length); - assertNull(server.getLastRequest().getHeader("If-None-Match")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void httpClientAdapterPreservesMissingResponseBody() throws Exception { - final OkHttpClient httpClient = mock(OkHttpClient.class); - final Call call = mock(Call.class); - final HttpUrl endpoint = HttpUrl.get("http://localhost"); - final okhttp3.Request request = new okhttp3.Request.Builder().url(endpoint).build(); - final Response okHttpResponse = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(HttpURLConnection.HTTP_OK) - .message("OK") - .build(); - when(httpClient.newCall(any())).thenReturn(call); - when(call.execute()).thenReturn(okHttpResponse); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(endpoint, config(), null); + assertEquals("Staging", configuration.getValue().environment.name); + assertNull(client.requests.get(0).etag); + assertEquals("etag-good", client.requests.get(1).etag); + } - assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertNull(response.etag); - assertNull(response.body); - } - - @Test - void realHttpClientCancellationInterruptsInFlightRequest() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - requestStarted.countDown(); - assertTrue(releaseRequest.await(1, TimeUnit.SECONDS)); - api.getResponse().send(emptyConfig()); - })))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future response = - runner.submit( - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - assertThrows( - IllegalStateException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - - client.cancel(); - - final ExecutionException failure = - assertThrows(ExecutionException.class, () -> response.get(1, TimeUnit.SECONDS)); - assertInstanceOf(IOException.class, failure.getCause()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientCancellationBeforeFetchPreventsRequest() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> h.get(CONFIG_PATH, api -> api.getResponse().send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - client.cancel(); + @Test + void ignoresBlankEtag() throws Exception { + final FakeClient client = new FakeClient(response(200, " ", emptyConfig()), response(304, null, null)); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); - assertThrows( - IOException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientTimesOutDelayedResponse() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - TimeUnit.MILLISECONDS.sleep(500); - api.getResponse().send(emptyConfig()); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = OkHttpUtils.buildHttpClient(endpoint, 50); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0); - - try { - assertThrows(IOException.class, () -> client.fetch(endpoint, config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void appliesAcceptedJsonApiUfcThroughGateway() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("2026-07-15T19:57:07.219869778Z", configuration.getValue().createdAt); - assertNull(configuration.getValue().format); - assertEquals("Staging", configuration.getValue().environment.name); - assertTrue(configuration.getValue().flags.isEmpty()); - assertNull(client.requests.get(0).etag); - } - - @Test - void customEndpointRequiresJsonApiAndKeepsLastKnownGoodOnRawUfc() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(200, "etag-raw", emptyConfigAttributes())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://custom-backend/custom/ufc"); - final AgentlessConfigurationSource service = service(client, config); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("Staging", configuration.getValue().environment.name); - assertNull(client.requests.get(0).etag); - assertEquals("etag-good", client.requests.get(1).etag); - } - - @Test - void ignoresBlankEtag() throws Exception { - final FakeClient client = - new FakeClient(response(200, " ", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void successfulResponseWithoutEtagClearsPreviousEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), - response(200, null, emptyConfig()), - response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals("etag-a", client.requests.get(1).etag); - assertNull(client.requests.get(2).etag); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void usesEtagAndSkipsDispatchOnUnchangedConfig() throws Exception { - final FakeClient client = - new FakeClient(response(200, "etag-a", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", client.requests.get(1).etag); - } - - @Test - void coldNotModifiedDoesNotEstablishEtag() throws Exception { - final FakeClient client = - new FakeClient(response(304, "etag-cold", null), response(200, "etag-warm", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void failedGatewayDispatchDoesNotAdvanceEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final FeatureFlaggingGateway.ConfigListener failingListener = - configuration -> { - throw new IllegalStateException("listener rejected configuration"); + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + + assertNull(client.requests.get(1).etag); + verify(listener).accept(any(ServerConfiguration.class)); + } + + @Test + void successfulResponseWithoutEtagClearsPreviousEtag() throws Exception { + final FakeClient client = new FakeClient( + response(200, "etag-a", emptyConfig()), response(200, null, emptyConfig()), response(304, null, null)); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + + assertEquals("etag-a", client.requests.get(1).etag); + assertNull(client.requests.get(2).etag); + verify(listener, times(2)).accept(any(ServerConfiguration.class)); + } + + @Test + void usesEtagAndSkipsDispatchOnUnchangedConfig() throws Exception { + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig()), response(304, null, null)); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + + verify(listener).accept(any(ServerConfiguration.class)); + assertEquals("etag-a", client.requests.get(1).etag); + } + + @Test + void coldNotModifiedDoesNotEstablishEtag() throws Exception { + final FakeClient client = + new FakeClient(response(304, "etag-cold", null), response(200, "etag-warm", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + + assertNull(client.requests.get(1).etag); + verify(listener).accept(any(ServerConfiguration.class)); + } + + @Test + void failedGatewayDispatchDoesNotAdvanceEtag() throws Exception { + final FakeClient client = + new FakeClient(response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + final FeatureFlaggingGateway.ConfigListener failingListener = configuration -> { + throw new IllegalStateException("listener rejected configuration"); }; - FeatureFlaggingGateway.addConfigListener(failingListener); - - try { - assertThrows(IllegalStateException.class, service::pollOnce); - FeatureFlaggingGateway.removeConfigListener(failingListener); - - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - } finally { - FeatureFlaggingGateway.removeConfigListener(failingListener); - } - } - - @Test - void keepsLastKnownGoodOnAuthFailureAndMalformedPayload() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(401, null, null), - response(200, null, "{not-json}"), - response(200, null, "{\"flags\":[]}")); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-good", client.requests.get(1).etag); - assertEquals("etag-good", client.requests.get(2).etag); - assertEquals("etag-good", client.requests.get(3).etag); - } - - @Test - void rejectsForbiddenNonOkMissingBodyAndNullConfiguration() throws Exception { - final FakeClient client = - new FakeClient( - response(403, null, null), - response(404, null, null), - response(600, null, null), - response(200, null, null), - response(200, null, "null"), - response(200, null, jsonApiResponse("other-configuration", emptyConfigAttributes())), - response(200, null, "{\"data\":null}"), - response( - 200, null, "{\"data\":{\"id\":\"1\",\"type\":\"universal-flag-configuration\"}}"), - response(200, null, emptyConfigAttributes())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedOnUnauthorizedAndForbidden() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final FakeClient client = new FakeClient(response(401, null, null), response(403, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - - try { - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_UNAUTHORIZED); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_FORBIDDEN); - } finally { - service.close(); - } - } - - @Test - void retriesTimeoutBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesClientTimeoutAndRateLimitStatusBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(408, null, null), - response(200, "etag-a", emptyConfig()), - response(429, null, null), - response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals(4, requests.size()); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesServerErrorThenKeepsColdStateOnNotModified() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, delay -> {}, () -> 1.0, response(500, null, null), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(2, requests.size()); - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedAfterRetryableFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(503, null, null), - response(503, null, null), - response(503, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", 3, 503); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void warnsRateLimitedAfterIoFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final SocketTimeoutException finalFailure = - new SocketTimeoutException("slow HTTP configuration source"); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - finalFailure); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - 3, - finalFailure); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void usesIntervalAwareRetryBackoff() throws Exception { - final List delays = new ArrayList<>(); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delays::add, - () -> 1.0, - response(503, null, null), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(Arrays.asList(5_000L, 10_000L), delays); - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retryPolicyRejectsNonIoAndInterruptedIoFailures() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry(new IllegalStateException("not an I/O failure"))); - - Thread.currentThread().interrupt(); - try { - assertFalse(policy.shouldRetry(new IOException("interrupted"))); - } finally { - assertTrue(Thread.interrupted()); - } - } - - @Test - void retryPolicyRejectsMissingResponse() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry((Response) null)); - } - - @Test - void cancelledRetryPolicyRejectsBackoff() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean(true)); - - assertThrows(InterruptedIOException.class, policy::backoff); - } - - @Test - void clampsAndJittersRetryBackoff() { - assertEquals(2_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 1, 1.0)); - assertEquals(5_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 2, 1.0)); - assertEquals(10_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 1, 1.0)); - assertEquals(30_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 2, 1.0)); - assertEquals(6_000, AgentlessConfigurationSource.retryDelayMillis(30_000, 1, 1.2)); - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.retryDelayMillis(30_000, 3, 1.0)); - } - - @Test - void rejectsOverlappingPolls() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newFixedThreadPool(2); - - try { - final Future first = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - final Future second = runner.submit(service::pollOnce); - - assertFalse(second.get(1, TimeUnit.SECONDS)); - releaseRequest.countDown(); - assertTrue(first.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void initCompletesFirstPollAndCloseCancelsScheduledFuture() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - service.init(); - assertEquals(1, client.calls.get()); - service.close(); - - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void initCompletesInitialRetryCycleBeforeReturning() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(500, null, null), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - service.init(); - - assertEquals(2, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } finally { - service.close(); - } - } - - @Test - void repeatedInitStartsOnlyOnePoller() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.init(); - service.init(); - awaitCalls(client, 1); - service.close(); - - assertEquals(1, client.calls.get()); - } - - @Test - void scheduledPollContinuesAfterListenerRuntimeException() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 10, - client, - Executors.newSingleThreadScheduledExecutor()); - final AtomicInteger listenerCalls = new AtomicInteger(); - // Wait on the listener rather than on FakeClient.calls: the call counter is incremented when - // a request starts, so it reaches 2 before the second configuration has been applied. - final CountDownLatch listenerNotified = new CountDownLatch(2); - final FeatureFlaggingGateway.ConfigListener flakyListener = - configuration -> { - final int call = listenerCalls.incrementAndGet(); - listenerNotified.countDown(); - if (call == 1) { - throw new IllegalStateException("listener rejected first configuration"); - } + FeatureFlaggingGateway.addConfigListener(failingListener); + + try { + assertThrows(IllegalStateException.class, service::pollOnce); + FeatureFlaggingGateway.removeConfigListener(failingListener); + + assertTrue(service.pollOnce()); + + assertNull(client.requests.get(1).etag); + } finally { + FeatureFlaggingGateway.removeConfigListener(failingListener); + } + } + + @Test + void keepsLastKnownGoodOnAuthFailureAndMalformedPayload() throws Exception { + final FakeClient client = new FakeClient( + response(200, "etag-good", emptyConfig()), + response(401, null, null), + response(200, null, "{not-json}"), + response(200, null, "{\"flags\":[]}")); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + + verify(listener).accept(any(ServerConfiguration.class)); + assertEquals("etag-good", client.requests.get(1).etag); + assertEquals("etag-good", client.requests.get(2).etag); + assertEquals("etag-good", client.requests.get(3).etag); + } + + @Test + void rejectsForbiddenNonOkMissingBodyAndNullConfiguration() throws Exception { + final FakeClient client = new FakeClient( + response(403, null, null), + response(404, null, null), + response(600, null, null), + response(200, null, null), + response(200, null, "null"), + response(200, null, jsonApiResponse("other-configuration", emptyConfigAttributes())), + response(200, null, "{\"data\":null}"), + response(200, null, "{\"data\":{\"id\":\"1\",\"type\":\"universal-flag-configuration\"}}"), + response(200, null, emptyConfigAttributes())); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + + verifyNoInteractions(listener); + } + + @Test + void warnsRateLimitedOnUnauthorizedAndForbidden() throws Exception { + final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); + final FakeClient client = new FakeClient(response(401, null, null), response(403, null, null)); + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor, ratelimitedLogger); + + try { + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + + verify(ratelimitedLogger) + .warn( + "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", + HttpURLConnection.HTTP_UNAUTHORIZED); + verify(ratelimitedLogger) + .warn( + "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", + HttpURLConnection.HTTP_FORBIDDEN); + } finally { + service.close(); + } + } + + @Test + void retriesTimeoutBeforeApplyingConfig() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, + delay -> {}, + () -> 1.0, + new SocketTimeoutException("slow HTTP configuration source"), + new SocketTimeoutException("slow HTTP configuration source"), + response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + + assertEquals(3, requests.size()); + verify(listener).accept(any(ServerConfiguration.class)); + } + + @Test + void retriesClientTimeoutAndRateLimitStatusBeforeApplyingConfig() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, + delay -> {}, + () -> 1.0, + response(408, null, null), + response(200, "etag-a", emptyConfig()), + response(429, null, null), + response(200, "etag-b", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + + assertEquals(4, requests.size()); + verify(listener, times(2)).accept(any(ServerConfiguration.class)); + } + + @Test + void retriesServerErrorThenKeepsColdStateOnNotModified() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = + scriptedClient(requests, delay -> {}, () -> 1.0, response(500, null, null), response(304, null, null)); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + + assertEquals(2, requests.size()); + verifyNoInteractions(listener); + } + + @Test + void warnsRateLimitedAfterRetryableFailuresAreExhausted() throws Exception { + final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, + delay -> {}, + () -> 1.0, + response(503, null, null), + response(503, null, null), + response(503, null, null)); + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor, ratelimitedLogger); + FeatureFlaggingGateway.addConfigListener(listener); + + try { + assertFalse(service.pollOnce()); + + assertEquals(3, requests.size()); + verify(ratelimitedLogger) + .warn("Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", 3, 503); + verifyNoInteractions(listener); + } finally { + service.close(); + } + } + + @Test + void warnsRateLimitedAfterIoFailuresAreExhausted() throws Exception { + final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); + final SocketTimeoutException finalFailure = new SocketTimeoutException("slow HTTP configuration source"); + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, + delay -> {}, + () -> 1.0, + new SocketTimeoutException("slow HTTP configuration source"), + new SocketTimeoutException("slow HTTP configuration source"), + finalFailure); + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor, ratelimitedLogger); + FeatureFlaggingGateway.addConfigListener(listener); + + try { + assertFalse(service.pollOnce()); + + assertEquals(3, requests.size()); + verify(ratelimitedLogger) + .warn("Feature Flagging agentless endpoint request failed after {} attempts", 3, finalFailure); + verifyNoInteractions(listener); + } finally { + service.close(); + } + } + + @Test + void usesIntervalAwareRetryBackoff() throws Exception { + final List delays = new ArrayList<>(); + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, + delays::add, + () -> 1.0, + response(503, null, null), + new SocketTimeoutException("slow HTTP configuration source"), + response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + + assertEquals(Arrays.asList(5_000L, 10_000L), delays); + assertEquals(3, requests.size()); + verify(listener).accept(any(ServerConfiguration.class)); + } + + @Test + void retryPolicyRejectsNonIoAndInterruptedIoFailures() { + final AgentlessConfigurationSource.AgentlessRetryPolicy policy = retryPolicy(new AtomicBoolean()); + + assertFalse(policy.shouldRetry(new IllegalStateException("not an I/O failure"))); + + Thread.currentThread().interrupt(); + try { + assertFalse(policy.shouldRetry(new IOException("interrupted"))); + } finally { + assertTrue(Thread.interrupted()); + } + } + + @Test + void retryPolicyRejectsMissingResponse() { + final AgentlessConfigurationSource.AgentlessRetryPolicy policy = retryPolicy(new AtomicBoolean()); + + assertFalse(policy.shouldRetry((Response) null)); + } + + @Test + void cancelledRetryPolicyRejectsBackoff() { + final AgentlessConfigurationSource.AgentlessRetryPolicy policy = retryPolicy(new AtomicBoolean(true)); + + assertThrows(InterruptedIOException.class, policy::backoff); + } + + @Test + void clampsAndJittersRetryBackoff() { + assertEquals(2_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 1, 1.0)); + assertEquals(5_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 2, 1.0)); + assertEquals(10_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 1, 1.0)); + assertEquals(30_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 2, 1.0)); + assertEquals(6_000, AgentlessConfigurationSource.retryDelayMillis(30_000, 1, 1.2)); + assertThrows( + IllegalArgumentException.class, () -> AgentlessConfigurationSource.retryDelayMillis(30_000, 3, 1.0)); + } + + @Test + void rejectsOverlappingPolls() throws Exception { + final CountDownLatch requestStarted = new CountDownLatch(1); + final CountDownLatch releaseRequest = new CountDownLatch(1); + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); + client.block(requestStarted, releaseRequest); + final AgentlessConfigurationSource service = service(client); + final ExecutorService runner = Executors.newFixedThreadPool(2); + + try { + final Future first = runner.submit(service::pollOnce); + assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); + final Future second = runner.submit(service::pollOnce); + + assertFalse(second.get(1, TimeUnit.SECONDS)); + releaseRequest.countDown(); + assertTrue(first.get(1, TimeUnit.SECONDS)); + assertEquals(1, client.calls.get()); + } finally { + releaseRequest.countDown(); + runner.shutdownNow(); + } + } + + @Test + void initCompletesFirstPollAndCloseCancelsScheduledFuture() throws Exception { + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), + config(), + 60_000, + client, + Executors.newSingleThreadScheduledExecutor()); + FeatureFlaggingGateway.addConfigListener(listener); + + service.init(); + assertEquals(1, client.calls.get()); + service.close(); + + verify(listener).accept(any(ServerConfiguration.class)); + } + + @Test + void initCompletesInitialRetryCycleBeforeReturning() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, delay -> {}, () -> 1.0, response(500, null, null), response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), + config(), + 60_000, + client, + Executors.newSingleThreadScheduledExecutor()); + FeatureFlaggingGateway.addConfigListener(listener); + + try { + service.init(); + + assertEquals(2, requests.size()); + verify(listener).accept(any(ServerConfiguration.class)); + } finally { + service.close(); + } + } + + @Test + void repeatedInitStartsOnlyOnePoller() throws Exception { + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + + service.init(); + service.init(); + awaitCalls(client, 1); + service.close(); + + assertEquals(1, client.calls.get()); + } + + @Test + void scheduledPollContinuesAfterListenerRuntimeException() throws Exception { + final FakeClient client = + new FakeClient(response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), + config(), + 10, + client, + Executors.newSingleThreadScheduledExecutor()); + final AtomicInteger listenerCalls = new AtomicInteger(); + // Wait on the listener rather than on FakeClient.calls: the call counter is incremented when + // a request starts, so it reaches 2 before the second configuration has been applied. + final CountDownLatch listenerNotified = new CountDownLatch(2); + final FeatureFlaggingGateway.ConfigListener flakyListener = configuration -> { + final int call = listenerCalls.incrementAndGet(); + listenerNotified.countDown(); + if (call == 1) { + throw new IllegalStateException("listener rejected first configuration"); + } }; - FeatureFlaggingGateway.addConfigListener(flakyListener); - - try { - service.init(); - assertTrue(listenerNotified.await(5, TimeUnit.SECONDS)); - - assertEquals(2, listenerCalls.get()); - assertNull(client.requests.get(1).etag); - } finally { - service.close(); - FeatureFlaggingGateway.removeConfigListener(flakyListener); - } - } - - @Test - void closeCancelsInFlightRequestAndIgnoresLateSuccess() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.cancelCalls.get()); - assertEquals(1, client.calls.get()); - verifyNoInteractions(listener); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeDuringIoFailurePreventsRetry() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = - new FakeClient(new SocketTimeoutException("slow HTTP configuration source")); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeInterruptsRetryBackoff() throws Exception { - final CountDownLatch backoffStarted = new CountDownLatch(1); - final List requests = new ArrayList<>(); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> { - backoffStarted.countDown(); - TimeUnit.MINUTES.sleep(1); - }, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future initialization = runner.submit(service::init); - assertTrue(backoffStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - initialization.get(1, TimeUnit.SECONDS); - assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS)); - assertEquals(1, requests.size()); - } finally { - runner.shutdownNow(); - } - } - - @Test - void closePreventsFurtherPolls() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - - assertFalse(service.pollOnce()); - assertEquals(0, client.calls.get()); - } - - @Test - void initAfterCloseDoesNotSchedulePoll() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - service.init(); - - assertEquals(0, client.calls.get()); - } - - @Nested - class SystemTestParity { + FeatureFlaggingGateway.addConfigListener(flakyListener); + + try { + service.init(); + assertTrue(listenerNotified.await(5, TimeUnit.SECONDS)); + + assertEquals(2, listenerCalls.get()); + assertNull(client.requests.get(1).etag); + } finally { + service.close(); + FeatureFlaggingGateway.removeConfigListener(flakyListener); + } + } + + @Test + void closeCancelsInFlightRequestAndIgnoresLateSuccess() throws Exception { + final CountDownLatch requestStarted = new CountDownLatch(1); + final CountDownLatch releaseRequest = new CountDownLatch(1); + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); + client.block(requestStarted, releaseRequest); + final AgentlessConfigurationSource service = service(client); + final ExecutorService runner = Executors.newSingleThreadExecutor(); + FeatureFlaggingGateway.addConfigListener(listener); + + try { + final Future poll = runner.submit(service::pollOnce); + assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); + + service.close(); + + assertFalse(poll.get(1, TimeUnit.SECONDS)); + assertEquals(1, client.cancelCalls.get()); + assertEquals(1, client.calls.get()); + verifyNoInteractions(listener); + } finally { + releaseRequest.countDown(); + runner.shutdownNow(); + } + } + + @Test + void closeDuringIoFailurePreventsRetry() throws Exception { + final CountDownLatch requestStarted = new CountDownLatch(1); + final CountDownLatch releaseRequest = new CountDownLatch(1); + final FakeClient client = new FakeClient(new SocketTimeoutException("slow HTTP configuration source")); + client.block(requestStarted, releaseRequest); + final AgentlessConfigurationSource service = service(client); + final ExecutorService runner = Executors.newSingleThreadExecutor(); + + try { + final Future poll = runner.submit(service::pollOnce); + assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); + + service.close(); + + assertFalse(poll.get(1, TimeUnit.SECONDS)); + assertEquals(1, client.calls.get()); + } finally { + releaseRequest.countDown(); + runner.shutdownNow(); + } + } + + @Test + void closeInterruptsRetryBackoff() throws Exception { + final CountDownLatch backoffStarted = new CountDownLatch(1); + final List requests = new ArrayList<>(); + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, + delay -> { + backoffStarted.countDown(); + TimeUnit.MINUTES.sleep(1); + }, + () -> 1.0, + new SocketTimeoutException("slow HTTP configuration source"), + response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor); + final ExecutorService runner = Executors.newSingleThreadExecutor(); + + try { + final Future initialization = runner.submit(service::init); + assertTrue(backoffStarted.await(1, TimeUnit.SECONDS)); + + service.close(); + + initialization.get(1, TimeUnit.SECONDS); + assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS)); + assertEquals(1, requests.size()); + } finally { + runner.shutdownNow(); + } + } + + @Test + void closePreventsFurtherPolls() throws Exception { + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + + service.close(); + + assertFalse(service.pollOnce()); + assertEquals(0, client.calls.get()); + } + @Test - void preservesSystemTestSourceTransitionsAndLastKnownGoodState() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(200, "etag-a", emptyConfig()), - response(304, "etag-must-not-replace-a", null), - response(509, null, null), - response(200, "etag-b", emptyConfig()), - response(200, "etag-c", "{not-json}"), - response(401, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", requests.get(1).header("If-None-Match")); - assertEquals("etag-a", requests.get(2).header("If-None-Match")); - assertEquals("etag-a", requests.get(3).header("If-None-Match")); - assertEquals("etag-b", requests.get(4).header("If-None-Match")); - assertEquals("etag-b", requests.get(5).header("If-None-Match")); - } - } - - private static AgentlessConfigurationSource service( - final AgentlessConfigurationSource.UfcHttpClient client) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource service( - final FakeClient client, final Config config) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config, - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource.OkHttpUfcHttpClient scriptedClient( - final List requests, - final AgentlessConfigurationSource.RetrySleeper retrySleeper, - final java.util.function.DoubleSupplier jitter, - final Object... outcomes) - throws IOException { - final BlockingQueue scriptedOutcomes = new LinkedBlockingQueue<>(); - scriptedOutcomes.addAll(Arrays.asList(outcomes)); - final OkHttpClient httpClient = mock(OkHttpClient.class); - when(httpClient.newCall(any())) - .thenAnswer( - invocation -> { - final okhttp3.Request request = invocation.getArgument(0); - requests.add(request); - final Object outcome = scriptedOutcomes.remove(); - final Call call = mock(Call.class); - when(call.execute()) - .thenAnswer( - ignored -> { - if (outcome instanceof IOException) { - throw (IOException) outcome; - } - return okHttpResponse( - request, (AgentlessConfigurationSource.UfcHttpResponse) outcome); - }); - return call; + void initAfterCloseDoesNotSchedulePoll() throws Exception { + final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); + final AgentlessConfigurationSource service = service(client); + + service.close(); + service.init(); + + assertEquals(0, client.calls.get()); + } + + @Nested + class SystemTestParity { + @Test + void preservesSystemTestSourceTransitionsAndLastKnownGoodState() throws Exception { + final List requests = new ArrayList<>(); + final AgentlessConfigurationSource.OkHttpUfcHttpClient client = scriptedClient( + requests, + delay -> {}, + () -> 1.0, + response(200, "etag-a", emptyConfig()), + response(304, "etag-must-not-replace-a", null), + response(509, null, null), + response(200, "etag-b", emptyConfig()), + response(200, "etag-c", "{not-json}"), + response(401, null, null)); + final AgentlessConfigurationSource service = service(client); + FeatureFlaggingGateway.addConfigListener(listener); + + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + assertTrue(service.pollOnce()); + assertFalse(service.pollOnce()); + assertFalse(service.pollOnce()); + + verify(listener, times(2)).accept(any(ServerConfiguration.class)); + assertEquals("etag-a", requests.get(1).header("If-None-Match")); + assertEquals("etag-a", requests.get(2).header("If-None-Match")); + assertEquals("etag-a", requests.get(3).header("If-None-Match")); + assertEquals("etag-b", requests.get(4).header("If-None-Match")); + assertEquals("etag-b", requests.get(5).header("If-None-Match")); + } + } + + private static AgentlessConfigurationSource service(final AgentlessConfigurationSource.UfcHttpClient client) { + return new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), + config(), + 30_000, + client, + Executors.newSingleThreadScheduledExecutor()); + } + + private static AgentlessConfigurationSource service(final FakeClient client, final Config config) { + return new AgentlessConfigurationSource( + HttpUrl.get("http://localhost" + CONFIG_PATH), + config, + 30_000, + client, + Executors.newSingleThreadScheduledExecutor()); + } + + private static AgentlessConfigurationSource.OkHttpUfcHttpClient scriptedClient( + final List requests, + final AgentlessConfigurationSource.RetrySleeper retrySleeper, + final java.util.function.DoubleSupplier jitter, + final Object... outcomes) + throws IOException { + final BlockingQueue scriptedOutcomes = new LinkedBlockingQueue<>(); + scriptedOutcomes.addAll(Arrays.asList(outcomes)); + final OkHttpClient httpClient = mock(OkHttpClient.class); + when(httpClient.newCall(any())).thenAnswer(invocation -> { + final okhttp3.Request request = invocation.getArgument(0); + requests.add(request); + final Object outcome = scriptedOutcomes.remove(); + final Call call = mock(Call.class); + when(call.execute()).thenAnswer(ignored -> { + if (outcome instanceof IOException) { + throw (IOException) outcome; + } + return okHttpResponse(request, (AgentlessConfigurationSource.UfcHttpResponse) outcome); }); - return new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, retrySleeper, jitter); - } - - private static AgentlessConfigurationSource.AgentlessRetryPolicy retryPolicy( - final AtomicBoolean cancelled) { - return new AgentlessConfigurationSource.AgentlessRetryPolicy( - cancelled, 30_000, delay -> {}, () -> 1.0); - } - - private static Response okHttpResponse( - final okhttp3.Request request, final AgentlessConfigurationSource.UfcHttpResponse response) { - final Response.Builder builder = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(response.status) - .message(Integer.toString(response.status)); - if (response.etag != null) { - builder.header("ETag", response.etag); - } - if (response.body != null) { - builder.body(ResponseBody.create(MediaType.get("application/json"), response.body)); - } - return builder.build(); - } - - private static Config config() { - return config("datadoghq.com", ""); - } - - private static Config config(final String site, final String env) { - final Config config = mock(Config.class); - lenient() - .when(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()) - .thenReturn(30); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()) - .thenReturn(5); - lenient().when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()).thenReturn(null); - lenient().when(config.getApiKey()).thenReturn("test-api-key"); - lenient().when(config.getSite()).thenReturn(site); - lenient().when(config.getEnv()).thenReturn(env); - return config; - } - - private static AgentlessConfigurationSource.UfcHttpResponse response( - final int status, final String etag, final String body) { - return new AgentlessConfigurationSource.UfcHttpResponse( - status, etag, body == null ? null : body.getBytes(UTF_8)); - } - - private static String emptyConfig() { - return jsonApiResponse("universal-flag-configuration", emptyConfigAttributes()); - } - - private static String emptyConfigAttributes() { - return "{" - + "\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," - + "\"environment\":{\"name\":\"Staging\"}," - + "\"flags\":{}" - + "}"; - } - - private static String jsonApiResponse(final String type, final String attributes) { - return "{\"data\":{" - + "\"id\":\"1\"," - + "\"type\":\"" - + type - + "\"," - + "\"attributes\":" - + attributes - + "}}"; - } - - private static String largeConfig(final int flagCount) { - final StringBuilder json = - new StringBuilder( - "{\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," + return call; + }); + return new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient, 30_000, retrySleeper, jitter); + } + + private static AgentlessConfigurationSource.AgentlessRetryPolicy retryPolicy(final AtomicBoolean cancelled) { + return new AgentlessConfigurationSource.AgentlessRetryPolicy(cancelled, 30_000, delay -> {}, () -> 1.0); + } + + private static Response okHttpResponse( + final okhttp3.Request request, final AgentlessConfigurationSource.UfcHttpResponse response) { + final Response.Builder builder = new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(response.status) + .message(Integer.toString(response.status)); + if (response.etag != null) { + builder.header("ETag", response.etag); + } + if (response.body != null) { + builder.body(ResponseBody.create(MediaType.get("application/json"), response.body)); + } + return builder.build(); + } + + private static Config config() { + return config("datadoghq.com", ""); + } + + private static Config config(final String site, final String env) { + final Config config = mock(Config.class); + lenient() + .when(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()) + .thenReturn(30); + lenient() + .when(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()) + .thenReturn(5); + lenient() + .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) + .thenReturn(null); + lenient().when(config.getApiKey()).thenReturn("test-api-key"); + lenient().when(config.getSite()).thenReturn(site); + lenient().when(config.getEnv()).thenReturn(env); + return config; + } + + private static AgentlessConfigurationSource.UfcHttpResponse response( + final int status, final String etag, final String body) { + return new AgentlessConfigurationSource.UfcHttpResponse( + status, etag, body == null ? null : body.getBytes(UTF_8)); + } + + private static String emptyConfig() { + return jsonApiResponse("universal-flag-configuration", emptyConfigAttributes()); + } + + private static String emptyConfigAttributes() { + return "{" + + "\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," + + "\"environment\":{\"name\":\"Staging\"}," + + "\"flags\":{}" + + "}"; + } + + private static String jsonApiResponse(final String type, final String attributes) { + return "{\"data\":{" + "\"id\":\"1\"," + "\"type\":\"" + type + "\"," + "\"attributes\":" + attributes + "}}"; + } + + private static String largeConfig(final int flagCount) { + final StringBuilder json = new StringBuilder("{\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," + "\"environment\":{\"name\":\"Large Test\"}," + "\"flags\":{"); - for (int index = 0; index < flagCount; index++) { - if (index > 0) { - json.append(','); - } - final String flagKey = "large-flag-" + index; - json.append('"') - .append(flagKey) - .append("\":{\"key\":\"") - .append(flagKey) - .append( - "\",\"enabled\":true,\"variationType\":\"STRING\"," - + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"on\"}}," - + "\"allocations\":[]}"); - } - return jsonApiResponse("universal-flag-configuration", json.append("}}").toString()); - } - - private static byte[] gzip(final String value) throws IOException { - final ByteArrayOutputStream output = new ByteArrayOutputStream(); - try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { - gzip.write(value.getBytes(UTF_8)); - } - return output.toByteArray(); - } - - private static void awaitCalls(final FakeClient client, final int count) throws Exception { - for (int i = 0; i < 100; i++) { - if (client.calls.get() >= count) { - return; - } - TimeUnit.MILLISECONDS.sleep(10); - } - assertEquals(count, client.calls.get()); - } - - private static final class FakeClient implements AgentlessConfigurationSource.UfcHttpClient { - private final AtomicInteger calls = new AtomicInteger(); - private final AtomicInteger cancelCalls = new AtomicInteger(); - private final List requests = new ArrayList<>(); - private final BlockingQueue responses = new LinkedBlockingQueue<>(); - private CountDownLatch requestStarted; - private CountDownLatch releaseRequest; - - private FakeClient(final Object... responses) { - for (final Object response : responses) { - this.responses.add(response); - } - } - - private void block(final CountDownLatch requestStarted, final CountDownLatch releaseRequest) { - this.requestStarted = requestStarted; - this.releaseRequest = releaseRequest; - } - - @Override - public AgentlessConfigurationSource.UfcHttpResponse fetch( - final HttpUrl endpoint, final Config config, final String etag) throws IOException { - calls.incrementAndGet(); - requests.add(new Request(etag)); - if (requestStarted != null) { - requestStarted.countDown(); - } - if (releaseRequest != null) { - await(releaseRequest); - } - final Object response = responses.remove(); - if (response instanceof IOException) { - throw (IOException) response; - } - if (response instanceof RuntimeException) { - throw (RuntimeException) response; - } - return (AgentlessConfigurationSource.UfcHttpResponse) response; - } - - @Override - public void cancel() { - cancelCalls.incrementAndGet(); - if (releaseRequest != null) { - releaseRequest.countDown(); - } - } - - private static void await(final CountDownLatch latch) throws IOException { - try { - if (!latch.await(1, TimeUnit.SECONDS)) { - throw new SocketTimeoutException("test request did not release"); + for (int index = 0; index < flagCount; index++) { + if (index > 0) { + json.append(','); + } + final String flagKey = "large-flag-" + index; + json.append('"') + .append(flagKey) + .append("\":{\"key\":\"") + .append(flagKey) + .append("\",\"enabled\":true,\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"on\"}}," + + "\"allocations\":[]}"); } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException(e); - } + return jsonApiResponse("universal-flag-configuration", json.append("}}").toString()); } - } - private static final class Request { - private final String etag; + private static byte[] gzip(final String value) throws IOException { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { + gzip.write(value.getBytes(UTF_8)); + } + return output.toByteArray(); + } + + private static void awaitCalls(final FakeClient client, final int count) throws Exception { + for (int i = 0; i < 100; i++) { + if (client.calls.get() >= count) { + return; + } + TimeUnit.MILLISECONDS.sleep(10); + } + assertEquals(count, client.calls.get()); + } + + private static final class FakeClient implements AgentlessConfigurationSource.UfcHttpClient { + private final AtomicInteger calls = new AtomicInteger(); + private final AtomicInteger cancelCalls = new AtomicInteger(); + private final List requests = new ArrayList<>(); + private final BlockingQueue responses = new LinkedBlockingQueue<>(); + private CountDownLatch requestStarted; + private CountDownLatch releaseRequest; + + private FakeClient(final Object... responses) { + for (final Object response : responses) { + this.responses.add(response); + } + } + + private void block(final CountDownLatch requestStarted, final CountDownLatch releaseRequest) { + this.requestStarted = requestStarted; + this.releaseRequest = releaseRequest; + } - private Request(final String etag) { - this.etag = etag; + @Override + public AgentlessConfigurationSource.UfcHttpResponse fetch( + final HttpUrl endpoint, final Config config, final String etag) throws IOException { + calls.incrementAndGet(); + requests.add(new Request(etag)); + if (requestStarted != null) { + requestStarted.countDown(); + } + if (releaseRequest != null) { + await(releaseRequest); + } + final Object response = responses.remove(); + if (response instanceof IOException) { + throw (IOException) response; + } + if (response instanceof RuntimeException) { + throw (RuntimeException) response; + } + return (AgentlessConfigurationSource.UfcHttpResponse) response; + } + + @Override + public void cancel() { + cancelCalls.incrementAndGet(); + if (releaseRequest != null) { + releaseRequest.countDown(); + } + } + + private static void await(final CountDownLatch latch) throws IOException { + try { + if (!latch.await(1, TimeUnit.SECONDS)) { + throw new SocketTimeoutException("test request did not release"); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + } + } + + private static final class Request { + private final String etag; + + private Request(final String etag) { + this.etag = etag; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java index 117c72ba2a1..bb5123e78bf 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java @@ -28,169 +28,157 @@ class AgentlessFeatureFlagBackendApiTest { - @ParameterizedTest - @ValueSource(ints = {403, 404, 405}) - void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(statusCode, "rejected")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "flag evaluation"); - final RequestBody firstBody = requestBody("first"); - final RequestBody secondBody = requestBody("second"); - - assertEquals(0, directApiCreations.get()); - api.post("flagevaluation", firstBody, stream -> null, null, false); - api.post("flagevaluation", secondBody, stream -> null, null, false); - - assertEquals(1, directApiCreations.get()); - assertEquals(1, local.calls); - assertEquals(2, direct.calls); - assertSame(firstBody, local.requestBodies.get(0)); - assertSame(firstBody, direct.requestBodies.get(0)); - assertSame(secondBody, direct.requestBodies.get(1)); - } - - @ParameterizedTest - @MethodSource("featureFlagRoutes") - void fallsBackAfterConnectionRefusal(final String route, final String eventType) - throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi(local, () -> direct, eventType); - - api.post(route, requestBody(eventType), stream -> null, null, false); - - assertEquals(1, local.calls); - assertEquals(1, direct.calls); - } - - @Test - void doesNotReturnToLocalRouteAfterSwitchingToDirectIntake() throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi(local, () -> direct, "exposure"); - - api.post("exposures", requestBody("first"), stream -> null, null, false); - direct.failure = new IOException("direct intake failed"); - - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - assertEquals(1, local.calls); - assertEquals(2, direct.calls); - } - - @ParameterizedTest - @ValueSource(ints = {429, 500}) - void doesNotReplayAmbiguousHttpFailure(final int statusCode) { - assertNoDirectReplay(new HttpResponseException(statusCode, "ambiguous")); - } - - @Test - void doesNotReplayTimeout() { - assertNoDirectReplay(new SocketTimeoutException("timed out")); - } - - @Test - void doesNotReplayConnectionReset() { - assertNoDirectReplay(new SocketException("connection reset")); - } - - @Test - void doesNotRetryDirectApiCreationWhenFallbackIsUnavailable() { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(404, "rejected")); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return null; - }, - "exposure"); - - assertThrows( - HttpResponseException.class, - () -> api.post("exposures", requestBody("first"), stream -> null, null, false)); - assertThrows( - HttpResponseException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - - assertEquals(2, local.calls); - assertEquals(1, directApiCreations.get()); - } - - private static void assertNoDirectReplay(final IOException failure) { - final RecordingBackendApi local = new RecordingBackendApi(failure); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "flag evaluation"); - - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("evaluation"), stream -> null, null, false)); - - assertEquals(1, local.calls); - assertEquals(0, direct.calls); - assertEquals(0, directApiCreations.get()); - } - - private static RequestBody requestBody(final String value) { - return RequestBody.create(MediaType.parse("application/json"), value); - } - - private static Stream featureFlagRoutes() { - return Stream.of( - Arguments.of("exposures", "exposure"), Arguments.of("flagevaluation", "flag evaluation")); - } - - private static final class RecordingBackendApi implements BackendApi { - private IOException failure; - private final List requestBodies = new ArrayList<>(); - private int calls; - - private RecordingBackendApi() { - this(null); + @ParameterizedTest + @ValueSource(ints = {403, 404, 405}) + void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throws Exception { + final RecordingBackendApi local = new RecordingBackendApi(new HttpResponseException(statusCode, "rejected")); + final RecordingBackendApi direct = new RecordingBackendApi(); + final AtomicInteger directApiCreations = new AtomicInteger(); + final AgentlessFeatureFlagBackendApi api = new AgentlessFeatureFlagBackendApi( + local, + () -> { + directApiCreations.incrementAndGet(); + return direct; + }, + "flag evaluation"); + final RequestBody firstBody = requestBody("first"); + final RequestBody secondBody = requestBody("second"); + + assertEquals(0, directApiCreations.get()); + api.post("flagevaluation", firstBody, stream -> null, null, false); + api.post("flagevaluation", secondBody, stream -> null, null, false); + + assertEquals(1, directApiCreations.get()); + assertEquals(1, local.calls); + assertEquals(2, direct.calls); + assertSame(firstBody, local.requestBodies.get(0)); + assertSame(firstBody, direct.requestBodies.get(0)); + assertSame(secondBody, direct.requestBodies.get(1)); } - private RecordingBackendApi(@Nullable final IOException failure) { - this.failure = failure; + @ParameterizedTest + @MethodSource("featureFlagRoutes") + void fallsBackAfterConnectionRefusal(final String route, final String eventType) throws Exception { + final RecordingBackendApi local = new RecordingBackendApi(new ConnectException("connection refused")); + final RecordingBackendApi direct = new RecordingBackendApi(); + final AgentlessFeatureFlagBackendApi api = new AgentlessFeatureFlagBackendApi(local, () -> direct, eventType); + + api.post(route, requestBody(eventType), stream -> null, null, false); + + assertEquals(1, local.calls); + assertEquals(1, direct.calls); + } + + @Test + void doesNotReturnToLocalRouteAfterSwitchingToDirectIntake() throws Exception { + final RecordingBackendApi local = new RecordingBackendApi(new ConnectException("connection refused")); + final RecordingBackendApi direct = new RecordingBackendApi(); + final AgentlessFeatureFlagBackendApi api = new AgentlessFeatureFlagBackendApi(local, () -> direct, "exposure"); + + api.post("exposures", requestBody("first"), stream -> null, null, false); + direct.failure = new IOException("direct intake failed"); + + assertThrows( + IOException.class, () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); + assertEquals(1, local.calls); + assertEquals(2, direct.calls); + } + + @ParameterizedTest + @ValueSource(ints = {429, 500}) + void doesNotReplayAmbiguousHttpFailure(final int statusCode) { + assertNoDirectReplay(new HttpResponseException(statusCode, "ambiguous")); + } + + @Test + void doesNotReplayTimeout() { + assertNoDirectReplay(new SocketTimeoutException("timed out")); + } + + @Test + void doesNotReplayConnectionReset() { + assertNoDirectReplay(new SocketException("connection reset")); + } + + @Test + void doesNotRetryDirectApiCreationWhenFallbackIsUnavailable() { + final RecordingBackendApi local = new RecordingBackendApi(new HttpResponseException(404, "rejected")); + final AtomicInteger directApiCreations = new AtomicInteger(); + final AgentlessFeatureFlagBackendApi api = new AgentlessFeatureFlagBackendApi( + local, + () -> { + directApiCreations.incrementAndGet(); + return null; + }, + "exposure"); + + assertThrows( + HttpResponseException.class, + () -> api.post("exposures", requestBody("first"), stream -> null, null, false)); + assertThrows( + HttpResponseException.class, + () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); + + assertEquals(2, local.calls); + assertEquals(1, directApiCreations.get()); + } + + private static void assertNoDirectReplay(final IOException failure) { + final RecordingBackendApi local = new RecordingBackendApi(failure); + final RecordingBackendApi direct = new RecordingBackendApi(); + final AtomicInteger directApiCreations = new AtomicInteger(); + final AgentlessFeatureFlagBackendApi api = new AgentlessFeatureFlagBackendApi( + local, + () -> { + directApiCreations.incrementAndGet(); + return direct; + }, + "flag evaluation"); + + assertThrows( + IOException.class, + () -> api.post("flagevaluation", requestBody("evaluation"), stream -> null, null, false)); + + assertEquals(1, local.calls); + assertEquals(0, direct.calls); + assertEquals(0, directApiCreations.get()); + } + + private static RequestBody requestBody(final String value) { + return RequestBody.create(MediaType.parse("application/json"), value); + } + + private static Stream featureFlagRoutes() { + return Stream.of(Arguments.of("exposures", "exposure"), Arguments.of("flagevaluation", "flag evaluation")); } - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - calls++; - requestBodies.add(requestBody); - if (failure != null) { - throw failure; - } - return null; + private static final class RecordingBackendApi implements BackendApi { + private IOException failure; + private final List requestBodies = new ArrayList<>(); + private int calls; + + private RecordingBackendApi() { + this(null); + } + + private RecordingBackendApi(@Nullable final IOException failure) { + this.failure = failure; + } + + @Override + public T post( + final String uri, + final RequestBody requestBody, + final IOThrowingFunction responseParser, + @Nullable final OkHttpUtils.CustomListener requestListener, + final boolean requestCompression) + throws IOException { + calls++; + requestBodies.add(requestBody); + if (failure != null) { + throw failure; + } + return null; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java index f70a4ff0fa1..ee0bba67064 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java @@ -72,475 +72,448 @@ class ExposureWriterTests { - private static final String EXPOSURES_ENDPOINT = "/evp_proxy/api/v2/exposures"; - private static final String DIRECT_EXPOSURES_ENDPOINT = "/api/v2/exposures"; - private static final String API_KEY = "test-api-key"; - private static final double TIMEOUT_SECONDS = 5; - - private final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); - private Queue requests; - private Set failed; - private JavaTestHttpServer server; - private SharedCommunicationObjects sharedCommunicationObjects; - - @BeforeEach - void setUp() { - requests = new ConcurrentLinkedQueue<>(); - failed = Collections.newSetFromMap(new ConcurrentHashMap()); - JsonAdapter adapter = - new Moshi.Builder().build().adapter(ExposuresRequest.class); - server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> { - h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - h.prefix( - DIRECT_EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - })); - sharedCommunicationObjects = sharedCommunicationObjects(true); - } - - @AfterEach - void cleanup() { - if (server != null) { - server.close(); + private static final String EXPOSURES_ENDPOINT = "/evp_proxy/api/v2/exposures"; + private static final String DIRECT_EXPOSURES_ENDPOINT = "/api/v2/exposures"; + private static final String API_KEY = "test-api-key"; + private static final double TIMEOUT_SECONDS = 5; + + private final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); + private Queue requests; + private Set failed; + private JavaTestHttpServer server; + private SharedCommunicationObjects sharedCommunicationObjects; + + @BeforeEach + void setUp() { + requests = new ConcurrentLinkedQueue<>(); + failed = Collections.newSetFromMap(new ConcurrentHashMap()); + JsonAdapter adapter = new Moshi.Builder().build().adapter(ExposuresRequest.class); + server = JavaTestHttpServer.httpServer(s -> s.handlers(h -> { + h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); + h.prefix(DIRECT_EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); + })); + sharedCommunicationObjects = sharedCommunicationObjects(true); } - } - - private void handleExposureRequest(HandlerApi api, JsonAdapter adapter) - throws Exception { - ExposuresRequest exposuresRequest = - adapter.fromJson( - Okio.buffer(Okio.source(new ByteArrayInputStream(api.getRequest().getBody())))); - String serviceName = exposuresRequest.context.get("service"); - boolean failForever = "fail-forever".equals(serviceName); - boolean fail = serviceName.startsWith("fail") && (failed.add(serviceName) || failForever); - if (fail) { - api.getResponse().status(500).send("Boom!!!"); - } else { - requests.add(exposuresRequest); - api.getResponse().status(200).send("OK"); + + @AfterEach + void cleanup() { + if (server != null) { + server.close(); + } + } + + private void handleExposureRequest(HandlerApi api, JsonAdapter adapter) throws Exception { + ExposuresRequest exposuresRequest = adapter.fromJson(Okio.buffer( + Okio.source(new ByteArrayInputStream(api.getRequest().getBody())))); + String serviceName = exposuresRequest.context.get("service"); + boolean failForever = "fail-forever".equals(serviceName); + boolean fail = serviceName.startsWith("fail") && (failed.add(serviceName) || failForever); + if (fail) { + api.getResponse().status(500).send("Boom!!!"); + } else { + requests.add(exposuresRequest); + api.getResponse().status(200).send("OK"); + } } - } - - @TableTest({ - "service | env | version", - " | | ", - "'test-service' | 'test' | '23' ", - "'test-service' | | '23' ", - "'test-service' | 'test' | " - }) - void testExposureEventWrites(String service, String env, String version) throws Exception { - Config config = mockConfig(service, env, version); - List exposures = buildExposures(5); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - poll.eventually( - () -> { - assertFalse(requests.isEmpty()); - for (ExposuresRequest request : requests) { - assertContext(request.context, service, env, version); + + @TableTest({ + "service | env | version", + " | | ", + "'test-service' | 'test' | '23' ", + "'test-service' | | '23' ", + "'test-service' | 'test' | " + }) + void testExposureEventWrites(String service, String env, String version) throws Exception { + Config config = mockConfig(service, env, version); + List exposures = buildExposures(5); + + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { + writer.init(); + for (ExposureEvent exposure : exposures) { + writer.accept(exposure); } - assertExposures(allExposures(), exposures); - }); + + poll.eventually(() -> { + assertFalse(requests.isEmpty()); + for (ExposuresRequest request : requests) { + assertContext(request.context, service, env, version); + } + assertExposures(allExposures(), exposures); + }); + } } - } - - @Test - void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { - Config config = mockConfig("test-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - IntakeApi directApi = - new IntakeApi( - HttpUrl.get(server.getAddress()).resolve("/api/v2/"), - API_KEY, - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - new OkHttpClient.Builder().build(), - false); - when(backendApiFactory.createDirectIntakeApi( - eq(datadog.trace.api.intake.Intake.EVENT_PLATFORM), eq(true), eq(false))) - .thenReturn(directApi); - FeatureFlagBackendApiFactory exposureBackendApiFactory = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); - List exposures = buildExposures(5); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, exposureBackendApiFactory, config)) { - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - poll.eventually( - () -> { - assertEquals(DIRECT_EXPOSURES_ENDPOINT, server.getLastRequest().getPath()); - assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); - assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); - assertExposures(allExposures(), exposures); - }); + + @Test + void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { + Config config = mockConfig("test-service"); + when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); + when(config.getApiKey()).thenReturn(API_KEY); + BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + IntakeApi directApi = new IntakeApi( + HttpUrl.get(server.getAddress()).resolve("/api/v2/"), + API_KEY, + "123", + HttpRetryPolicy.Factory.NEVER_RETRY, + new OkHttpClient.Builder().build(), + false); + when(backendApiFactory.createDirectIntakeApi( + eq(datadog.trace.api.intake.Intake.EVENT_PLATFORM), eq(true), eq(false))) + .thenReturn(directApi); + FeatureFlagBackendApiFactory exposureBackendApiFactory = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); + List exposures = buildExposures(5); + + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, exposureBackendApiFactory, config)) { + writer.init(); + for (ExposureEvent exposure : exposures) { + writer.accept(exposure); + } + + poll.eventually(() -> { + assertEquals(DIRECT_EXPOSURES_ENDPOINT, server.getLastRequest().getPath()); + assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); + assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); + assertExposures(allExposures(), exposures); + }); + } } - } - - @Test - void testLruCache() throws Exception { - Config config = mockConfig("test-service"); - List exposures = buildExposures(6); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - // populating the cache - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - // all events are written - poll.eventually(() -> assertEquals(exposures.size(), allExposures().size())); - - // publishing duplicate events - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - // no events are written - MILLISECONDS.sleep(300); // wait until a flush happens - assertEquals(exposures.size(), allExposures().size()); - - // a new event is generated - writer.accept(buildExposure()); - - // oldest event is evicted and the new one is submitted - poll.eventually(() -> assertEquals(exposures.size() + 1, allExposures().size())); + + @Test + void testLruCache() throws Exception { + Config config = mockConfig("test-service"); + List exposures = buildExposures(6); + + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { + writer.init(); + // populating the cache + for (ExposureEvent exposure : exposures) { + writer.accept(exposure); + } + + // all events are written + poll.eventually(() -> assertEquals(exposures.size(), allExposures().size())); + + // publishing duplicate events + for (ExposureEvent exposure : exposures) { + writer.accept(exposure); + } + + // no events are written + MILLISECONDS.sleep(300); // wait until a flush happens + assertEquals(exposures.size(), allExposures().size()); + + // a new event is generated + writer.accept(buildExposure()); + + // oldest event is evicted and the new one is submitted + poll.eventually( + () -> assertEquals(exposures.size() + 1, allExposures().size())); + } } - } - - @Test - void testHighLoadScenario() throws Exception { - Config config = mockConfig("test-service"); - int exposuresPerThread = 100; - Random random = new Random(); - int threads = Runtime.getRuntime().availableProcessors(); - ExecutorService executor = Executors.newFixedThreadPool(threads); - List exposures = buildExposures(threads * exposuresPerThread); - CountDownLatch latch = new CountDownLatch(1); - - try (ExposureWriterImpl writer = new ExposureWriterImpl(sharedCommunicationObjects, config)) { - writer.init(); - List> futures = new ArrayList<>(); - for (int index = 0; index < exposures.size(); index += exposuresPerThread) { - List partition = - exposures.subList(index, Math.min(index + exposuresPerThread, exposures.size())); - futures.add( - executor.submit( - () -> { - latch.await(); - for (ExposureEvent exposure : partition) { - MILLISECONDS.sleep(random.nextInt(2)); - writer.accept(exposure); - } - return true; + + @Test + void testHighLoadScenario() throws Exception { + Config config = mockConfig("test-service"); + int exposuresPerThread = 100; + Random random = new Random(); + int threads = Runtime.getRuntime().availableProcessors(); + ExecutorService executor = Executors.newFixedThreadPool(threads); + List exposures = buildExposures(threads * exposuresPerThread); + CountDownLatch latch = new CountDownLatch(1); + + try (ExposureWriterImpl writer = new ExposureWriterImpl(sharedCommunicationObjects, config)) { + writer.init(); + List> futures = new ArrayList<>(); + for (int index = 0; index < exposures.size(); index += exposuresPerThread) { + List partition = + exposures.subList(index, Math.min(index + exposuresPerThread, exposures.size())); + futures.add(executor.submit(() -> { + latch.await(); + for (ExposureEvent exposure : partition) { + MILLISECONDS.sleep(random.nextInt(2)); + writer.accept(exposure); + } + return true; })); - } - latch.countDown(); // start threads - - for (Future future : futures) { - assertTrue(future.get()); // wait for all threads to finish - } - poll.eventually(() -> assertExposures(allExposures(), exposures)); - } finally { - executor.shutdownNow(); + } + latch.countDown(); // start threads + + for (Future future : futures) { + assertTrue(future.get()); // wait for all threads to finish + } + poll.eventually(() -> assertExposures(allExposures(), exposures)); + } finally { + executor.shutdownNow(); + } } - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void testFailuresAreRetried(boolean finallyFail) throws Exception { - String serviceName = finallyFail ? "fail-forever" : "fail-once"; - Config config = mockConfig(serviceName); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure()); - - MILLISECONDS.sleep(500); // wait for a flush to happen - ExposuresRequest found = findRequest(serviceName); - if (finallyFail) { - assertNull(found, requests.toString()); - } else { - poll.eventually(() -> assertNotNull(findRequest(serviceName), requests.toString())); - } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testFailuresAreRetried(boolean finallyFail) throws Exception { + String serviceName = finallyFail ? "fail-forever" : "fail-once"; + Config config = mockConfig(serviceName); + + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { + writer.init(); + writer.accept(buildExposure()); + + MILLISECONDS.sleep(500); // wait for a flush to happen + ExposuresRequest found = findRequest(serviceName); + if (finallyFail) { + assertNull(found, requests.toString()); + } else { + poll.eventually(() -> assertNotNull(findRequest(serviceName), requests.toString())); + } + } } - } - @Test - void testSerializationFailureDoesNotPoisonFollowingExposures() throws Exception { - Config config = mockConfig("test-service"); - ExposureEvent validExposure = buildExposure(); + @Test + void testSerializationFailureDoesNotPoisonFollowingExposures() throws Exception { + Config config = mockConfig("test-service"); + ExposureEvent validExposure = buildExposure(); - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure(singletonMap("invalid", (Object) Double.NaN))); + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { + writer.init(); + writer.accept(buildExposure(singletonMap("invalid", (Object) Double.NaN))); - MILLISECONDS.sleep(300); // wait for the invalid batch to be dropped - writer.accept(validExposure); + MILLISECONDS.sleep(300); // wait for the invalid batch to be dropped + writer.accept(validExposure); - poll.eventually(() -> assertExposures(allExposures(), singletonList(validExposure))); + poll.eventually(() -> assertExposures(allExposures(), singletonList(validExposure))); + } } - } - - @Test - void testAmbiguousExposureBatchIsNotRetriedOrReplayedDirectly() throws Exception { - final Config config = mockConfig("test-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, true, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(proxyApi); - when(backendApiFactory.createDirectIntakeApi(eq(Intake.EVENT_PLATFORM), eq(true), eq(false))) - .thenReturn(directApi); - when(proxyApi.post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))) - .thenThrow(new SocketTimeoutException("ambiguous timeout")) - .thenThrow(new ConnectException("definitive refusal")); - final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); - final List exposures = buildExposures(2); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, featureFlagBackendApiFactory, config)) { - writer.init(); - writer.accept(exposures.get(0)); - - poll.eventually( - () -> - verify(proxyApi) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); - MILLISECONDS.sleep(300); - verify(proxyApi, times(1)) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); - verify(directApi, never()) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); - - writer.accept(exposures.get(1)); - poll.eventually( - () -> - verify(directApi) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); - - final ArgumentCaptor directBody = ArgumentCaptor.forClass(RequestBody.class); - verify(directApi).post(eq("exposures"), directBody.capture(), any(), any(), eq(false)); - final Buffer buffer = new Buffer(); - directBody.getValue().writeTo(buffer); - final ExposuresRequest directRequest = - new Moshi.Builder().build().adapter(ExposuresRequest.class).fromJson(buffer.readUtf8()); - assertNotNull(directRequest); - assertExposures(directRequest.exposures, singletonList(exposures.get(1))); + + @Test + void testAmbiguousExposureBatchIsNotRetriedOrReplayedDirectly() throws Exception { + final Config config = mockConfig("test-service"); + when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); + when(config.getApiKey()).thenReturn(API_KEY); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi proxyApi = mock(BackendApi.class); + final BackendApi directApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, true, HttpRetryPolicy.Factory.NEVER_RETRY)) + .thenReturn(proxyApi); + when(backendApiFactory.createDirectIntakeApi(eq(Intake.EVENT_PLATFORM), eq(true), eq(false))) + .thenReturn(directApi); + when(proxyApi.post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))) + .thenThrow(new SocketTimeoutException("ambiguous timeout")) + .thenThrow(new ConnectException("definitive refusal")); + final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); + final List exposures = buildExposures(2); + + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, featureFlagBackendApiFactory, config)) { + writer.init(); + writer.accept(exposures.get(0)); + + poll.eventually( + () -> verify(proxyApi).post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); + MILLISECONDS.sleep(300); + verify(proxyApi, times(1)).post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); + verify(directApi, never()).post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); + + writer.accept(exposures.get(1)); + poll.eventually( + () -> verify(directApi).post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); + + final ArgumentCaptor directBody = ArgumentCaptor.forClass(RequestBody.class); + verify(directApi).post(eq("exposures"), directBody.capture(), any(), any(), eq(false)); + final Buffer buffer = new Buffer(); + directBody.getValue().writeTo(buffer); + final ExposuresRequest directRequest = + new Moshi.Builder().build().adapter(ExposuresRequest.class).fromJson(buffer.readUtf8()); + assertNotNull(directRequest); + assertExposures(directRequest.exposures, singletonList(exposures.get(1))); + } } - } - @Test - void testWriterStopsReceivingExposuresIfEvpProxyIsNotAvailable() throws Exception { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(false); + @Test + void testWriterStopsReceivingExposuresIfEvpProxyIsNotAvailable() throws Exception { + SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(false); - try (ExposureWriterImpl writer = - new ExposureWriterImpl(sharedCommunicationObjects, Config.get())) { - writer.init(); - poll.eventually(() -> assertFalse(writer.isSerializerThreadAlive())); + try (ExposureWriterImpl writer = new ExposureWriterImpl(sharedCommunicationObjects, Config.get())) { + writer.init(); + poll.eventually(() -> assertFalse(writer.isSerializerThreadAlive())); - FeatureFlaggingGateway.dispatch(buildExposure()); + FeatureFlaggingGateway.dispatch(buildExposure()); - assertEquals(0, writer.queueSize()); + assertEquals(0, writer.queueSize()); + } } - } - - private static Config mockConfig(String serviceName) { - return mockConfig(serviceName, "test", "0.0.0"); - } - - private static Config mockConfig(String serviceName, String env, String version) { - Config config = mock(Config.class); - when(config.getIdGenerationStrategy()).thenReturn(IdGenerationStrategy.fromName("RANDOM")); - when(config.getServiceName()).thenReturn(serviceName); - when(config.getEnv()).thenReturn(env); - when(config.getVersion()).thenReturn(version); - return config; - } - - private SharedCommunicationObjects sharedCommunicationObjects(boolean evpProxyAvailable) { - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxy()).thenReturn(evpProxyAvailable); - if (evpProxyAvailable) { - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); + + private static Config mockConfig(String serviceName) { + return mockConfig(serviceName, "test", "0.0.0"); } - SharedCommunicationObjects sharedCommunicationObjects = new SharedCommunicationObjects(); - sharedCommunicationObjects.setFeaturesDiscovery(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get(server.getAddress()); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - return sharedCommunicationObjects; - } - - private static void assertContext( - Map context, String service, String env, String version) { - assertEquals(service == null ? "unknown" : service, context.get("service")); - assertOptionalContextValue(context, "env", env); - assertOptionalContextValue(context, "version", version); - } - - private static void assertOptionalContextValue( - Map context, String key, String value) { - if (value == null) { - assertFalse(context.containsKey(key)); - } else { - assertEquals(value, context.get(key)); + private static Config mockConfig(String serviceName, String env, String version) { + Config config = mock(Config.class); + when(config.getIdGenerationStrategy()).thenReturn(IdGenerationStrategy.fromName("RANDOM")); + when(config.getServiceName()).thenReturn(serviceName); + when(config.getEnv()).thenReturn(env); + when(config.getVersion()).thenReturn(version); + return config; } - } - private ExposuresRequest findRequest(String serviceName) { - for (ExposuresRequest request : requests) { - if (serviceName.equals(request.context.get("service"))) { - return request; - } + private SharedCommunicationObjects sharedCommunicationObjects(boolean evpProxyAvailable) { + DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); + when(discovery.supportsEvpProxy()).thenReturn(evpProxyAvailable); + if (evpProxyAvailable) { + when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); + } + + SharedCommunicationObjects sharedCommunicationObjects = new SharedCommunicationObjects(); + sharedCommunicationObjects.setFeaturesDiscovery(discovery); + sharedCommunicationObjects.agentUrl = HttpUrl.get(server.getAddress()); + sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); + return sharedCommunicationObjects; } - return null; - } - private List allExposures() { - List exposures = new ArrayList<>(); - for (ExposuresRequest request : requests) { - exposures.addAll(request.exposures); + private static void assertContext(Map context, String service, String env, String version) { + assertEquals(service == null ? "unknown" : service, context.get("service")); + assertOptionalContextValue(context, "env", env); + assertOptionalContextValue(context, "version", version); } - return exposures; - } - - private static void assertExposures( - List receivedExposures, List expectedExposures) { - assertEquals(expectedExposures.size(), receivedExposures.size()); - TreeSet received = new TreeSet<>(ExposureWriterTests::compare); - received.addAll(receivedExposures); - assertTrue(received.containsAll(expectedExposures)); - } - - private static int compare(ExposureEvent first, ExposureEvent second) { - if (first == second) { - return 0; + + private static void assertOptionalContextValue(Map context, String key, String value) { + if (value == null) { + assertFalse(context.containsKey(key)); + } else { + assertEquals(value, context.get(key)); + } } - if (first == null) { - return -1; + + private ExposuresRequest findRequest(String serviceName) { + for (ExposuresRequest request : requests) { + if (serviceName.equals(request.context.get("service"))) { + return request; + } + } + return null; } - if (second == null) { - return 1; + + private List allExposures() { + List exposures = new ArrayList<>(); + for (ExposuresRequest request : requests) { + exposures.addAll(request.exposures); + } + return exposures; } - int result = Long.compare(first.timestamp, second.timestamp); - if (result != 0) { - return result; + private static void assertExposures(List receivedExposures, List expectedExposures) { + assertEquals(expectedExposures.size(), receivedExposures.size()); + TreeSet received = new TreeSet<>(ExposureWriterTests::compare); + received.addAll(receivedExposures); + assertTrue(received.containsAll(expectedExposures)); } - result = compareNullableString(first.flag == null ? null : first.flag.key, second.flag); - if (result != 0) { - return result; + private static int compare(ExposureEvent first, ExposureEvent second) { + if (first == second) { + return 0; + } + if (first == null) { + return -1; + } + if (second == null) { + return 1; + } + + int result = Long.compare(first.timestamp, second.timestamp); + if (result != 0) { + return result; + } + + result = compareNullableString(first.flag == null ? null : first.flag.key, second.flag); + if (result != 0) { + return result; + } + + result = compareNullableString(first.variant == null ? null : first.variant.key, second.variant); + if (result != 0) { + return result; + } + + result = compareNullableString(first.allocation == null ? null : first.allocation.key, second.allocation); + if (result != 0) { + return result; + } + + result = compareNullableString(first.subject == null ? null : first.subject.id, second.subject); + if (result != 0) { + return result; + } + + Map.Entry firstEntry = firstEntry(first.subject); + Map.Entry secondEntry = firstEntry(second.subject); + result = compareNullableString( + firstEntry == null ? null : firstEntry.getKey(), secondEntry == null ? null : secondEntry.getKey()); + if (result != 0) { + return result; + } + return compareNullableString( + firstEntry == null ? null : String.valueOf(firstEntry.getValue()), + secondEntry == null ? null : String.valueOf(secondEntry.getValue())); } - result = - compareNullableString(first.variant == null ? null : first.variant.key, second.variant); - if (result != 0) { - return result; + private static int compareNullableString(String first, Flag second) { + return compareNullableString(first, second == null ? null : second.key); } - result = - compareNullableString( - first.allocation == null ? null : first.allocation.key, second.allocation); - if (result != 0) { - return result; + private static int compareNullableString(String first, Variant second) { + return compareNullableString(first, second == null ? null : second.key); } - result = compareNullableString(first.subject == null ? null : first.subject.id, second.subject); - if (result != 0) { - return result; + private static int compareNullableString(String first, Allocation second) { + return compareNullableString(first, second == null ? null : second.key); } - Map.Entry firstEntry = firstEntry(first.subject); - Map.Entry secondEntry = firstEntry(second.subject); - result = - compareNullableString( - firstEntry == null ? null : firstEntry.getKey(), - secondEntry == null ? null : secondEntry.getKey()); - if (result != 0) { - return result; + private static int compareNullableString(String first, Subject second) { + return compareNullableString(first, second == null ? null : second.id); } - return compareNullableString( - firstEntry == null ? null : String.valueOf(firstEntry.getValue()), - secondEntry == null ? null : String.valueOf(secondEntry.getValue())); - } - - private static int compareNullableString(String first, Flag second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Variant second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Allocation second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Subject second) { - return compareNullableString(first, second == null ? null : second.id); - } - - private static int compareNullableString(String first, String second) { - String firstValue = first == null ? "" : first; - String secondValue = second == null ? "" : second; - return firstValue.compareTo(secondValue); - } - - private static Map.Entry firstEntry(Subject subject) { - if (subject == null || subject.attributes == null) { - return null; + + private static int compareNullableString(String first, String second) { + String firstValue = first == null ? "" : first; + String secondValue = second == null ? "" : second; + return firstValue.compareTo(secondValue); } - Iterator> iterator = subject.attributes.entrySet().iterator(); - return iterator.hasNext() ? iterator.next() : null; - } - - private static List buildExposures(int count) { - List exposures = new ArrayList<>(); - for (int index = 0; index < count; index++) { - exposures.add(buildExposure()); + + private static Map.Entry firstEntry(Subject subject) { + if (subject == null || subject.attributes == null) { + return null; + } + Iterator> iterator = + subject.attributes.entrySet().iterator(); + return iterator.hasNext() ? iterator.next() : null; + } + + private static List buildExposures(int count) { + List exposures = new ArrayList<>(); + for (int index = 0; index < count; index++) { + exposures.add(buildExposure()); + } + return exposures; + } + + private static ExposureEvent buildExposure() { + String id = UUID.randomUUID().toString(); + return buildExposure(id, singletonMap("key_" + id, (Object) ("value_" + id))); + } + + private static ExposureEvent buildExposure(Map attributes) { + return buildExposure(UUID.randomUUID().toString(), attributes); + } + + private static ExposureEvent buildExposure(String id, Map attributes) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation("Allocation_" + id), + new Flag("Flag_" + id), + new Variant("Variant_" + id), + new Subject("Subject_" + id, attributes)); } - return exposures; - } - - private static ExposureEvent buildExposure() { - String id = UUID.randomUUID().toString(); - return buildExposure(id, singletonMap("key_" + id, (Object) ("value_" + id))); - } - - private static ExposureEvent buildExposure(Map attributes) { - return buildExposure(UUID.randomUUID().toString(), attributes); - } - - private static ExposureEvent buildExposure(String id, Map attributes) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation("Allocation_" + id), - new Flag("Flag_" + id), - new Variant("Variant_" + id), - new Subject("Subject_" + id, attributes)); - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java index b89cf3ce2b1..ea271bfa890 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java @@ -21,138 +21,133 @@ class FeatureFlagBackendApiFactoryTest { - @Test - void remoteConfigUsesOnlyLocalEvpProxy() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void remoteConfigDisablesDeliveryWhenLocalEvpProxyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); - - assertNull(selected); - verify(backendApiFactory).createEvpProxyApi(Intake.EVENT_PLATFORM, true); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); - } - - @Test - void agentlessPrefersLocalEvpProxyWithDirectFallback() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(mock(BackendApi.class)); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenReturn(mock(BackendApi.class)); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - verify(backendApiFactory) - .createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenReturn(directApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(directApi, selected); - } - - @Test - void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessDisablesDeliveryWhenNoRouteIsAvailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertNull(selected); - } - - @Test - void agentlessDisablesDeliveryWhenApiKeyIsEmpty() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, ""); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); - - assertNull(selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); - } - - @Test - void agentlessDoesNotValidateDirectUrlWhileLocalRouteIsAvailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(proxyApi); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenThrow(new IllegalArgumentException("invalid URL")); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessDisablesDeliveryWhenDirectUrlIsInvalidAndLocalRouteIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenThrow(new IllegalArgumentException("invalid URL")); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertNull(selected); - } - - private static Config config(final String source, final String apiKey) { - final Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); - when(config.getApiKey()).thenReturn(apiKey); - return config; - } + @Test + void remoteConfigUsesOnlyLocalEvpProxy() { + final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi proxyApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); + + assertSame(proxyApi, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); + } + + @Test + void remoteConfigDisablesDeliveryWhenLocalEvpProxyIsUnavailable() { + final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + + final BackendApi selected = new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); + + assertNull(selected); + verify(backendApiFactory).createEvpProxyApi(Intake.EVENT_PLATFORM, true); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); + } + + @Test + void agentlessPrefersLocalEvpProxyWithDirectFallback() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) + .thenReturn(mock(BackendApi.class)); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) + .thenReturn(mock(BackendApi.class)); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); + + assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); + verify(backendApiFactory).createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); + } + + @Test + void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi directApi = mock(BackendApi.class); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) + .thenReturn(directApi); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); + + assertSame(directApi, selected); + } + + @Test + void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi proxyApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); + + assertSame(proxyApi, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); + } + + @Test + void agentlessDisablesDeliveryWhenNoRouteIsAvailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); + + assertNull(selected); + } + + @Test + void agentlessDisablesDeliveryWhenApiKeyIsEmpty() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, ""); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + + final BackendApi selected = new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); + + assertNull(selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); + } + + @Test + void agentlessDoesNotValidateDirectUrlWhileLocalRouteIsAvailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi proxyApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) + .thenReturn(proxyApi); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) + .thenThrow(new IllegalArgumentException("invalid URL")); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); + + assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); + } + + @Test + void agentlessDisablesDeliveryWhenDirectUrlIsInvalidAndLocalRouteIsUnavailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) + .thenThrow(new IllegalArgumentException("invalid URL")); + + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); + + assertNull(selected); + } + + private static Config config(final String source, final String apiKey) { + final Config config = mock(Config.class); + when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); + when(config.getApiKey()).thenReturn(apiKey); + return config; + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java index 711e1b90e29..e5c5533cabe 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java @@ -28,101 +28,97 @@ class FeatureFlagEvpPublisherTest { - @Test - void defaultPublisherRequestsResponseCompression() { - final BackendApi backendApi = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(Intake.EVENT_PLATFORM, true)).thenReturn(backendApi); - - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class); - - publisher.start(); - - verify(factory).createBackendApi(Intake.EVENT_PLATFORM, true); - verifyNoMoreInteractions(factory); - } - - @Test - void responseCompressionCanBeDisabled() throws Exception { - final BackendApi backendApi = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(Intake.EVENT_PLATFORM, false)).thenReturn(backendApi); - - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class, false); - - publisher.post("flagevaluation", new TestRequest("value")); - - verify(factory).createBackendApi(Intake.EVENT_PLATFORM, false); - verify(backendApi) - .post(eq("flagevaluation"), any(RequestBody.class), any(), isNull(), eq(false)); - } - - @Test - void postThrowsWhenEvpBackendApiCannotBeCreated() { - final BackendApiFactory factory = mock(BackendApiFactory.class); - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class); - - assertFalse(publisher.start()); - assertThrows( - IllegalStateException.class, - () -> publisher.post("flagevaluation", FeatureFlagEvpPublisher.utf8Bytes("{}"))); - } - - @Test - void serializesSerialIdUnderTheIntakeWireKey() { - assertTrue(exposureJson(340132).contains("\"serial_id\":340132")); - } - - @Test - void serializesSerialIdZeroRatherThanOmittingIt() { - assertTrue(exposureJson(0).contains("\"serial_id\":0")); - } - - @Test - void omitsSerialIdKeyWhenAbsent() { - assertFalse(exposureJson(null).contains("serial_id")); - } - - @Test - void omitsSerialIdKeyForAnEventBuiltWithoutOne() { - final ExposureEvent event = - new ExposureEvent( - 1234L, - new Allocation("allocation"), - new Flag("flag"), - new Variant("variant"), - new Subject("subject", emptyMap())); - - assertFalse(exposureJsonOf(event).contains("serial_id")); - } - - private static String exposureJson(final Integer serialId) { - return exposureJsonOf( - new ExposureEvent( - 1234L, - new Allocation("allocation"), - new Flag("flag"), - new Variant("variant"), - new Subject("subject", emptyMap()), - serialId)); - } - - private static String exposureJsonOf(final ExposureEvent event) { - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(mock(BackendApiFactory.class), ExposuresRequest.class); - return new String( - publisher.serialize(new ExposuresRequest(emptyMap(), singletonList(event))), - StandardCharsets.UTF_8); - } - - static class TestRequest { - public final String value; - - TestRequest(final String value) { - this.value = value; + @Test + void defaultPublisherRequestsResponseCompression() { + final BackendApi backendApi = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(Intake.EVENT_PLATFORM, true)).thenReturn(backendApi); + + final FeatureFlagEvpPublisher publisher = + new FeatureFlagEvpPublisher<>(factory, TestRequest.class); + + publisher.start(); + + verify(factory).createBackendApi(Intake.EVENT_PLATFORM, true); + verifyNoMoreInteractions(factory); + } + + @Test + void responseCompressionCanBeDisabled() throws Exception { + final BackendApi backendApi = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(Intake.EVENT_PLATFORM, false)).thenReturn(backendApi); + + final FeatureFlagEvpPublisher publisher = + new FeatureFlagEvpPublisher<>(factory, TestRequest.class, false); + + publisher.post("flagevaluation", new TestRequest("value")); + + verify(factory).createBackendApi(Intake.EVENT_PLATFORM, false); + verify(backendApi).post(eq("flagevaluation"), any(RequestBody.class), any(), isNull(), eq(false)); + } + + @Test + void postThrowsWhenEvpBackendApiCannotBeCreated() { + final BackendApiFactory factory = mock(BackendApiFactory.class); + final FeatureFlagEvpPublisher publisher = + new FeatureFlagEvpPublisher<>(factory, TestRequest.class); + + assertFalse(publisher.start()); + assertThrows( + IllegalStateException.class, + () -> publisher.post("flagevaluation", FeatureFlagEvpPublisher.utf8Bytes("{}"))); + } + + @Test + void serializesSerialIdUnderTheIntakeWireKey() { + assertTrue(exposureJson(340132).contains("\"serial_id\":340132")); + } + + @Test + void serializesSerialIdZeroRatherThanOmittingIt() { + assertTrue(exposureJson(0).contains("\"serial_id\":0")); + } + + @Test + void omitsSerialIdKeyWhenAbsent() { + assertFalse(exposureJson(null).contains("serial_id")); + } + + @Test + void omitsSerialIdKeyForAnEventBuiltWithoutOne() { + final ExposureEvent event = new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", emptyMap())); + + assertFalse(exposureJsonOf(event).contains("serial_id")); + } + + private static String exposureJson(final Integer serialId) { + return exposureJsonOf(new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", emptyMap()), + serialId)); + } + + private static String exposureJsonOf(final ExposureEvent event) { + final FeatureFlagEvpPublisher publisher = + new FeatureFlagEvpPublisher<>(mock(BackendApiFactory.class), ExposuresRequest.class); + return new String( + publisher.serialize(new ExposuresRequest(emptyMap(), singletonList(event))), StandardCharsets.UTF_8); + } + + static class TestRequest { + public final String value; + + TestRequest(final String value) { + this.value = value; + } } - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java index 06faeadefea..7e705183bb8 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java @@ -14,412 +14,395 @@ class FlagEvaluationAggregatorTest { - @Test - void identicalEventsAggregateIntoOneBucketWithCount2() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 2000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(1, state.fullTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertEquals(1000L, bucket.firstEvalMs); - assertEquals(2000L, bucket.lastEvalMs); - assertTrue(bucket.firstEvalMs <= bucket.lastEvalMs); - } - - @Test - void differentValueTypesProduceDifferentBuckets() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map attrsInt = new HashMap<>(); - attrsInt.put("score", 1); - final Map attrsStr = new HashMap<>(); - attrsStr.put("score", "1"); - - aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsInt)); - aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsStr)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(2, state.fullTier.size()); - } - - @Test - void nulCharactersInKeyFieldsDoNotCollide() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("a\0b", "c", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("a", "b\0c", "alloc1", "user-1", 1000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(2, state.fullTier.size()); - } - - @Test - void globalCapOverflowRoutesToDegradedTier() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.simulateFullTierAtCap(); - aggregator.aggregate(simpleEvent("extra-flag", "on")); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertTrue(state.degradedTier.size() > 0); - assertEquals(0, state.droppedDegradedOverflow); - } - - @Test - void degradedCapOverflowIncrementsDroppedCounter() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.simulateFullTierAtCap(); - aggregator.simulateDegradedTierAtCap(); - aggregator.aggregate(simpleEvent("drop-flag", "on")); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertTrue(state.droppedDegradedOverflow > 0); - } - - @Test - void perFlagCapOverflowRoutesToDegradedTierAndMergesSameDegradedKey() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); - - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, emptyMap())); - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(0, state.fullTier.size()); - assertEquals(1, state.degradedTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = - state.degradedTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertEquals(1000L, bucket.firstEvalMs); - assertEquals(2000L, bucket.lastEvalMs); - } - - @Test - void mixedConsentDegradedEvaluationsMergeIntoOneBucket() { - // Mirror of mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets, but for the degraded - // tier: the wire serializer for degraded rows drops the targeting key and context regardless - // of consent, so two events differing only in consent emit byte-identical JSON. They must - // share a bucket, otherwise DEGRADED_CAP is effectively halved for zero wire fidelity gain. - // The AND-fold on EvalBucket.observeFullEvaluationData still runs but the value has no - // downstream effect for degraded rows. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); - - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, false, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(0, state.fullTier.size()); - assertEquals(1, state.degradedTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = - state.degradedTier.values().iterator().next(); - assertEquals(2, bucket.count); - // AND-fold collapses to consent-off; benign for degraded rows but a documented invariant. - assertFalse(bucket.observeFullEvaluationData); - } - - @Test - void absentVariantSetsRuntimeDefaultUsed() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - - aggregator.aggregate(event("flag-c", null, "alloc1", "user-1", 1000L, emptyMap())); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - assertEquals(1, state.fullTier.size()); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertTrue(bucket.runtimeDefaultUsed); - } - - @Test - void aggregatorStoresPrunedAttrsVerbatim() { - // Hot-path hook (DDEvaluator#copyPrunedContext) now delivers an already-pruned map. - // The aggregator no longer re-prunes; it stores what it is given. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map preprunedAttrs = new HashMap<>(); - for (int i = 0; i < 100; i++) { - preprunedAttrs.put("key" + i, "v" + i); + @Test + void identicalEventsAggregateIntoOneBucketWithCount2() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 2000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(1, state.fullTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.fullTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertEquals(1000L, bucket.firstEvalMs); + assertEquals(2000L, bucket.lastEvalMs); + assertTrue(bucket.firstEvalMs <= bucket.lastEvalMs); } - aggregator.aggregate(event("flag-d", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertEquals(100, bucket.prunedContextFieldCount()); - assertEquals(100, bucket.prunedAttrs.size()); - } - - @Test - void emptyContextInputsProduceEmptyCanonicalKey() { - assertEquals("", FlagEvaluationAggregator.canonicalContextKey(null)); - assertEquals("", FlagEvaluationAggregator.canonicalContextKey(emptyMap())); - } - - @Test - void canonicalContextKeyEncodesSupportedValueTypes() { - final Map attrs = new HashMap<>(); - attrs.put("bool", true); - attrs.put("double", 1.5d); - attrs.put("float", 1.25f); - attrs.put("int", 1); - attrs.put("long", 2L); - attrs.put("null", null); - attrs.put("object", new StringBuilder("other")); - attrs.put("string", "value"); - - final String key = FlagEvaluationAggregator.canonicalContextKey(attrs); - - assertEquals(key, FlagEvaluationAggregator.canonicalContextKey(new HashMap<>(attrs))); - assertTrue(key.contains("bool")); - assertTrue(key.contains("string")); - assertTrue(key.contains("other")); - } - - @Test - void aggregatorStoresPrePrunedAttrsWithoutRePruning() { - // Value-length pruning moved to DDEvaluator#copyPrunedContext (hot path). The aggregator - // stores what it is given, so any long value present in the input remains present. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map preprunedAttrs = new HashMap<>(); - preprunedAttrs.put("short-val", "ok"); - - aggregator.aggregate(event("flag-e", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); - - final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); - final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); - assertTrue(bucket.prunedAttrs.containsKey("short-val")); - } - - @Test - void capSizingUsesNamedScaleConstants() { - assertEquals(125_000, FlagEvaluationAggregator.FULL_BUCKET_SIZING_BASIS); - assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_BUCKET_SIZING_BASIS); - assertEquals(25_000, FlagEvaluationAggregator.DEGRADED_BUCKET_SIZING_BASIS); - assertEquals(131_072, FlagEvaluationAggregator.GLOBAL_CAP); - assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_CAP); - assertEquals(32_768, FlagEvaluationAggregator.DEGRADED_CAP); - } - - @Test - void flagEvalEventDoesNotCarryReason() { - final boolean hasReasonField = - Arrays.stream( - datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent.class - .getDeclaredFields()) - .anyMatch(field -> field.getName().equals("reason")); - - assertFalse(hasReasonField); - } - - @Test - void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false); - - assertEquals(0, bucket.prunedContextFieldCount()); - - bucket.merge(900L, true); - bucket.merge(1100L, false); - bucket.merge(1000L, false); - - assertEquals(4, bucket.count); - assertEquals(900L, bucket.firstEvalMs); - assertEquals(1100L, bucket.lastEvalMs); - assertTrue(bucket.runtimeDefaultUsed); - } - - @Test - void fullKeyEqualityUsesEveryDimension() { - final FlagEvaluationAggregator.FullKey base = - fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); - final FlagEvaluationAggregator.FullKey same = - fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); - - assertEquals(base, base); - assertEquals(base, same); - assertEquals(base.hashCode(), same.hashCode()); - assertNotEquals(base, null); - assertNotEquals(base, "not-a-key"); - assertNotEquals(base, fullKey("other", "on", "alloc", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "off", "alloc", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "other", false, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", true, "error", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "other", "user", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "other", "ctx")); - assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "user", "other")); - } - - @Test - void degradedKeyEqualityUsesEveryDimension() { - final FlagEvaluationAggregator.DegradedKey base = - degradedKey("flag", "on", "alloc", false, "error"); - final FlagEvaluationAggregator.DegradedKey same = - degradedKey("flag", "on", "alloc", false, "error"); - - assertEquals(base, base); - assertEquals(base, same); - assertEquals(base.hashCode(), same.hashCode()); - assertNotEquals(base, null); - assertNotEquals(base, "not-a-key"); - assertNotEquals(base, degradedKey("other", "on", "alloc", false, "error")); - assertNotEquals(base, degradedKey("flag", "off", "alloc", false, "error")); - assertNotEquals(base, degradedKey("flag", "on", "other", false, "error")); - assertNotEquals(base, degradedKey("flag", "on", "alloc", true, "error")); - assertNotEquals(base, degradedKey("flag", "on", "alloc", false, "other")); - } - - @Test - void mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets() { - // Consent is part of FullKey: two evaluations that differ only in consent produce different - // wire rows (raw vs hashed targeting key, context vs no context) and belong in different - // buckets. Merging them would silently downgrade the consent-on row to the protected shape. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, false, emptyMap())); - - assertEquals(2, aggregator.fullTierSize()); - int onCount = 0; - int offCount = 0; - for (final FlagEvaluationAggregator.EvalBucket bucket : - aggregator.snapshot().fullTier.values()) { - if (bucket.observeFullEvaluationData) { - onCount++; - } else { - offCount++; - } + @Test + void differentValueTypesProduceDifferentBuckets() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map attrsInt = new HashMap<>(); + attrsInt.put("score", 1); + final Map attrsStr = new HashMap<>(); + attrsStr.put("score", "1"); + + aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsInt)); + aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsStr)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(2, state.fullTier.size()); + } + + @Test + void nulCharactersInKeyFieldsDoNotCollide() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("a\0b", "c", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("a", "b\0c", "alloc1", "user-1", 1000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(2, state.fullTier.size()); + } + + @Test + void globalCapOverflowRoutesToDegradedTier() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.simulateFullTierAtCap(); + aggregator.aggregate(simpleEvent("extra-flag", "on")); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertTrue(state.degradedTier.size() > 0); + assertEquals(0, state.droppedDegradedOverflow); + } + + @Test + void degradedCapOverflowIncrementsDroppedCounter() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.simulateFullTierAtCap(); + aggregator.simulateDegradedTierAtCap(); + aggregator.aggregate(simpleEvent("drop-flag", "on")); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertTrue(state.droppedDegradedOverflow > 0); + } + + @Test + void perFlagCapOverflowRoutesToDegradedTierAndMergesSameDegradedKey() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); + + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(0, state.fullTier.size()); + assertEquals(1, state.degradedTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.degradedTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertEquals(1000L, bucket.firstEvalMs); + assertEquals(2000L, bucket.lastEvalMs); + } + + @Test + void mixedConsentDegradedEvaluationsMergeIntoOneBucket() { + // Mirror of mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets, but for the degraded + // tier: the wire serializer for degraded rows drops the targeting key and context regardless + // of consent, so two events differing only in consent emit byte-identical JSON. They must + // share a bucket, otherwise DEGRADED_CAP is effectively halved for zero wire fidelity gain. + // The AND-fold on EvalBucket.observeFullEvaluationData still runs but the value has no + // downstream effect for degraded rows. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); + + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, false, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(0, state.fullTier.size()); + assertEquals(1, state.degradedTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.degradedTier.values().iterator().next(); + assertEquals(2, bucket.count); + // AND-fold collapses to consent-off; benign for degraded rows but a documented invariant. + assertFalse(bucket.observeFullEvaluationData); + } + + @Test + void absentVariantSetsRuntimeDefaultUsed() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("flag-c", null, "alloc1", "user-1", 1000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(1, state.fullTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.fullTier.values().iterator().next(); + assertTrue(bucket.runtimeDefaultUsed); + } + + @Test + void aggregatorStoresPrunedAttrsVerbatim() { + // Hot-path hook (DDEvaluator#copyPrunedContext) now delivers an already-pruned map. + // The aggregator no longer re-prunes; it stores what it is given. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map preprunedAttrs = new HashMap<>(); + for (int i = 0; i < 100; i++) { + preprunedAttrs.put("key" + i, "v" + i); + } + + aggregator.aggregate(event("flag-d", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + final FlagEvaluationAggregator.EvalBucket bucket = + state.fullTier.values().iterator().next(); + assertEquals(100, bucket.prunedContextFieldCount()); + assertEquals(100, bucket.prunedAttrs.size()); + } + + @Test + void emptyContextInputsProduceEmptyCanonicalKey() { + assertEquals("", FlagEvaluationAggregator.canonicalContextKey(null)); + assertEquals("", FlagEvaluationAggregator.canonicalContextKey(emptyMap())); + } + + @Test + void canonicalContextKeyEncodesSupportedValueTypes() { + final Map attrs = new HashMap<>(); + attrs.put("bool", true); + attrs.put("double", 1.5d); + attrs.put("float", 1.25f); + attrs.put("int", 1); + attrs.put("long", 2L); + attrs.put("null", null); + attrs.put("object", new StringBuilder("other")); + attrs.put("string", "value"); + + final String key = FlagEvaluationAggregator.canonicalContextKey(attrs); + + assertEquals(key, FlagEvaluationAggregator.canonicalContextKey(new HashMap<>(attrs))); + assertTrue(key.contains("bool")); + assertTrue(key.contains("string")); + assertTrue(key.contains("other")); + } + + @Test + void aggregatorStoresPrePrunedAttrsWithoutRePruning() { + // Value-length pruning moved to DDEvaluator#copyPrunedContext (hot path). The aggregator + // stores what it is given, so any long value present in the input remains present. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map preprunedAttrs = new HashMap<>(); + preprunedAttrs.put("short-val", "ok"); + + aggregator.aggregate(event("flag-e", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + final FlagEvaluationAggregator.EvalBucket bucket = + state.fullTier.values().iterator().next(); + assertTrue(bucket.prunedAttrs.containsKey("short-val")); + } + + @Test + void capSizingUsesNamedScaleConstants() { + assertEquals(125_000, FlagEvaluationAggregator.FULL_BUCKET_SIZING_BASIS); + assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_BUCKET_SIZING_BASIS); + assertEquals(25_000, FlagEvaluationAggregator.DEGRADED_BUCKET_SIZING_BASIS); + assertEquals(131_072, FlagEvaluationAggregator.GLOBAL_CAP); + assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_CAP); + assertEquals(32_768, FlagEvaluationAggregator.DEGRADED_CAP); + } + + @Test + void flagEvalEventDoesNotCarryReason() { + final boolean hasReasonField = Arrays.stream( + datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent.class.getDeclaredFields()) + .anyMatch(field -> field.getName().equals("reason")); + + assertFalse(hasReasonField); + } + + @Test + void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() { + final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( + "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false); + + assertEquals(0, bucket.prunedContextFieldCount()); + + bucket.merge(900L, true); + bucket.merge(1100L, false); + bucket.merge(1000L, false); + + assertEquals(4, bucket.count); + assertEquals(900L, bucket.firstEvalMs); + assertEquals(1100L, bucket.lastEvalMs); + assertTrue(bucket.runtimeDefaultUsed); + } + + @Test + void fullKeyEqualityUsesEveryDimension() { + final FlagEvaluationAggregator.FullKey base = fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); + final FlagEvaluationAggregator.FullKey same = fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); + + assertEquals(base, base); + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, null); + assertNotEquals(base, "not-a-key"); + assertNotEquals(base, fullKey("other", "on", "alloc", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "off", "alloc", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "other", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", true, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "other", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "other", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "user", "other")); + } + + @Test + void degradedKeyEqualityUsesEveryDimension() { + final FlagEvaluationAggregator.DegradedKey base = degradedKey("flag", "on", "alloc", false, "error"); + final FlagEvaluationAggregator.DegradedKey same = degradedKey("flag", "on", "alloc", false, "error"); + + assertEquals(base, base); + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, null); + assertNotEquals(base, "not-a-key"); + assertNotEquals(base, degradedKey("other", "on", "alloc", false, "error")); + assertNotEquals(base, degradedKey("flag", "off", "alloc", false, "error")); + assertNotEquals(base, degradedKey("flag", "on", "other", false, "error")); + assertNotEquals(base, degradedKey("flag", "on", "alloc", true, "error")); + assertNotEquals(base, degradedKey("flag", "on", "alloc", false, "other")); + } + + @Test + void mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets() { + // Consent is part of FullKey: two evaluations that differ only in consent produce different + // wire rows (raw vs hashed targeting key, context vs no context) and belong in different + // buckets. Merging them would silently downgrade the consent-on row to the protected shape. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, false, emptyMap())); + + assertEquals(2, aggregator.fullTierSize()); + int onCount = 0; + int offCount = 0; + for (final FlagEvaluationAggregator.EvalBucket bucket : + aggregator.snapshot().fullTier.values()) { + if (bucket.observeFullEvaluationData) { + onCount++; + } else { + offCount++; + } + } + assertEquals(1, onCount); + assertEquals(1, offCount); + } + + @Test + void sameConsentEvaluationsForSameSubjectMergeIntoOneBucket() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, true, emptyMap())); + + final FlagEvaluationAggregator.EvalBucket bucket = + aggregator.snapshot().fullTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertTrue(bucket.observeFullEvaluationData); + } + + @Test + void protectedPathCollapsesDifferingContextIntoOneBucket() { + // Same subject, different request-id contexts, consent off: the context is dropped on emit so + // it must not fragment full-tier buckets or the per-flag cap blows out under real traffic. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map ctx1 = new HashMap<>(); + ctx1.put("request_id", "req-1"); + final Map ctx2 = new HashMap<>(); + ctx2.put("request_id", "req-2"); + final Map ctx3 = new HashMap<>(); + ctx3.put("request_id", "req-3"); + + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, ctx1)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, false, ctx2)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 3000L, false, ctx3)); + + assertEquals(1, aggregator.fullTierSize()); + final FlagEvaluationAggregator.EvalBucket bucket = + aggregator.snapshot().fullTier.values().iterator().next(); + assertEquals(3, bucket.count); + assertFalse(bucket.observeFullEvaluationData); + assertEquals(0, bucket.prunedContextFieldCount()); + } + + @Test + void protectedPathSeparatesDifferentSubjects() { + // Different targeting keys must still fall into distinct buckets on the protected path — the + // (hashed) targeting key stays part of the aggregation identity. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, emptyMap())); + aggregator.aggregate(event("checkout", "on", "alloc1", "bob", 2000L, false, emptyMap())); + + assertEquals(2, aggregator.fullTierSize()); + } + + @Test + void fullPathStillSplitsBucketsOnDifferingContext() { + // Consent-on preserves the previous behaviour: distinct contexts remain distinct buckets so + // each raw context is emitted verbatim. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map ctx1 = new HashMap<>(); + ctx1.put("plan", "pro"); + ctx1.put("request_id", "req-1"); + final Map ctx2 = new HashMap<>(); + ctx2.put("plan", "pro"); + ctx2.put("request_id", "req-2"); + + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, true, ctx1)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, true, ctx2)); + + assertEquals(2, aggregator.fullTierSize()); + } + + private static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + return new FlagEvalEvent( + flagKey, variant, allocationKey, targetingKey, null, evalTimeMs, observeFullEvaluationData, attrs); + } + + private static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); + } + + private static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { + return event(flagKey, variant, "alloc1", "user-1", 1000L, emptyMap()); + } + + private static FlagEvaluationAggregator.FullKey fullKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final String targetingKey, + final String contextKey) { + return new FlagEvaluationAggregator.FullKey( + flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage, targetingKey, contextKey, false); + } + + private static FlagEvaluationAggregator.DegradedKey degradedKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage) { + return new FlagEvaluationAggregator.DegradedKey( + flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage); } - assertEquals(1, onCount); - assertEquals(1, offCount); - } - - @Test - void sameConsentEvaluationsForSameSubjectMergeIntoOneBucket() { - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); - aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, true, emptyMap())); - - final FlagEvaluationAggregator.EvalBucket bucket = - aggregator.snapshot().fullTier.values().iterator().next(); - assertEquals(2, bucket.count); - assertTrue(bucket.observeFullEvaluationData); - } - - @Test - void protectedPathCollapsesDifferingContextIntoOneBucket() { - // Same subject, different request-id contexts, consent off: the context is dropped on emit so - // it must not fragment full-tier buckets or the per-flag cap blows out under real traffic. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map ctx1 = new HashMap<>(); - ctx1.put("request_id", "req-1"); - final Map ctx2 = new HashMap<>(); - ctx2.put("request_id", "req-2"); - final Map ctx3 = new HashMap<>(); - ctx3.put("request_id", "req-3"); - - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, ctx1)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, false, ctx2)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 3000L, false, ctx3)); - - assertEquals(1, aggregator.fullTierSize()); - final FlagEvaluationAggregator.EvalBucket bucket = - aggregator.snapshot().fullTier.values().iterator().next(); - assertEquals(3, bucket.count); - assertFalse(bucket.observeFullEvaluationData); - assertEquals(0, bucket.prunedContextFieldCount()); - } - - @Test - void protectedPathSeparatesDifferentSubjects() { - // Different targeting keys must still fall into distinct buckets on the protected path — the - // (hashed) targeting key stays part of the aggregation identity. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, emptyMap())); - aggregator.aggregate(event("checkout", "on", "alloc1", "bob", 2000L, false, emptyMap())); - - assertEquals(2, aggregator.fullTierSize()); - } - - @Test - void fullPathStillSplitsBucketsOnDifferingContext() { - // Consent-on preserves the previous behaviour: distinct contexts remain distinct buckets so - // each raw context is emitted verbatim. - final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); - final Map ctx1 = new HashMap<>(); - ctx1.put("plan", "pro"); - ctx1.put("request_id", "req-1"); - final Map ctx2 = new HashMap<>(); - ctx2.put("plan", "pro"); - ctx2.put("request_id", "req-2"); - - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, true, ctx1)); - aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, true, ctx2)); - - assertEquals(2, aggregator.fullTierSize()); - } - - private static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - return new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - null, - evalTimeMs, - observeFullEvaluationData, - attrs); - } - - private static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); - } - - private static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { - return event(flagKey, variant, "alloc1", "user-1", 1000L, emptyMap()); - } - - private static FlagEvaluationAggregator.FullKey fullKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage, - final String targetingKey, - final String contextKey) { - return new FlagEvaluationAggregator.FullKey( - flagKey, - variant, - allocationKey, - runtimeDefaultUsed, - errorMessage, - targetingKey, - contextKey, - false); - } - - private static FlagEvaluationAggregator.DegradedKey degradedKey( - final String flagKey, - final String variant, - final String allocationKey, - final boolean runtimeDefaultUsed, - final String errorMessage) { - return new FlagEvaluationAggregator.DegradedKey( - flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage); - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java index d4ca517fffd..a0818ccdad6 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java @@ -19,435 +19,349 @@ class FlagEvaluationPayloadsTest { - private static final long EVAL_MS = 1_760_000_000_000L; - private static final Map CONTEXT = context(); - private static final JsonAdapter> JSON_MAP; - - static { - final Moshi moshi = new Moshi.Builder().build(); - final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); - JSON_MAP = moshi.adapter(type); - } - - @Test - void fullTierPayloadUsesWorkerWireShape() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event("my-flag", "on", "alloc-x", "user-1", 1, attrs)), + private static final long EVAL_MS = 1_760_000_000_000L; + private static final Map CONTEXT = context(); + private static final JsonAdapter> JSON_MAP; + + static { + final Moshi moshi = new Moshi.Builder().build(); + final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); + JSON_MAP = moshi.adapter(type); + } + + @Test + void fullTierPayloadUsesWorkerWireShape() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + + final Map json = firstPayload(FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(event("my-flag", "on", "alloc-x", "user-1", 1, attrs)), CONTEXT, 1_000_000)); - final Map ev = firstEvent(json); - assertObjectWithKey(ev.get("variant"), "on"); - assertObjectWithKey(ev.get("allocation"), "alloc-x"); - assertObjectWithKey(ev.get("flag"), "my-flag"); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - assertFalse(ev.containsKey("reason")); - } - - @Test - void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true); - bucket.merge(EVAL_MS + 10, false); - final long flushTimeMs = EVAL_MS + 5_000; - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( + final Map ev = firstEvent(json); + assertObjectWithKey(ev.get("variant"), "on"); + assertObjectWithKey(ev.get("allocation"), "alloc-x"); + assertObjectWithKey(ev.get("flag"), "my-flag"); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + assertFalse(ev.containsKey("reason")); + } + + @Test + void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { + final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( + "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true); + bucket.merge(EVAL_MS + 10, false); + final long flushTimeMs = EVAL_MS + 5_000; + + final Map json = firstPayload(FlagEvaluationPayloads.buildPayloads( java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, true, flushTimeMs)), + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, true, true, flushTimeMs)), CONTEXT, 1_000_000)); - final Map ev = firstEvent(json); - assertEquals((double) flushTimeMs, ((Number) ev.get("timestamp")).doubleValue()); - assertEquals((double) EVAL_MS, ((Number) ev.get("first_evaluation")).doubleValue()); - assertEquals((double) (EVAL_MS + 10), ((Number) ev.get("last_evaluation")).doubleValue()); - assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); - } - - @Test - void degradedTierEventOmitsTargetingKeyAndContext() throws Exception { - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( + final Map ev = firstEvent(json); + assertEquals((double) flushTimeMs, ((Number) ev.get("timestamp")).doubleValue()); + assertEquals((double) EVAL_MS, ((Number) ev.get("first_evaluation")).doubleValue()); + assertEquals((double) (EVAL_MS + 10), ((Number) ev.get("last_evaluation")).doubleValue()); + assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); + } + + @Test + void degradedTierEventOmitsTargetingKeyAndContext() throws Exception { + final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( + "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false); + + final Map json = firstPayload(FlagEvaluationPayloads.buildPayloads( java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, false, true, EVAL_MS)), + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, false, true, EVAL_MS)), CONTEXT, 1_000_000)); - final Map ev = firstEvent(json); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void fullTierWithObserveFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - null, - EVAL_MS, - false, - attrs, - true); - - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( + final Map ev = firstEvent(json); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void fullTierWithObserveFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( + "pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", null, EVAL_MS, false, attrs, true); + + final Map json = firstPayload(FlagEvaluationPayloads.buildPayloads( java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, true, EVAL_MS)), + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, true, true, EVAL_MS)), CONTEXT, 1_000_000)); - final Map ev = firstEvent(json); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - } - - @Test - void fullTierWithObserveFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() - throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - final FlagEvaluationAggregator.EvalBucket bucket = - new FlagEvaluationAggregator.EvalBucket( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - null, - EVAL_MS, - false, - attrs, - false); - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( - bucket, true, false, EVAL_MS)), - CONTEXT, - 1_000_000); - final String rawJson = - new String(payloads.bodies.get(0), java.nio.charset.StandardCharsets.UTF_8); - - // The raw wire bytes must carry the hashed key and must not leak the raw PII value or the - // per-event evaluation context — these are the exact properties system-tests asserts over the - // wire. (The batch envelope has its own top-level "context" field, so we guard on the nested - // "evaluation" key instead, which only appears inside a per-event context object.) - assertTrue( - rawJson.contains( - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b")); - assertFalse(rawJson.contains("jane.doe@datadoghq.com")); - assertFalse(rawJson.contains("\"evaluation\":")); - - final Map ev = firstEvent(parse(payloads.bodies.get(0))); - assertEquals( - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", - ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - } - - @Test - void splitPayloadsByEncodedSize() throws Exception { - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 180)); - final java.util.ArrayList events = - new java.util.ArrayList<>(); - for (int i = 0; i < 4; i++) { - events.add(event("split-flag-" + i, "on", "alloc1", "user-" + i, 1, attrs)); + final Map ev = firstEvent(json); + assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + } + + @Test + void fullTierWithObserveFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( + "pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", null, EVAL_MS, false, attrs, false); + + final FlagEvaluationPayloads.EncodedPayloads payloads = FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, true, false, EVAL_MS)), + CONTEXT, + 1_000_000); + final String rawJson = new String(payloads.bodies.get(0), java.nio.charset.StandardCharsets.UTF_8); + + // The raw wire bytes must carry the hashed key and must not leak the raw PII value or the + // per-event evaluation context — these are the exact properties system-tests asserts over the + // wire. (The batch envelope has its own top-level "context" field, so we guard on the nested + // "evaluation" key instead, which only appears inside a per-event context object.) + assertTrue(rawJson.contains("sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b")); + assertFalse(rawJson.contains("jane.doe@datadoghq.com")); + assertFalse(rawJson.contains("\"evaluation\":")); + + final Map ev = firstEvent(parse(payloads.bodies.get(0))); + assertEquals( + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", ev.get("targeting_key")); + assertFalse(ev.containsKey("context")); + } + + @Test + void splitPayloadsByEncodedSize() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 180)); + final java.util.ArrayList events = new java.util.ArrayList<>(); + for (int i = 0; i < 4; i++) { + events.add(event("split-flag-" + i, "on", "alloc1", "user-" + i, 1, attrs)); + } + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(events, CONTEXT, 1_100); + + assertTrue(payloads.bodies.size() > 1); + int eventCount = 0; + for (final byte[] body : payloads.bodies) { + assertTrue(body.length <= 1_100); + eventCount += eventCount(parse(body)); + } + assertEquals(4, eventCount); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); } - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(events, CONTEXT, 1_100); + @Test + void oversizedFullPayloadRowIsDegradedBeforeDrop() throws Exception { + final Map attrs = new HashMap<>(); + for (int i = 0; i < 4; i++) { + attrs.put("payload-" + i, repeat('x', 200)); + } - assertTrue(payloads.bodies.size() > 1); - int eventCount = 0; - for (final byte[] body : payloads.bodies) { - assertTrue(body.length <= 1_100); - eventCount += eventCount(parse(body)); + final FlagEvaluationPayloads.EncodedPayloads payloads = FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(event("oversized-full", "on", "alloc1", "user-1", 2, attrs)), + CONTEXT, + 512); + + assertEquals(1, payloads.bodies.size()); + assertTrue(payloads.bodies.get(0).length <= 512); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(2, payloads.degradedPayloadLimit); + final Map ev = firstEvent(parse(payloads.bodies.get(0))); + assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); } - assertEquals(4, eventCount); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void oversizedFullPayloadRowIsDegradedBeforeDrop() throws Exception { - final Map attrs = new HashMap<>(); - for (int i = 0; i < 4; i++) { - attrs.put("payload-" + i, repeat('x', 200)); + + @Test + void oversizedFullPayloadRowStartsNewPayloadWhenDegradedRowFitsByItself() throws Exception { + final FlagEvaluationPayloads.FlagEvaluationEvent first = + event("first-flag", "on", "alloc1", "user-1", 1, emptyMap()); + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 400)); + final FlagEvaluationPayloads.FlagEvaluationEvent second = + event("second-flag", "on", "alloc1", "user-2", 3, attrs); + final FlagEvaluationPayloads.FlagEvaluationEvent degradedSecond = second.withoutTargetingKeyAndContext(); + assertNotNull(degradedSecond); + + final int firstPayloadSize = FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(first), CONTEXT, 1_000_000) + .bodies + .get(0) + .length; + final int degradedPayloadSize = FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(degradedSecond), CONTEXT, 1_000_000) + .bodies + .get(0) + .length; + final int limit = Math.max(firstPayloadSize, degradedPayloadSize); + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(Arrays.asList(first, second), CONTEXT, limit); + + assertEquals(2, payloads.bodies.size()); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(3, payloads.degradedPayloadLimit); + assertEquals(1, eventCount(parse(payloads.bodies.get(0)))); + final Map ev = firstEvent(parse(payloads.bodies.get(1))); + assertObjectWithKey(ev.get("flag"), "second-flag"); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); } - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event("oversized-full", "on", "alloc1", "user-1", 2, attrs)), - CONTEXT, - 512); - - assertEquals(1, payloads.bodies.size()); - assertTrue(payloads.bodies.get(0).length <= 512); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(2, payloads.degradedPayloadLimit); - final Map ev = firstEvent(parse(payloads.bodies.get(0))); - assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void oversizedFullPayloadRowStartsNewPayloadWhenDegradedRowFitsByItself() throws Exception { - final FlagEvaluationPayloads.FlagEvaluationEvent first = - event("first-flag", "on", "alloc1", "user-1", 1, emptyMap()); - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 400)); - final FlagEvaluationPayloads.FlagEvaluationEvent second = - event("second-flag", "on", "alloc1", "user-2", 3, attrs); - final FlagEvaluationPayloads.FlagEvaluationEvent degradedSecond = - second.withoutTargetingKeyAndContext(); - assertNotNull(degradedSecond); - - final int firstPayloadSize = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList(first), CONTEXT, 1_000_000) - .bodies - .get(0) - .length; - final int degradedPayloadSize = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList(degradedSecond), CONTEXT, 1_000_000) - .bodies - .get(0) - .length; - final int limit = Math.max(firstPayloadSize, degradedPayloadSize); - - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(Arrays.asList(first, second), CONTEXT, limit); - - assertEquals(2, payloads.bodies.size()); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(3, payloads.degradedPayloadLimit); - assertEquals(1, eventCount(parse(payloads.bodies.get(0)))); - final Map ev = firstEvent(parse(payloads.bodies.get(1))); - assertObjectWithKey(ev.get("flag"), "second-flag"); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void oversizedDegradedPayloadRowIsDropped() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event(repeat('f', 512), "on", "alloc1", null, 2, emptyMap())), - CONTEXT, - 128); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(2, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void oversizedFullPayloadRowIsDroppedWhenDegradedRowStillExceedsLimit() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - event(repeat('f', 512), "on", "alloc1", "user-1", 2, emptyMap())), - CONTEXT, - 128); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(2, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void errorPayloadSerializesErrorObject() throws Exception { - final Map json = - firstPayload( - FlagEvaluationPayloads.buildPayloads( - java.util.Collections.singletonList( - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - "err-flag", - EVAL_MS, - EVAL_MS, - 1, - null, - null, - null, - true, - "type mismatch", - null)), + @Test + void oversizedDegradedPayloadRowIsDropped() { + final FlagEvaluationPayloads.EncodedPayloads payloads = FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(event(repeat('f', 512), "on", "alloc1", null, 2, emptyMap())), + CONTEXT, + 128); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(2, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void oversizedFullPayloadRowIsDroppedWhenDegradedRowStillExceedsLimit() { + final FlagEvaluationPayloads.EncodedPayloads payloads = FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(event(repeat('f', 512), "on", "alloc1", "user-1", 2, emptyMap())), + CONTEXT, + 128); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(2, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void errorPayloadSerializesErrorObject() throws Exception { + final Map json = firstPayload(FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, "err-flag", EVAL_MS, EVAL_MS, 1, null, null, null, true, "type mismatch", null)), CONTEXT, 1_000_000)); - final Map ev = firstEvent(json); - final Map error = (Map) ev.get("error"); - assertNotNull(error); - assertEquals("type mismatch", error.get("message")); - assertEquals(Boolean.TRUE, ev.get("runtime_default_used")); - } - - @Test - void emptyEventListProducesNoPayloads() { - final FlagEvaluationPayloads.EncodedPayloads payloads = - FlagEvaluationPayloads.buildPayloads(java.util.Collections.emptyList(), CONTEXT, 1_000_000); - - assertTrue(payloads.bodies.isEmpty()); - assertEquals(0, payloads.droppedPayloadLimit); - assertEquals(0, payloads.degradedPayloadLimit); - } - - @Test - void requestDtoStoresContextAndEvents() { - final List events = - java.util.Collections.singletonList( - event("dto-flag", "on", "alloc1", "user-1", 1, emptyMap())); - - final FlagEvaluationPayloads.FlagEvaluationsRequest request = - new FlagEvaluationPayloads.FlagEvaluationsRequest(CONTEXT, events); - - assertEquals(CONTEXT, request.context); - assertEquals(events, request.flagEvaluations); - } - - @Test - void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { - final FlagEvaluationPayloads.FlagEvaluationEvent degraded = - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - "default-flag", - EVAL_MS, - EVAL_MS, - 1, - null, - null, - "user-1", - true, - null, - null) - .withoutTargetingKeyAndContext(); - - assertNotNull(degraded); - assertNull(degraded.variant); - assertNull(degraded.allocation); - assertNull(degraded.targeting_key); - assertNull(degraded.context); - assertNull(degraded.error); - assertEquals(Boolean.TRUE, degraded.runtime_default_used); - } - - @Test - void emptyOptionalStringsAreTreatedAsAbsentWhenContextIsPresent() { - final Map attrs = new HashMap<>(); - attrs.put("tier", "gold"); - final FlagEvaluationPayloads.FlagEvaluationEvent event = - new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs); - - assertNull(event.variant); - assertNull(event.allocation); - assertNull(event.error); - assertNotNull(event.context); - - final FlagEvaluationPayloads.FlagEvaluationEvent degraded = - event.withoutTargetingKeyAndContext(); - assertNotNull(degraded); - assertNull(degraded.targeting_key); - assertNull(degraded.context); - } - - private static FlagEvaluationPayloads.FlagEvaluationEvent event( - final String flagKey, - final String variant, - final String allocation, - final String targetingKey, - final long count, - final Map attrs) { - return new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, - flagKey, - EVAL_MS, - EVAL_MS, - count, - variant, - allocation, - targetingKey, - false, - null, - attrs); - } - - private static Map firstPayload( - final FlagEvaluationPayloads.EncodedPayloads payloads) throws Exception { - assertEquals(1, payloads.bodies.size()); - return parse(payloads.bodies.get(0)); - } - - private static Map parse(final byte[] body) throws Exception { - return JSON_MAP.fromJson(new String(body, java.nio.charset.StandardCharsets.UTF_8)); - } - - @SuppressWarnings("unchecked") - private static Map firstEvent(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events); - assertFalse(events.isEmpty()); - return (Map) events.get(0); - } - - @SuppressWarnings("unchecked") - private static int eventCount(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events); - return events.size(); - } - - private static void assertObjectWithKey(final Object object, final String expectedKey) { - assertTrue(object instanceof Map); - assertEquals(expectedKey, ((Map) object).get("key")); - } - - private static String repeat(final char c, final int count) { - final char[] chars = new char[count]; - java.util.Arrays.fill(chars, c); - return new String(chars); - } - - private static Map context() { - final Map context = new HashMap<>(); - context.put("service", "test-service"); - return context; - } + final Map ev = firstEvent(json); + final Map error = (Map) ev.get("error"); + assertNotNull(error); + assertEquals("type mismatch", error.get("message")); + assertEquals(Boolean.TRUE, ev.get("runtime_default_used")); + } + + @Test + void emptyEventListProducesNoPayloads() { + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(java.util.Collections.emptyList(), CONTEXT, 1_000_000); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void requestDtoStoresContextAndEvents() { + final List events = + java.util.Collections.singletonList(event("dto-flag", "on", "alloc1", "user-1", 1, emptyMap())); + + final FlagEvaluationPayloads.FlagEvaluationsRequest request = + new FlagEvaluationPayloads.FlagEvaluationsRequest(CONTEXT, events); + + assertEquals(CONTEXT, request.context); + assertEquals(events, request.flagEvaluations); + } + + @Test + void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { + final FlagEvaluationPayloads.FlagEvaluationEvent degraded = new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, "default-flag", EVAL_MS, EVAL_MS, 1, null, null, "user-1", true, null, null) + .withoutTargetingKeyAndContext(); + + assertNotNull(degraded); + assertNull(degraded.variant); + assertNull(degraded.allocation); + assertNull(degraded.targeting_key); + assertNull(degraded.context); + assertNull(degraded.error); + assertEquals(Boolean.TRUE, degraded.runtime_default_used); + } + + @Test + void emptyOptionalStringsAreTreatedAsAbsentWhenContextIsPresent() { + final Map attrs = new HashMap<>(); + attrs.put("tier", "gold"); + final FlagEvaluationPayloads.FlagEvaluationEvent event = new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs); + + assertNull(event.variant); + assertNull(event.allocation); + assertNull(event.error); + assertNotNull(event.context); + + final FlagEvaluationPayloads.FlagEvaluationEvent degraded = event.withoutTargetingKeyAndContext(); + assertNotNull(degraded); + assertNull(degraded.targeting_key); + assertNull(degraded.context); + } + + private static FlagEvaluationPayloads.FlagEvaluationEvent event( + final String flagKey, + final String variant, + final String allocation, + final String targetingKey, + final long count, + final Map attrs) { + return new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, flagKey, EVAL_MS, EVAL_MS, count, variant, allocation, targetingKey, false, null, attrs); + } + + private static Map firstPayload(final FlagEvaluationPayloads.EncodedPayloads payloads) + throws Exception { + assertEquals(1, payloads.bodies.size()); + return parse(payloads.bodies.get(0)); + } + + private static Map parse(final byte[] body) throws Exception { + return JSON_MAP.fromJson(new String(body, java.nio.charset.StandardCharsets.UTF_8)); + } + + @SuppressWarnings("unchecked") + private static Map firstEvent(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events); + assertFalse(events.isEmpty()); + return (Map) events.get(0); + } + + @SuppressWarnings("unchecked") + private static int eventCount(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events); + return events.size(); + } + + private static void assertObjectWithKey(final Object object, final String expectedKey) { + assertTrue(object instanceof Map); + assertEquals(expectedKey, ((Map) object).get("key")); + } + + private static String repeat(final char c, final int count) { + final char[] chars = new char[count]; + java.util.Arrays.fill(chars, c); + return new String(chars); + } + + private static Map context() { + final Map context = new HashMap<>(); + context.put("service", "test-service"); + return context; + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java index 502f0e76c69..62dabeb9317 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java @@ -32,215 +32,202 @@ final class FlagEvaluationTestSupport { - static final long REALISTIC_EVAL_MS = 1_760_000_000_000L; - static final JsonAdapter> JSON_MAP; - - static { - final Moshi moshi = new Moshi.Builder().build(); - final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); - JSON_MAP = moshi.adapter(type); - } - - private FlagEvaluationTestSupport() {} - - static Supplier backendApiSupplier(final BackendApiFactory factory) { - return () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false); - } - - static void clearCoreMetrics() { - CoreMetricCollector.getInstance().drain(); - } - - static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); - } - - static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - return new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - null, - evalTimeMs, - observeFullEvaluationData, - attrs); - } - - static FlagEvalEvent errorEvent( - final String flagKey, final String errorMessage, final long evalTimeMs) { - return new FlagEvalEvent( - flagKey, null, null, null, errorMessage, evalTimeMs, java.util.Collections.emptyMap()); - } - - static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { - return event(flagKey, variant, "alloc1", "user-1", 1000L, java.util.Collections.emptyMap()); - } - - static String repeat(final char c, final int count) { - final char[] chars = new char[count]; - Arrays.fill(chars, c); - return new String(chars); - } - - static TestWriterSetup buildTestWriter(final BackendApi mockEvp) { - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - - final Map context = new HashMap<>(); - context.put("service", "test-service"); - - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context); - - return new TestWriterSetup(handler, mockEvp, factory); - } - - static TestWriterSetup buildTestWriter( - final BackendApi mockEvp, final int payloadSizeLimitBytes) { - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - - final Map context = new HashMap<>(); - context.put("service", "test-service"); - - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest( - backendApiSupplier(factory), context, payloadSizeLimitBytes); - - return new TestWriterSetup(handler, mockEvp, factory); - } - - static CapturedJson flushAndCapture(final TestWriterSetup setup) throws Exception { - final List captured = flushAndCaptureAll(setup); - assertEquals(1, captured.size(), "Expected exactly one posted payload"); - return captured.get(0); - } - - static List flushAndCaptureAll(final TestWriterSetup setup) throws Exception { - final List captured = new ArrayList<>(); - when(setup.mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - final List json = new ArrayList<>(); - for (final RequestBody body : captured) { - json.add(readJson(body)); - } - return json; - } - - static CapturedJson readJson(final RequestBody body) throws Exception { - assertNotNull(body, "RequestBody must have been posted"); - final Buffer buf = new Buffer(); - body.writeTo(buf); - final String raw = buf.readUtf8(); - return new CapturedJson(raw, JSON_MAP.fromJson(raw)); - } - - static Map flushAndCaptureJson(final TestWriterSetup setup) throws Exception { - return flushAndCapture(setup).parsed; - } - - static long metricSum( - final Collection metrics, - final String metricName, - final String tag) { - long sum = 0; - for (final MetricCollector.Metric metric : metrics) { - if (!metricName.equals(metric.metricName)) { - continue; - } - if (tag == null) { - if (!metric.tags.isEmpty()) { - continue; + static final long REALISTIC_EVAL_MS = 1_760_000_000_000L; + static final JsonAdapter> JSON_MAP; + + static { + final Moshi moshi = new Moshi.Builder().build(); + final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); + JSON_MAP = moshi.adapter(type); + } + + private FlagEvaluationTestSupport() {} + + static Supplier backendApiSupplier(final BackendApiFactory factory) { + return () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false); + } + + static void clearCoreMetrics() { + CoreMetricCollector.getInstance().drain(); + } + + static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); + } + + static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + return new FlagEvalEvent( + flagKey, variant, allocationKey, targetingKey, null, evalTimeMs, observeFullEvaluationData, attrs); + } + + static FlagEvalEvent errorEvent(final String flagKey, final String errorMessage, final long evalTimeMs) { + return new FlagEvalEvent(flagKey, null, null, null, errorMessage, evalTimeMs, java.util.Collections.emptyMap()); + } + + static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { + return event(flagKey, variant, "alloc1", "user-1", 1000L, java.util.Collections.emptyMap()); + } + + static String repeat(final char c, final int count) { + final char[] chars = new char[count]; + Arrays.fill(chars, c); + return new String(chars); + } + + static TestWriterSetup buildTestWriter(final BackendApi mockEvp) { + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + + final Map context = new HashMap<>(); + context.put("service", "test-service"); + + final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = + FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context); + + return new TestWriterSetup(handler, mockEvp, factory); + } + + static TestWriterSetup buildTestWriter(final BackendApi mockEvp, final int payloadSizeLimitBytes) { + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + + final Map context = new HashMap<>(); + context.put("service", "test-service"); + + final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = + FlagEvaluationWriterImpl.createHandlerForTest( + backendApiSupplier(factory), context, payloadSizeLimitBytes); + + return new TestWriterSetup(handler, mockEvp, factory); + } + + static CapturedJson flushAndCapture(final TestWriterSetup setup) throws Exception { + final List captured = flushAndCaptureAll(setup); + assertEquals(1, captured.size(), "Expected exactly one posted payload"); + return captured.get(0); + } + + static List flushAndCaptureAll(final TestWriterSetup setup) throws Exception { + final List captured = new ArrayList<>(); + when(setup.mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) + .thenAnswer(inv -> { + captured.add(inv.getArgument(1)); + return null; + }); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + final List json = new ArrayList<>(); + for (final RequestBody body : captured) { + json.add(readJson(body)); + } + return json; + } + + static CapturedJson readJson(final RequestBody body) throws Exception { + assertNotNull(body, "RequestBody must have been posted"); + final Buffer buf = new Buffer(); + body.writeTo(buf); + final String raw = buf.readUtf8(); + return new CapturedJson(raw, JSON_MAP.fromJson(raw)); + } + + static Map flushAndCaptureJson(final TestWriterSetup setup) throws Exception { + return flushAndCapture(setup).parsed; + } + + static long metricSum( + final Collection metrics, final String metricName, final String tag) { + long sum = 0; + for (final MetricCollector.Metric metric : metrics) { + if (!metricName.equals(metric.metricName)) { + continue; + } + if (tag == null) { + if (!metric.tags.isEmpty()) { + continue; + } + } else if (!metric.tags.contains(tag)) { + continue; + } + sum += metric.value.longValue(); + } + return sum; + } + + static datadog.trace.api.Config cfg() { + final datadog.trace.api.Config config = mock(datadog.trace.api.Config.class); + when(config.getServiceName()).thenReturn("test-service"); + return config; + } + + static void assertObjectWithKey(final Object o, final String expectedKey, final String msg) { + assertTrue(o instanceof Map, msg + " (must be a JSON object, not a bare string)"); + assertEquals(expectedKey, ((Map) o).get("key"), msg); + } + + @SuppressWarnings("unchecked") + static Map firstEvent(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events, "flagEvaluations array must be present"); + assertFalse(events.isEmpty(), "flagEvaluations must not be empty"); + return (Map) events.get(0); + } + + @SuppressWarnings("unchecked") + static int eventCount(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events, "flagEvaluations array must be present"); + return events.size(); + } + + @SuppressWarnings("unchecked") + static Map eventForFlag(final Map batch, final String flagKey) { + final List events = (List) batch.get("flagEvaluations"); + for (final Object o : events) { + final Map ev = (Map) o; + final Map flag = (Map) ev.get("flag"); + if (flag != null && flagKey.equals(flag.get("key"))) { + return ev; + } + } + return null; + } + + static class TestWriterSetup { + final FlagEvaluationWriterImpl.SerializingHandlerForTest handler; + final BackendApi mockEvp; + final BackendApiFactory factory; + + TestWriterSetup( + final FlagEvaluationWriterImpl.SerializingHandlerForTest handler, + final BackendApi mockEvp, + final BackendApiFactory factory) { + this.handler = handler; + this.mockEvp = mockEvp; + this.factory = factory; } - } else if (!metric.tags.contains(tag)) { - continue; - } - sum += metric.value.longValue(); - } - return sum; - } - - static datadog.trace.api.Config cfg() { - final datadog.trace.api.Config config = mock(datadog.trace.api.Config.class); - when(config.getServiceName()).thenReturn("test-service"); - return config; - } - - static void assertObjectWithKey(final Object o, final String expectedKey, final String msg) { - assertTrue(o instanceof Map, msg + " (must be a JSON object, not a bare string)"); - assertEquals(expectedKey, ((Map) o).get("key"), msg); - } - - @SuppressWarnings("unchecked") - static Map firstEvent(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events, "flagEvaluations array must be present"); - assertFalse(events.isEmpty(), "flagEvaluations must not be empty"); - return (Map) events.get(0); - } - - @SuppressWarnings("unchecked") - static int eventCount(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events, "flagEvaluations array must be present"); - return events.size(); - } - - @SuppressWarnings("unchecked") - static Map eventForFlag(final Map batch, final String flagKey) { - final List events = (List) batch.get("flagEvaluations"); - for (final Object o : events) { - final Map ev = (Map) o; - final Map flag = (Map) ev.get("flag"); - if (flag != null && flagKey.equals(flag.get("key"))) { - return ev; - } - } - return null; - } - - static class TestWriterSetup { - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler; - final BackendApi mockEvp; - final BackendApiFactory factory; - - TestWriterSetup( - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler, - final BackendApi mockEvp, - final BackendApiFactory factory) { - this.handler = handler; - this.mockEvp = mockEvp; - this.factory = factory; - } - } - - static class CapturedJson { - final String raw; - final Map parsed; - - CapturedJson(final String raw, final Map parsed) { - this.raw = raw; - this.parsed = parsed; - } - } + } + + static class CapturedJson { + final String raw; + final Map parsed; + + CapturedJson(final String raw, final Map parsed) { + this.raw = raw; + this.parsed = parsed; + } + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java index 75a767c5c3f..24b4dc5e568 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java @@ -63,799 +63,752 @@ class FlagEvaluationWriterImplTest { - private static final String DIRECT_FLAG_EVALUATION_ENDPOINT = "/api/v2/flagevaluation"; - private static final String API_KEY = "test-api-key"; - private static final double TIMEOUT_SECONDS = 5; - - @BeforeEach - void clearCoreMetricsBefore() { - clearCoreMetrics(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @AfterEach - void clearCoreMetricsAfter() { - clearCoreMetrics(); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - // Reset the dispatched UFC state so observeFullEvaluationData can't leak into other tests. - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - @Test - void degradedCapOverflowTelemetryIsEmittedOnFlush() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.addDroppedDegradedOverflowForTest(3); - setup.handler.flush(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 3, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_DEGRADED_CAP)); - } - - @Test - void startRegistersWriterAndCloseDeregistersIt() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.start(); - assertEquals(writer, FeatureFlaggingGateway.getFlagEvalWriter()); - - writer.close(); - writer.close(); - writer.start(); - - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } - - @Test - void queueOverflowIncrementsObservableDropCounter() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl(2, 10L, TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - for (int i = 0; i < 100; i++) { - writer.enqueue(simpleEvent("of-flag", "on")); - } - - assertTrue(writer.droppedQueueOverflow() > 0); - final long queueDrops = writer.droppedQueueOverflow(); - writer.flushForTest(); - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - queueDrops, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); - } - - @Test - void enqueueAfterCloseIsDroppedAndCounted() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.close(); - writer.enqueue(simpleEvent("closed-flag", "on")); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void enqueueDisabledDropsAndCountsAsClosedDrop() { - // FeatureFlaggingSystem.stop() flips the gate before this writer's close() runs. Producers - // that race the gate flip must count the drop, otherwise shutdown loss stays invisible. - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - writer.enqueue(simpleEvent("disabled-flag", "on")); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void closeSweepsAndCountsEventsLeftInTheQueue() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - // The worker is never started, so nothing drains these; close() must account for them rather - // than leave them silently stranded. Stands in for the narrow window where a lock-free - // producer offers after the worker's final drain. - writer.enqueue(simpleEvent("residual-flag-1", "on")); - writer.enqueue(simpleEvent("residual-flag-2", "on")); - writer.close(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 2, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void enqueueIgnoresNullEvent() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.enqueue(null); - - assertNull(writer.pollQueuedEventForTest()); - assertEquals(0, writer.droppedQueueOverflow()); - } - - @Test - void enqueueDoesNotAggregateOnTheCallingThread() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.enqueue(simpleEvent("g2-flag", "on")); - writer.enqueue(simpleEvent("g2-flag", "on")); - - assertEquals(0, writer.aggregatorFullTierSizeForTest()); - assertEquals(0, writer.droppedQueueOverflow()); - } - - @Test - void handlerRunFailsFastWhenEvpProxyIsUnavailable() { - final BackendApiFactory factory = mock(BackendApiFactory.class); - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context()); - - assertThrows(IllegalArgumentException.class, handler::run); - } - - @Test - void flushIfNecessarySkipsEmptyStateAndWaitsForInterval() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.flushIfNecessary(); - setup.handler.add(simpleEvent("pending-flag", "on")); - setup.handler.drainAndAggregate(); - setup.handler.flushIfNecessary(); - - assertEquals(1, setup.handler.fullTierSizeForTest()); - } - - @Test - void flushIfNecessaryDoesNotReturnEarlyWhenOnlyQueueDropsArePending() { - final AtomicLong queueDrops = new AtomicLong(1); - final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = - new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( - () -> null, - Queues.mpscBlockingConsumerArrayQueue(16), - Long.MAX_VALUE, - TimeUnit.NANOSECONDS, - context(), - queueDrops, - new java.util.concurrent.ConcurrentHashMap<>(), - () -> {}, - FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - - handler.flushIfNecessary(); - - assertEquals(1, queueDrops.get()); - } - - @Test - @SuppressWarnings("unchecked") - void workerHandlesEmptyPolls() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final MessagePassingBlockingQueue queue = - mock(MessagePassingBlockingQueue.class); - when(queue.poll(100, TimeUnit.MILLISECONDS)) - .thenAnswer( - invocation -> { - Thread.currentThread().interrupt(); - return null; - }); - final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = - new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( - backendApiSupplier(factory), - queue, - Long.MAX_VALUE, - TimeUnit.NANOSECONDS, - context(), - new AtomicLong(0), - new java.util.concurrent.ConcurrentHashMap<>(), - () -> {}, - FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - - handler.run(); - - assertTrue(Thread.interrupted()); - } - - @Test - void degradedBucketsAreSerializedWithoutTargetingKeyOrContext() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.addDegradedBucketForTest("degraded-flag", "on", "alloc1", null, 1000L); - - final Map json = flushAndCaptureJson(setup); - - final Map ev = eventForFlag(json, "degraded-flag"); - assertNotNull(ev); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void testHandlerCanSimulateAndClearDegradedTierAtCap() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.simulateDegradedTierAtCap(); - setup.handler.clearAggregationForTest(); - setup.handler.add(simpleEvent("after-clear", "on")); - setup.handler.drainAndAggregate(); - - assertEquals(1, setup.handler.fullTierSizeForTest()); - } - - @Test - void payloadLimitDropsAreCountedOnFlush() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, 128); - setup.handler.add(event(repeat('f', 512), "on", "alloc1", "user-1", 1000L, emptyMap())); - - setup.handler.drainAndAggregate(); - setup.handler.flush(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_PAYLOAD_LIMIT)); - } - - @Test - void finalFlushRunsWithoutTheInterruptFlagSet() throws Exception { - // close() interrupts the worker to break it out of poll(). The final flush does socket I/O, - // and OkHttp fails fast on an interrupted thread, so the flag must be clear by the time the - // publisher is called. A mock publisher ignores the flag, so assert on it directly. - final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); - final boolean[] interruptedDuringPost = {true}; - final BackendApi mockEvp = mock(BackendApi.class); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - interruptedDuringPost[0] = Thread.currentThread().isInterrupted(); - posted.countDown(); - return null; - }); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - writer.enqueue(simpleEvent("interrupt-flag", "on")); - writer.close(); - - assertTrue(posted.await(5, TimeUnit.SECONDS)); - assertFalse( - interruptedDuringPost[0], - "final flush must not run on an interrupted thread; OkHttp fails fast and the drained" - + " rows are lost without being counted"); - } - - @Test - void closeDrainsAndFinalFlushesQueuedEvents() throws Exception { - final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); - final RequestBody[] captured = {null}; - final BackendApi mockEvp = mock(BackendApi.class); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured[0] = inv.getArgument(1); - posted.countDown(); - return null; - }); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - writer.enqueue(simpleEvent("shutdown-flag", "on")); - writer.close(); - - assertTrue(posted.await(5, TimeUnit.SECONDS)); - assertNotNull(captured[0]); - final Buffer buf = new Buffer(); - captured[0].writeTo(buf); - final Map json = JSON_MAP.fromJson(buf.readUtf8()); - assertNotNull(eventForFlag(json, "shutdown-flag")); - } - - @Test - void continuousTrafficFlushesWithoutWaitingForIdle() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 1 << 12, 1, TimeUnit.MILLISECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - boolean posted = false; - try { - final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (System.nanoTime() < deadline) { - writer.enqueue(simpleEvent("busy-flag", "on")); + private static final String DIRECT_FLAG_EVALUATION_ENDPOINT = "/api/v2/flagevaluation"; + private static final String API_KEY = "test-api-key"; + private static final double TIMEOUT_SECONDS = 5; + + @BeforeEach + void clearCoreMetricsBefore() { + clearCoreMetrics(); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + } + + @AfterEach + void clearCoreMetricsAfter() { + clearCoreMetrics(); + FeatureFlaggingGateway.setFlagEvalWriter(null); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + // Reset the dispatched UFC state so observeFullEvaluationData can't leak into other tests. + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } + + @Test + void degradedCapOverflowTelemetryIsEmittedOnFlush() { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + + setup.handler.addDroppedDegradedOverflowForTest(3); + setup.handler.flush(); + + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + 3, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationWriterImpl.DROP_REASON_DEGRADED_CAP)); + } + + @Test + void startRegistersWriterAndCloseDeregistersIt() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + writer.start(); + assertEquals(writer, FeatureFlaggingGateway.getFlagEvalWriter()); + + writer.close(); + writer.close(); + writer.start(); + + assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); + } + + @Test + void queueOverflowIncrementsObservableDropCounter() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = + new FlagEvaluationWriterImpl(2, 10L, TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); + + for (int i = 0; i < 100; i++) { + writer.enqueue(simpleEvent("of-flag", "on")); + } + + assertTrue(writer.droppedQueueOverflow() > 0); + final long queueDrops = writer.droppedQueueOverflow(); + writer.flushForTest(); + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + queueDrops, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); + } + + @Test + void enqueueAfterCloseIsDroppedAndCounted() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + writer.close(); + writer.enqueue(simpleEvent("closed-flag", "on")); + + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + 1, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void enqueueDisabledDropsAndCountsAsClosedDrop() { + // FeatureFlaggingSystem.stop() flips the gate before this writer's close() runs. Producers + // that race the gate flip must count the drop, otherwise shutdown loss stays invisible. + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); + writer.enqueue(simpleEvent("disabled-flag", "on")); + + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + 1, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void closeSweepsAndCountsEventsLeftInTheQueue() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + // The worker is never started, so nothing drains these; close() must account for them rather + // than leave them silently stranded. Stands in for the narrow window where a lock-free + // producer offers after the worker's final drain. + writer.enqueue(simpleEvent("residual-flag-1", "on")); + writer.enqueue(simpleEvent("residual-flag-2", "on")); + writer.close(); + + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + 2, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void enqueueIgnoresNullEvent() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + writer.enqueue(null); + + assertNull(writer.pollQueuedEventForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void enqueueDoesNotAggregateOnTheCallingThread() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + writer.enqueue(simpleEvent("g2-flag", "on")); + writer.enqueue(simpleEvent("g2-flag", "on")); + + assertEquals(0, writer.aggregatorFullTierSizeForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void handlerRunFailsFastWhenEvpProxyIsUnavailable() { + final BackendApiFactory factory = mock(BackendApiFactory.class); + final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = + FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context()); + + assertThrows(IllegalArgumentException.class, handler::run); + } + + @Test + void flushIfNecessarySkipsEmptyStateAndWaitsForInterval() { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + + setup.handler.flushIfNecessary(); + setup.handler.add(simpleEvent("pending-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flushIfNecessary(); + + assertEquals(1, setup.handler.fullTierSizeForTest()); + } + + @Test + void flushIfNecessaryDoesNotReturnEarlyWhenOnlyQueueDropsArePending() { + final AtomicLong queueDrops = new AtomicLong(1); + final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = + new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( + () -> null, + Queues.mpscBlockingConsumerArrayQueue(16), + Long.MAX_VALUE, + TimeUnit.NANOSECONDS, + context(), + queueDrops, + new java.util.concurrent.ConcurrentHashMap<>(), + () -> {}, + FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + + handler.flushIfNecessary(); + + assertEquals(1, queueDrops.get()); + } + + @Test + @SuppressWarnings("unchecked") + void workerHandlesEmptyPolls() throws Exception { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final MessagePassingBlockingQueue queue = mock(MessagePassingBlockingQueue.class); + when(queue.poll(100, TimeUnit.MILLISECONDS)).thenAnswer(invocation -> { + Thread.currentThread().interrupt(); + return null; + }); + final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = + new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( + backendApiSupplier(factory), + queue, + Long.MAX_VALUE, + TimeUnit.NANOSECONDS, + context(), + new AtomicLong(0), + new java.util.concurrent.ConcurrentHashMap<>(), + () -> {}, + FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + + handler.run(); + + assertTrue(Thread.interrupted()); + } + + @Test + void degradedBucketsAreSerializedWithoutTargetingKeyOrContext() throws Exception { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + setup.handler.addDegradedBucketForTest("degraded-flag", "on", "alloc1", null, 1000L); + + final Map json = flushAndCaptureJson(setup); + + final Map ev = eventForFlag(json, "degraded-flag"); + assertNotNull(ev); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void testHandlerCanSimulateAndClearDegradedTierAtCap() { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + + setup.handler.simulateDegradedTierAtCap(); + setup.handler.clearAggregationForTest(); + setup.handler.add(simpleEvent("after-clear", "on")); + setup.handler.drainAndAggregate(); + + assertEquals(1, setup.handler.fullTierSizeForTest()); + } + + @Test + void payloadLimitDropsAreCountedOnFlush() { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, 128); + setup.handler.add(event(repeat('f', 512), "on", "alloc1", "user-1", 1000L, emptyMap())); + + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + 1, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationWriterImpl.DROP_REASON_PAYLOAD_LIMIT)); + } + + @Test + void finalFlushRunsWithoutTheInterruptFlagSet() throws Exception { + // close() interrupts the worker to break it out of poll(). The final flush does socket I/O, + // and OkHttp fails fast on an interrupted thread, so the flag must be clear by the time the + // publisher is called. A mock publisher ignores the flag, so assert on it directly. + final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); + final boolean[] interruptedDuringPost = {true}; + final BackendApi mockEvp = mock(BackendApi.class); + when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) + .thenAnswer(inv -> { + interruptedDuringPost[0] = Thread.currentThread().isInterrupted(); + posted.countDown(); + return null; + }); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); + + writer.startForTest(); + writer.enqueue(simpleEvent("interrupt-flag", "on")); + writer.close(); + + assertTrue(posted.await(5, TimeUnit.SECONDS)); + assertFalse( + interruptedDuringPost[0], + "final flush must not run on an interrupted thread; OkHttp fails fast and the drained" + + " rows are lost without being counted"); + } + + @Test + void closeDrainsAndFinalFlushesQueuedEvents() throws Exception { + final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); + final RequestBody[] captured = {null}; + final BackendApi mockEvp = mock(BackendApi.class); + when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) + .thenAnswer(inv -> { + captured[0] = inv.getArgument(1); + posted.countDown(); + return null; + }); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); + + writer.startForTest(); + writer.enqueue(simpleEvent("shutdown-flag", "on")); + writer.close(); + + assertTrue(posted.await(5, TimeUnit.SECONDS)); + assertNotNull(captured[0]); + final Buffer buf = new Buffer(); + captured[0].writeTo(buf); + final Map json = JSON_MAP.fromJson(buf.readUtf8()); + assertNotNull(eventForFlag(json, "shutdown-flag")); + } + + @Test + void continuousTrafficFlushesWithoutWaitingForIdle() throws Exception { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = + new FlagEvaluationWriterImpl(1 << 12, 1, TimeUnit.MILLISECONDS, backendApiSupplier(factory), cfg()); + + writer.startForTest(); + boolean posted = false; try { - verify(mockEvp, atLeastOnce()) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - posted = true; - break; - } catch (AssertionError ignored) { - // Keep the worker busy until the deadline. + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + writer.enqueue(simpleEvent("busy-flag", "on")); + try { + verify(mockEvp, atLeastOnce()) + .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); + posted = true; + break; + } catch (AssertionError ignored) { + // Keep the worker busy until the deadline. + } + } + } finally { + writer.close(); + } + + assertTrue(posted); + } + + @Test + void flushPostsToFlagevaluationEndpoint() throws Exception { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + + setup.handler.add(event("flag-f", "on", "alloc1", "user-1", 1000L, emptyMap())); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + verify(setup.factory).createBackendApi(Intake.EVENT_PLATFORM, false); + verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); + } + + @Test + void splitPostFailureDoesNotRetryAlreadySentPayloads() throws Exception { + final int limit = 1_100; + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, limit); + final AtomicInteger posts = new AtomicInteger(); + doAnswer(invocation -> { + if (posts.incrementAndGet() == 2) { + throw new IOException("boom"); + } + return null; + }) + .when(mockEvp) + .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); + + for (int i = 0; i < 4; i++) { + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 180)); + setup.handler.add(event("split-failure-" + i, "on", "alloc1", "user-" + i, 1000L, attrs)); + } + + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(2, posts.get()); + setup.handler.flush(); + assertEquals(2, posts.get()); + } + + private static final String HASHED_JANE_DOE = + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b"; + + @Test + void observeFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { + // Consent travels on the event (snapshotted by the hook at evaluation time); the writer honours + // it verbatim and never consults the gateway. + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + setup.handler.add(piiEvent(true)); + + final Map json = flushAndCapture(setup).parsed; + + final Map ev = eventForFlag(json, "pii-flag"); + assertNotNull(ev); + assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + } + + @Test + void observeFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() throws Exception { + assertHashedTargetingKeyAndOmittedContext(piiEvent(false)); + } + + @Test + void flagEvalEventDefaultConsentHashesTargetingKeyAndOmitsContext() throws Exception { + // An event built without an explicit consent value defaults to the privacy-preserving false, so + // it must behave exactly like the explicit "false" case. This is the state the hook produces + // when no UFC has been dispatched (the gateway reports false). + assertHashedTargetingKeyAndOmittedContext(piiEventDefaultConsent()); + } + + @Test + void eventConsentFalseStaysHashedEvenWhenGatewayLaterReportsTrue() throws Exception { + // Regression guard: consent is decided by the value the event carried at evaluation time, never + // re-read from the gateway at flush. An event evaluated under consent=false must stay hashed + // even if a later RC update turns the gateway's consent on before the flush drains. + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + setup.handler.add(piiEvent(false)); + + // Flip the gateway's consent on before both aggregation and flush; the event's evaluation-time + // snapshot (false) must win at every downstream step, so neither may consult the gateway. + dispatchObserveFullEvaluationData(true); + setup.handler.drainAndAggregate(); + + final java.util.List captured = new java.util.ArrayList<>(); + when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) + .thenAnswer(inv -> { + captured.add(inv.getArgument(1)); + return null; + }); + setup.handler.flush(); + + assertEquals(1, captured.size()); + final FlagEvaluationTestSupport.CapturedJson json = FlagEvaluationTestSupport.readJson(captured.get(0)); + final Map ev = eventForFlag(json.parsed, "pii-flag"); + assertNotNull(ev); + assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); + assertFalse(ev.containsKey("context")); + assertTrue(json.raw.contains(HASHED_JANE_DOE)); + assertFalse(json.raw.contains("jane.doe@datadoghq.com")); + } + + @Test + void eventConsentTrueStaysRawEvenWhenGatewayLaterReportsFalse() throws Exception { + // Symmetric guard: an event evaluated under consent=true must stay raw even if a later RC + // update + // turns the gateway's consent off before aggregation and flush. Together with the false-stays- + // hashed test this pins that neither aggregation nor flush ever consults the gateway. + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + setup.handler.add(piiEvent(true)); + + dispatchObserveFullEvaluationData(false); + setup.handler.drainAndAggregate(); + + final java.util.List captured = new java.util.ArrayList<>(); + when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) + .thenAnswer(inv -> { + captured.add(inv.getArgument(1)); + return null; + }); + setup.handler.flush(); + + assertEquals(1, captured.size()); + final Map ev = + eventForFlag(FlagEvaluationTestSupport.readJson(captured.get(0)).parsed, "pii-flag"); + assertNotNull(ev); + assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + assertNotNull(ctx.get("evaluation")); + } + + @Test + void consentOffPreservesErrorCodeSignalAndNeverLeaksPiiInErrorMessage() throws Exception { + // Upstream contract: the hook substitutes the ErrorCode name for the raw exception message + // under consent-off (see + // FlagEvalLoggingHookTest#errorMessageReplacedByErrorCodeUnderConsentOff). + // This wire-level guard pins that a properly-formed consent-off event (a) still surfaces the + // stable ErrorCode signal for operators and (b) never lets a PII-shaped string escape onto the + // wire. Mirrors the existing PII guards on the targeting_key axis. + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + setup.handler.add(new FlagEvalEvent( + "err-flag", null, "alloc1", "jane.doe@datadoghq.com", "TYPE_MISMATCH", 1000L, false, emptyMap())); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + final Map ev = eventForFlag(captured.parsed, "err-flag"); + assertNotNull(ev); + final Map error = (Map) ev.get("error"); + assertNotNull(error, "error object must be present so operators keep the ErrorCode signal"); + assertEquals("TYPE_MISMATCH", error.get("message")); + assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); + assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); + assertFalse( + captured.raw.contains("For input string"), + "no exception-message-shaped text may reach the wire under consent-off"); + } + + @Test + void encodeFailureClearsAggregatorSoLaterFlushesRecover() throws Exception { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + + // Moshi rejects non-finite JSON numbers. A NaN in the context poisons buildPayloads for this + // bucket. Before the fix, the aggregator kept the bucket and every later flush re-threw. + final Map poison = new HashMap<>(); + poison.put("bad-number", Double.NaN); + setup.handler.add(event("poison-flag", "on", "alloc1", "user-1", 1000L, true, poison)); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + verify(mockEvp, org.mockito.Mockito.never()) + .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); + + // The bucket must not survive the failed flush. A follow-up healthy event flushes cleanly. + setup.handler.add(simpleEvent("healthy-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); + } + + @Test + void scoConstructorCreatesUsableWriter() { + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl(new SharedCommunicationObjects(), cfg()); + writer.enqueue(simpleEvent("sco-flag", "on")); + assertNotNull(writer.pollQueuedEventForTest()); + writer.close(); + } + + @Test + void agentlessWritesFlagEvaluationsDirectlyWhenLocalProxyIsUnavailable() throws Exception { + try (JavaTestHttpServer server = JavaTestHttpServer.httpServer(s -> s.handlers(h -> h.prefix( + DIRECT_FLAG_EVALUATION_ENDPOINT, + api -> api.getResponse().status(200).send("OK"))))) { + final Config config = cfg(); + when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); + when(config.getApiKey()).thenReturn(API_KEY); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final OkHttpClient client = new OkHttpClient.Builder().build(); + try { + final IntakeApi directApi = new IntakeApi( + HttpUrl.get(server.getAddress()).resolve("/api/v2/"), + API_KEY, + "123", + HttpRetryPolicy.Factory.NEVER_RETRY, + client, + false); + when(backendApiFactory.createDirectIntakeApi(eq(Intake.EVENT_PLATFORM), eq(false), eq(false))) + .thenReturn(directApi); + final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = new FeatureFlagBackendApiFactory( + config, backendApiFactory, FeatureFlagEventType.FLAG_EVALUATION); + final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); + + try (FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, 1, TimeUnit.MILLISECONDS, featureFlagBackendApiFactory::create, config)) { + writer.startForTest(); + writer.enqueue(simpleEvent("direct-flag", "on")); + + poll.eventually(() -> { + assertNotNull(server.getLastRequest()); + assertEquals( + DIRECT_FLAG_EVALUATION_ENDPOINT, + server.getLastRequest().getPath()); + assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); + assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); + assertTrue(server.getLastRequest().getBody().length > 0); + }); + } + } finally { + client.dispatcher().executorService().shutdownNow(); + client.connectionPool().evictAll(); + } } - } - } finally { - writer.close(); - } - - assertTrue(posted); - } - - @Test - void flushPostsToFlagevaluationEndpoint() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.add(event("flag-f", "on", "alloc1", "user-1", 1000L, emptyMap())); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - - verify(setup.factory).createBackendApi(Intake.EVENT_PLATFORM, false); - verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - } - - @Test - void splitPostFailureDoesNotRetryAlreadySentPayloads() throws Exception { - final int limit = 1_100; - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, limit); - final AtomicInteger posts = new AtomicInteger(); - doAnswer( - invocation -> { - if (posts.incrementAndGet() == 2) { - throw new IOException("boom"); - } - return null; - }) - .when(mockEvp) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - - for (int i = 0; i < 4; i++) { - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 180)); - setup.handler.add(event("split-failure-" + i, "on", "alloc1", "user-" + i, 1000L, attrs)); - } - - setup.handler.drainAndAggregate(); - setup.handler.flush(); - assertEquals(2, posts.get()); - setup.handler.flush(); - assertEquals(2, posts.get()); - } - - private static final String HASHED_JANE_DOE = - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b"; - - @Test - void observeFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { - // Consent travels on the event (snapshotted by the hook at evaluation time); the writer honours - // it verbatim and never consults the gateway. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(true)); - - final Map json = flushAndCapture(setup).parsed; - - final Map ev = eventForFlag(json, "pii-flag"); - assertNotNull(ev); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - } - - @Test - void observeFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() throws Exception { - assertHashedTargetingKeyAndOmittedContext(piiEvent(false)); - } - - @Test - void flagEvalEventDefaultConsentHashesTargetingKeyAndOmitsContext() throws Exception { - // An event built without an explicit consent value defaults to the privacy-preserving false, so - // it must behave exactly like the explicit "false" case. This is the state the hook produces - // when no UFC has been dispatched (the gateway reports false). - assertHashedTargetingKeyAndOmittedContext(piiEventDefaultConsent()); - } - - @Test - void eventConsentFalseStaysHashedEvenWhenGatewayLaterReportsTrue() throws Exception { - // Regression guard: consent is decided by the value the event carried at evaluation time, never - // re-read from the gateway at flush. An event evaluated under consent=false must stay hashed - // even if a later RC update turns the gateway's consent on before the flush drains. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(false)); - - // Flip the gateway's consent on before both aggregation and flush; the event's evaluation-time - // snapshot (false) must win at every downstream step, so neither may consult the gateway. - dispatchObserveFullEvaluationData(true); - setup.handler.drainAndAggregate(); - - final java.util.List captured = new java.util.ArrayList<>(); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.flush(); - - assertEquals(1, captured.size()); - final FlagEvaluationTestSupport.CapturedJson json = - FlagEvaluationTestSupport.readJson(captured.get(0)); - final Map ev = eventForFlag(json.parsed, "pii-flag"); - assertNotNull(ev); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - assertTrue(json.raw.contains(HASHED_JANE_DOE)); - assertFalse(json.raw.contains("jane.doe@datadoghq.com")); - } - - @Test - void eventConsentTrueStaysRawEvenWhenGatewayLaterReportsFalse() throws Exception { - // Symmetric guard: an event evaluated under consent=true must stay raw even if a later RC - // update - // turns the gateway's consent off before aggregation and flush. Together with the false-stays- - // hashed test this pins that neither aggregation nor flush ever consults the gateway. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(true)); - - dispatchObserveFullEvaluationData(false); - setup.handler.drainAndAggregate(); - - final java.util.List captured = new java.util.ArrayList<>(); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.flush(); - - assertEquals(1, captured.size()); - final Map ev = - eventForFlag(FlagEvaluationTestSupport.readJson(captured.get(0)).parsed, "pii-flag"); - assertNotNull(ev); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - assertNotNull(ctx.get("evaluation")); - } - - @Test - void consentOffPreservesErrorCodeSignalAndNeverLeaksPiiInErrorMessage() throws Exception { - // Upstream contract: the hook substitutes the ErrorCode name for the raw exception message - // under consent-off (see - // FlagEvalLoggingHookTest#errorMessageReplacedByErrorCodeUnderConsentOff). - // This wire-level guard pins that a properly-formed consent-off event (a) still surfaces the - // stable ErrorCode signal for operators and (b) never lets a PII-shaped string escape onto the - // wire. Mirrors the existing PII guards on the targeting_key axis. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add( - new FlagEvalEvent( - "err-flag", - null, - "alloc1", - "jane.doe@datadoghq.com", - "TYPE_MISMATCH", - 1000L, - false, - emptyMap())); - - final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); - - final Map ev = eventForFlag(captured.parsed, "err-flag"); - assertNotNull(ev); - final Map error = (Map) ev.get("error"); - assertNotNull(error, "error object must be present so operators keep the ErrorCode signal"); - assertEquals("TYPE_MISMATCH", error.get("message")); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); - assertFalse( - captured.raw.contains("For input string"), - "no exception-message-shaped text may reach the wire under consent-off"); - } - - @Test - void encodeFailureClearsAggregatorSoLaterFlushesRecover() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - // Moshi rejects non-finite JSON numbers. A NaN in the context poisons buildPayloads for this - // bucket. Before the fix, the aggregator kept the bucket and every later flush re-threw. - final Map poison = new HashMap<>(); - poison.put("bad-number", Double.NaN); - setup.handler.add(event("poison-flag", "on", "alloc1", "user-1", 1000L, true, poison)); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - verify(mockEvp, org.mockito.Mockito.never()) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - - // The bucket must not survive the failed flush. A follow-up healthy event flushes cleanly. - setup.handler.add(simpleEvent("healthy-flag", "on")); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - } - - @Test - void scoConstructorCreatesUsableWriter() { - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl(new SharedCommunicationObjects(), cfg()); - writer.enqueue(simpleEvent("sco-flag", "on")); - assertNotNull(writer.pollQueuedEventForTest()); - writer.close(); - } - - @Test - void agentlessWritesFlagEvaluationsDirectlyWhenLocalProxyIsUnavailable() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.prefix( - DIRECT_FLAG_EVALUATION_ENDPOINT, - api -> api.getResponse().status(200).send("OK"))))) { - final Config config = cfg(); - when(config.getFeatureFlaggingConfigurationSource()) - .thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final OkHttpClient client = new OkHttpClient.Builder().build(); - try { - final IntakeApi directApi = - new IntakeApi( - HttpUrl.get(server.getAddress()).resolve("/api/v2/"), - API_KEY, - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - client, - false); - when(backendApiFactory.createDirectIntakeApi( - eq(Intake.EVENT_PLATFORM), eq(false), eq(false))) - .thenReturn(directApi); - final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = - new FeatureFlagBackendApiFactory( - config, backendApiFactory, FeatureFlagEventType.FLAG_EVALUATION); - final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); - - try (FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, 1, TimeUnit.MILLISECONDS, featureFlagBackendApiFactory::create, config)) { - writer.startForTest(); - writer.enqueue(simpleEvent("direct-flag", "on")); - - poll.eventually( - () -> { - assertNotNull(server.getLastRequest()); - assertEquals(DIRECT_FLAG_EVALUATION_ENDPOINT, server.getLastRequest().getPath()); - assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); - assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); - assertTrue(server.getLastRequest().getBody().length > 0); - }); + } + + @Test + void countContextTruncatedAccumulatesPerReason() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_length"); + writer.flushForTest(); + + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + 2, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, + "reason:field_count")); + assertEquals( + 1, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, + "reason:field_length")); + } + + @Test + void hasCapacityForEnqueueReflectsQueueSaturationAndCountsPreQueueOverflow() { + final BackendApi mockEvp = mock(BackendApi.class); + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); + final int capacity = 2; + final FlagEvaluationWriterImpl writer = new FlagEvaluationWriterImpl( + capacity, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); + + assertTrue(writer.hasCapacityForEnqueue()); + + // Saturate the hand-off queue without starting the worker so no drain can free slots. + for (int i = 0; i < capacity; i++) { + writer.enqueue(simpleEvent("cap-flag-" + i, "on")); } - } finally { - client.dispatcher().executorService().shutdownNow(); - client.connectionPool().evictAll(); - } - } - } - - @Test - void countContextTruncatedAccumulatesPerReason() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.countContextTruncated("field_count"); - writer.countContextTruncated("field_count"); - writer.countContextTruncated("field_length"); - writer.flushForTest(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 2, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, - "reason:field_count")); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, - "reason:field_length")); - } - - @Test - void hasCapacityForEnqueueReflectsQueueSaturationAndCountsPreQueueOverflow() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final int capacity = 2; - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - capacity, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - assertTrue(writer.hasCapacityForEnqueue()); - - // Saturate the hand-off queue without starting the worker so no drain can free slots. - for (int i = 0; i < capacity; i++) { - writer.enqueue(simpleEvent("cap-flag-" + i, "on")); - } - assertFalse(writer.hasCapacityForEnqueue()); - - // Simulate a pre-queue overflow account by the hook and surface it on flush. - writer.countPreQueueOverflow(); - writer.flushForTest(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); - - writer.close(); - } - - private void assertHashedTargetingKeyAndOmittedContext(final FlagEvalEvent piiEvent) - throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent); - - final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); - - final Map ev = eventForFlag(captured.parsed, "pii-flag"); - assertNotNull(ev); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - // The raw wire bytes must carry the hashed key and never leak the raw PII value or a per-event - // evaluation context (the batch envelope owns the top-level "context" key, so guard on the - // nested "evaluation" field instead). - assertTrue(captured.raw.contains(HASHED_JANE_DOE)); - assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); - assertFalse(captured.raw.contains("\"evaluation\":")); - } - - private static FlagEvalEvent piiEvent(final boolean observeFullEvaluationData) { - return event( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - 1000L, - observeFullEvaluationData, - piiAttrs()); - } - - private static FlagEvalEvent piiEventDefaultConsent() { - return event("pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", 1000L, piiAttrs()); - } - - private static Map piiAttrs() { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - return attrs; - } - - private static void dispatchObserveFullEvaluationData(final boolean value) { - FeatureFlaggingGateway.dispatch( - new ServerConfiguration( - "2024-04-17T19:40:53.716Z", "SERVER", value, null, java.util.Collections.emptyMap())); - } - - private static Object lifecycleLock(final FlagEvaluationWriterImpl writer) throws Exception { - final Field field = FlagEvaluationWriterImpl.class.getDeclaredField("lifecycleLock"); - field.setAccessible(true); - return field.get(writer); - } - - private static void awaitThreadState(final Thread thread, final Thread.State state) - throws InterruptedException { - final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (thread.getState() != state && System.nanoTime() < deadline) { - Thread.sleep(10); - } - assertEquals(state, thread.getState()); - } - - private static Map context() { - final Map context = new HashMap<>(); - context.put("service", "test-service"); - return context; - } + assertFalse(writer.hasCapacityForEnqueue()); + + // Simulate a pre-queue overflow account by the hook and surface it on flush. + writer.countPreQueueOverflow(); + writer.flushForTest(); + + final Collection metrics = + CoreMetricCollector.getInstance().drain(); + assertEquals( + 1, + metricSum( + metrics, + FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); + + writer.close(); + } + + private void assertHashedTargetingKeyAndOmittedContext(final FlagEvalEvent piiEvent) throws Exception { + final BackendApi mockEvp = mock(BackendApi.class); + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); + setup.handler.add(piiEvent); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + final Map ev = eventForFlag(captured.parsed, "pii-flag"); + assertNotNull(ev); + assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); + assertFalse(ev.containsKey("context")); + // The raw wire bytes must carry the hashed key and never leak the raw PII value or a per-event + // evaluation context (the batch envelope owns the top-level "context" key, so guard on the + // nested "evaluation" field instead). + assertTrue(captured.raw.contains(HASHED_JANE_DOE)); + assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); + assertFalse(captured.raw.contains("\"evaluation\":")); + } + + private static FlagEvalEvent piiEvent(final boolean observeFullEvaluationData) { + return event( + "pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", 1000L, observeFullEvaluationData, piiAttrs()); + } + + private static FlagEvalEvent piiEventDefaultConsent() { + return event("pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", 1000L, piiAttrs()); + } + + private static Map piiAttrs() { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + return attrs; + } + + private static void dispatchObserveFullEvaluationData(final boolean value) { + FeatureFlaggingGateway.dispatch(new ServerConfiguration( + "2024-04-17T19:40:53.716Z", "SERVER", value, null, java.util.Collections.emptyMap())); + } + + private static Object lifecycleLock(final FlagEvaluationWriterImpl writer) throws Exception { + final Field field = FlagEvaluationWriterImpl.class.getDeclaredField("lifecycleLock"); + field.setAccessible(true); + return field.get(writer); + } + + private static void awaitThreadState(final Thread thread, final Thread.State state) throws InterruptedException { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (thread.getState() != state && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertEquals(state, thread.getState()); + } + + private static Map context() { + final Map context = new HashMap<>(); + context.put("service", "test-service"); + return context; + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java index fae2631ce2f..688f30cb161 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java @@ -24,11 +24,9 @@ class JsonApiUfcResponseParserTest { - @Test - void parsesJsonApiMembersInAnyOrder() throws Exception { - final ServerConfiguration configuration = - parse( - "{" + @Test + void parsesJsonApiMembersInAnyOrder() throws Exception { + final ServerConfiguration configuration = parse("{" + "\"meta\":{\"ignored\":true}," + "\"data\":{" + "\"attributes\":" @@ -38,426 +36,390 @@ void parsesJsonApiMembersInAnyOrder() throws Exception { + "}" + "}"); - assertNotNull(configuration); - assertEquals("Test", configuration.environment.name); - assertTrue(configuration.flags.isEmpty()); - } - - @Test - void rejectsRawUfc() throws Exception { - assertNull(parse(emptyConfig())); - } - - @Test - void rejectsUnexpectedJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":\"other-type\",\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsNonStringJsonApiType() throws Exception { - assertNull(parse("{\"data\":{\"type\":null,\"attributes\":" + emptyConfig() + "}}")); - } - - @Test - void rejectsConfigurationWithoutFlags() throws Exception { - assertNull( - parse( - "{\"data\":{" + assertNotNull(configuration); + assertEquals("Test", configuration.environment.name); + assertTrue(configuration.flags.isEmpty()); + } + + @Test + void rejectsRawUfc() throws Exception { + assertNull(parse(emptyConfig())); + } + + @Test + void rejectsUnexpectedJsonApiType() throws Exception { + assertNull(parse("{\"data\":{\"type\":\"other-type\",\"attributes\":" + emptyConfig() + "}}")); + } + + @Test + void rejectsNonStringJsonApiType() throws Exception { + assertNull(parse("{\"data\":{\"type\":null,\"attributes\":" + emptyConfig() + "}}")); + } + + @Test + void rejectsConfigurationWithoutFlags() throws Exception { + assertNull(parse("{\"data\":{" + "\"type\":\"universal-flag-configuration\"," + "\"attributes\":{\"environment\":{\"name\":\"Test\"}}" + "}}")); - } - - @Test - void rejectsNonObjectData() throws Exception { - assertNull(parse("{\"data\":[]}")); - } - - @Test - void rejectsTrailingJson() { - assertThrows( - IOException.class, - () -> - parse( - "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" - + emptyConfig() - + "}}{}")); - } - - @Test - void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag("no-allocations", ""), - booleanFlag( - "no-splits", ",\"allocations\":[{\"key\":\"no-splits\",\"rules\":[]}]"), - booleanFlag( - "null-split", - ",\"allocations\":[{\"key\":\"null-split\",\"rules\":[],\"splits\":[null]}]"), - booleanFlag("no-rules", allocation("no-rules", "")), - booleanFlag("no-conditions", allocation("no-conditions", "[{}]")), - booleanFlag( - "no-operator", allocation("no-operator", "[{\"conditions\":[{}]}]")), - booleanFlag( + } + + @Test + void rejectsNonObjectData() throws Exception { + assertNull(parse("{\"data\":[]}")); + } + + @Test + void rejectsTrailingJson() { + assertThrows( + IOException.class, + () -> parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + emptyConfig() + + "}}{}")); + } + + @Test + void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { + final ServerConfiguration configuration = parse(wrap(configWithFlags( + booleanFlag("no-allocations", ""), + booleanFlag("no-splits", ",\"allocations\":[{\"key\":\"no-splits\",\"rules\":[]}]"), + booleanFlag("null-split", ",\"allocations\":[{\"key\":\"null-split\",\"rules\":[],\"splits\":[null]}]"), + booleanFlag("no-rules", allocation("no-rules", "")), + booleanFlag("no-conditions", allocation("no-conditions", "[{}]")), + booleanFlag("no-operator", allocation("no-operator", "[{\"conditions\":[{}]}]")), + booleanFlag( "non-semver", allocation( - "non-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), - booleanFlag( + "non-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), + booleanFlag( "valid-semver", allocation( - "valid-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2\"}]}]")), - booleanFlag( + "valid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2\"}]}]")), + booleanFlag( "invalid-semver", allocation( - "invalid-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.02\"}]}]")), - booleanFlag( + "invalid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.02\"}]}]")), + booleanFlag( "non-string-semver", allocation( - "non-string-semver", - "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":1}]}]")), - "\"null-flag\":null"))); - - assertNotNull(configuration); - assertTrue(configuration.flags.containsKey("no-allocations")); - assertTrue(configuration.flags.containsKey("no-splits")); - assertTrue(configuration.flags.containsKey("null-split")); - assertTrue(configuration.flags.containsKey("no-rules")); - assertTrue(configuration.flags.containsKey("no-conditions")); - assertTrue(configuration.flags.containsKey("no-operator")); - assertTrue(configuration.flags.containsKey("non-semver")); - assertTrue(configuration.flags.containsKey("valid-semver")); - assertFalse(configuration.flags.containsKey("invalid-semver")); - assertFalse(configuration.flags.containsKey("non-string-semver")); - assertFalse(configuration.flags.containsKey("null-flag")); - assertEquals(2, configuration.invalidFlags.size()); - assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); - assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); - - assertNotNull( - configuration - .flags - .get("valid-semver") - .allocations - .get(0) - .rules - .get(0) - .conditions - .get(0) - .semverComparand); - } - - @Test - void dropsFlagWithMalformedAllocationsWithoutRejectingConfig() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag("malformed-allocations", ",\"allocations\":\"not-a-list\""), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("malformed-allocations")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("malformed-allocations")); - } - - @Test - void dropsFlagWithMissingSplitShards() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( + "non-string-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":1}]}]")), + "\"null-flag\":null"))); + + assertNotNull(configuration); + assertTrue(configuration.flags.containsKey("no-allocations")); + assertTrue(configuration.flags.containsKey("no-splits")); + assertTrue(configuration.flags.containsKey("null-split")); + assertTrue(configuration.flags.containsKey("no-rules")); + assertTrue(configuration.flags.containsKey("no-conditions")); + assertTrue(configuration.flags.containsKey("no-operator")); + assertTrue(configuration.flags.containsKey("non-semver")); + assertTrue(configuration.flags.containsKey("valid-semver")); + assertFalse(configuration.flags.containsKey("invalid-semver")); + assertFalse(configuration.flags.containsKey("non-string-semver")); + assertFalse(configuration.flags.containsKey("null-flag")); + assertEquals(2, configuration.invalidFlags.size()); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); + + assertNotNull(configuration + .flags + .get("valid-semver") + .allocations + .get(0) + .rules + .get(0) + .conditions + .get(0) + .semverComparand); + } + + @Test + void dropsFlagWithMalformedAllocationsWithoutRejectingConfig() throws Exception { + final ServerConfiguration configuration = parse(wrap(configWithFlags( + booleanFlag("malformed-allocations", ",\"allocations\":\"not-a-list\""), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("malformed-allocations")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("malformed-allocations")); + } + + @Test + void dropsFlagWithMissingSplitShards() throws Exception { + final ServerConfiguration configuration = parse(wrap(configWithFlags( + booleanFlag( "missing-shards", ",\"allocations\":[{\"key\":\"missing-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\"}]}]"), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("missing-shards")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); - } - - @Test - void dropsFlagsWithInvalidConditionOperandsAndShardBounds() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("missing-shards")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); + } + + @Test + void dropsFlagsWithInvalidConditionOperandsAndShardBounds() throws Exception { + final ServerConfiguration configuration = parse(wrap(configWithFlags( + booleanFlag( "non-numeric-gt", allocation( - "non-numeric-gt", - "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"GT\",\"value\":\"bad\"}]}]")), - booleanFlag( + "non-numeric-gt", + "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"GT\",\"value\":\"bad\"}]}]")), + booleanFlag( "non-list-one-of", allocation( - "non-list-one-of", - "[{\"conditions\":[{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":\"admin\"}]}]")), - booleanFlag( + "non-list-one-of", + "[{\"conditions\":[{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":\"admin\"}]}]")), + booleanFlag( "negative-shard-range", ",\"allocations\":[{\"key\":\"negative-shard-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":100,\"ranges\":[{\"start\":-1,\"end\":1}]}]}]}]"), - booleanFlag("valid-sibling", "")))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("non-numeric-gt")); - assertFalse(configuration.flags.containsKey("non-list-one-of")); - assertFalse(configuration.flags.containsKey("negative-shard-range")); - assertTrue(configuration.flags.containsKey("valid-sibling")); - assertEquals("invalid_flag", configuration.invalidFlags.get("non-numeric-gt")); - assertEquals("invalid_flag", configuration.invalidFlags.get("non-list-one-of")); - assertEquals("invalid_flag", configuration.invalidFlags.get("negative-shard-range")); - } - - @Test - void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { - final ServerConfiguration configuration = - parse( - wrap( - configWithFlags( - booleanFlag( - "zero-total-shards", - ",\"allocations\":[{\"key\":\"zero-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":0,\"ranges\":[]}]}]}]"), - booleanFlag( - "unsigned-total-shards", - ",\"allocations\":[{\"key\":\"unsigned-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":2147483648,\"ranges\":[{\"start\":2147483648,\"end\":2147483649}]}]}]}]"), - booleanFlag( - "too-many-shards", - ",\"allocations\":[{\"key\":\"too-many-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":4294967296,\"ranges\":[]}]}]}]"), - booleanFlag( - "missing-ranges", - ",\"allocations\":[{\"key\":\"missing-ranges\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1}]}]}]"), - booleanFlag( - "null-shard", - ",\"allocations\":[{\"key\":\"null-shard\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[null]}]}]"), - booleanFlag( - "null-range", - ",\"allocations\":[{\"key\":\"null-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[null]}]}]}]"), - booleanFlag( - "negative-range-end", - ",\"allocations\":[{\"key\":\"negative-range-end\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[{\"start\":0,\"end\":-1}]}]}]}]"), - booleanFlag( - "non-boolean-is-null", - allocation( - "non-boolean-is-null", - "[{\"conditions\":[{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":\"false\"}]}]")), - booleanFlag( - "valid-condition-operands", - allocation( - "valid-condition-operands", - "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"LT\",\"value\":1},{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":[\"admin\"]},{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":true}]}]"))))); - - assertNotNull(configuration); - assertFalse(configuration.flags.containsKey("zero-total-shards")); - assertTrue(configuration.flags.containsKey("unsigned-total-shards")); - assertEquals( - 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .totalShards)); - assertEquals( - 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .start)); - assertEquals( - 2_147_483_649L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .end)); - assertFalse(configuration.flags.containsKey("too-many-shards")); - assertFalse(configuration.flags.containsKey("missing-ranges")); - assertFalse(configuration.flags.containsKey("null-shard")); - assertFalse(configuration.flags.containsKey("null-range")); - assertFalse(configuration.flags.containsKey("negative-range-end")); - assertFalse(configuration.flags.containsKey("non-boolean-is-null")); - assertTrue(configuration.flags.containsKey("valid-condition-operands")); - } - - @Test - void shardAdapterFactoryRejectsQualifiedShard() { - assertNull( - UniversalFlagConfigParser.ShardAdapter.FACTORY.create( - Shard.class, - Collections.singleton(QualifiedShard.class.getAnnotation(ShardQualifier.class)), - new Moshi.Builder().build())); - } - - @JsonQualifier - @Retention(RetentionPolicy.RUNTIME) - @Target(ElementType.TYPE) - private @interface ShardQualifier {} - - @ShardQualifier - private static final class QualifiedShard {} - - @Test - void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { - assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { - // Absent → Moshi leaves the boxed field null; the read site's Boolean.TRUE.equals(...) then - // resolves to the privacy-preserving default (consent-off). Either null-or-false is the - // documented invariant; assert the field never reads as true. - final ServerConfiguration configuration = parse(wrap(emptyConfig())); - assertNotNull(configuration); - assertFalse(Boolean.TRUE.equals(configuration.observeFullEvaluationData)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void observeFullEvaluationDataParsesExplicitValue(final boolean value) throws Exception { - final ServerConfiguration configuration = - parse(wrap(configWithObserveFullEvaluationData(value))); - assertNotNull(configuration); - assertEquals(value, configuration.observeFullEvaluationData); - } - - @Test - void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig() - throws Exception { - // An explicit null (or a wrong-typed value) for this field must not abort the whole UFC parse. - // A pod that starts after a malformed UFC has no last-known-good, so aborting would strand - // every flag on PROVIDER_NOT_READY (its default value). We fail closed on privacy (consent - // stays false) but preserve availability: flags parse and evaluate. - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("null"))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - @Test - void observeFullEvaluationDataWrongTypedStringDefaultsToFalse() throws Exception { - // Moshi tolerates a stringified boolean like "true" via nullSafe/boxed handling: it either - // parses as null or throws locally and leaves the field null. Either way, downstream reads - // via Boolean.TRUE.equals(...) treat it as consent-off. The rest of the config must parse. - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("\"true\""))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - @Test - void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception { - final ServerConfiguration configuration = - parse(wrap(configWithRawObserveFullEvaluationData("1"))); - - assertNotNull(configuration); - assertFalse( - configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); - assertNotNull(configuration.flags); - } - - private static ServerConfiguration parse(final String json) throws Exception { - return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); - } - - private static String wrap(final String attributes) { - return "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" - + attributes - + "}}"; - } - - private static String configWithObserveFullEvaluationData(final boolean value) { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"observeFullEvaluationData\":" - + value - + "," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } - - private static String configWithRawObserveFullEvaluationData(final String rawJsonValue) { - // Emit the field with a caller-controlled raw JSON value (null / "true" / 1 / ...) so we can - // assert the parser's tolerance of malformed shapes without going through configWith's - // boolean-typed helper. - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"observeFullEvaluationData\":" - + rawJsonValue - + "," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } - - private static String configWithFlags(final String... flags) { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{" - + String.join(",", flags) - + "}}"; - } - - private static String booleanFlag(final String key, final String allocations) { - return "\"" - + key - + "\":{" - + "\"key\":\"" - + key - + "\",\"enabled\":true,\"variationType\":\"BOOLEAN\"," - + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":true}}" - + allocations - + "}"; - } - - private static String allocation(final String key, final String rules) { - return ",\"allocations\":[{\"key\":\"" - + key - + "\"" - + (rules.isEmpty() ? "" : ",\"rules\":" + rules) - + ",\"splits\":[]}]"; - } - - private static String emptyConfig() { - return "{" - + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," - + "\"environment\":{\"name\":\"Test\"}," - + "\"flags\":{}" - + "}"; - } + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("non-numeric-gt")); + assertFalse(configuration.flags.containsKey("non-list-one-of")); + assertFalse(configuration.flags.containsKey("negative-shard-range")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("non-numeric-gt")); + assertEquals("invalid_flag", configuration.invalidFlags.get("non-list-one-of")); + assertEquals("invalid_flag", configuration.invalidFlags.get("negative-shard-range")); + } + + @Test + void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { + final ServerConfiguration configuration = parse( + wrap( + configWithFlags( + booleanFlag( + "zero-total-shards", + ",\"allocations\":[{\"key\":\"zero-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":0,\"ranges\":[]}]}]}]"), + booleanFlag( + "unsigned-total-shards", + ",\"allocations\":[{\"key\":\"unsigned-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":2147483648,\"ranges\":[{\"start\":2147483648,\"end\":2147483649}]}]}]}]"), + booleanFlag( + "too-many-shards", + ",\"allocations\":[{\"key\":\"too-many-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":4294967296,\"ranges\":[]}]}]}]"), + booleanFlag( + "missing-ranges", + ",\"allocations\":[{\"key\":\"missing-ranges\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1}]}]}]"), + booleanFlag( + "null-shard", + ",\"allocations\":[{\"key\":\"null-shard\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[null]}]}]"), + booleanFlag( + "null-range", + ",\"allocations\":[{\"key\":\"null-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[null]}]}]}]"), + booleanFlag( + "negative-range-end", + ",\"allocations\":[{\"key\":\"negative-range-end\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[{\"start\":0,\"end\":-1}]}]}]}]"), + booleanFlag( + "non-boolean-is-null", + allocation( + "non-boolean-is-null", + "[{\"conditions\":[{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":\"false\"}]}]")), + booleanFlag( + "valid-condition-operands", + allocation( + "valid-condition-operands", + "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"LT\",\"value\":1},{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":[\"admin\"]},{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":true}]}]"))))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("zero-total-shards")); + assertTrue(configuration.flags.containsKey("unsigned-total-shards")); + assertEquals( + 2_147_483_648L, + Integer.toUnsignedLong(configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .totalShards)); + assertEquals( + 2_147_483_648L, + Integer.toUnsignedLong(configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .start)); + assertEquals( + 2_147_483_649L, + Integer.toUnsignedLong(configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .end)); + assertFalse(configuration.flags.containsKey("too-many-shards")); + assertFalse(configuration.flags.containsKey("missing-ranges")); + assertFalse(configuration.flags.containsKey("null-shard")); + assertFalse(configuration.flags.containsKey("null-range")); + assertFalse(configuration.flags.containsKey("negative-range-end")); + assertFalse(configuration.flags.containsKey("non-boolean-is-null")); + assertTrue(configuration.flags.containsKey("valid-condition-operands")); + } + + @Test + void shardAdapterFactoryRejectsQualifiedShard() { + assertNull(UniversalFlagConfigParser.ShardAdapter.FACTORY.create( + Shard.class, + Collections.singleton(QualifiedShard.class.getAnnotation(ShardQualifier.class)), + new Moshi.Builder().build())); + } + + @JsonQualifier + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + private @interface ShardQualifier {} + + @ShardQualifier + private static final class QualifiedShard {} + + @Test + void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { + assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); + } + + @Test + void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { + // Absent → Moshi leaves the boxed field null; the read site's Boolean.TRUE.equals(...) then + // resolves to the privacy-preserving default (consent-off). Either null-or-false is the + // documented invariant; assert the field never reads as true. + final ServerConfiguration configuration = parse(wrap(emptyConfig())); + assertNotNull(configuration); + assertFalse(Boolean.TRUE.equals(configuration.observeFullEvaluationData)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void observeFullEvaluationDataParsesExplicitValue(final boolean value) throws Exception { + final ServerConfiguration configuration = parse(wrap(configWithObserveFullEvaluationData(value))); + assertNotNull(configuration); + assertEquals(value, configuration.observeFullEvaluationData); + } + + @Test + void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig() throws Exception { + // An explicit null (or a wrong-typed value) for this field must not abort the whole UFC parse. + // A pod that starts after a malformed UFC has no last-known-good, so aborting would strand + // every flag on PROVIDER_NOT_READY (its default value). We fail closed on privacy (consent + // stays false) but preserve availability: flags parse and evaluate. + final ServerConfiguration configuration = parse(wrap(configWithRawObserveFullEvaluationData("null"))); + + assertNotNull(configuration); + assertFalse(configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + @Test + void observeFullEvaluationDataWrongTypedStringDefaultsToFalse() throws Exception { + // Moshi tolerates a stringified boolean like "true" via nullSafe/boxed handling: it either + // parses as null or throws locally and leaves the field null. Either way, downstream reads + // via Boolean.TRUE.equals(...) treat it as consent-off. The rest of the config must parse. + final ServerConfiguration configuration = parse(wrap(configWithRawObserveFullEvaluationData("\"true\""))); + + assertNotNull(configuration); + assertFalse(configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + @Test + void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception { + final ServerConfiguration configuration = parse(wrap(configWithRawObserveFullEvaluationData("1"))); + + assertNotNull(configuration); + assertFalse(configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + private static ServerConfiguration parse(final String json) throws Exception { + return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); + } + + private static String wrap(final String attributes) { + return "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + attributes + "}}"; + } + + private static String configWithObserveFullEvaluationData(final boolean value) { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"observeFullEvaluationData\":" + + value + + "," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } + + private static String configWithRawObserveFullEvaluationData(final String rawJsonValue) { + // Emit the field with a caller-controlled raw JSON value (null / "true" / 1 / ...) so we can + // assert the parser's tolerance of malformed shapes without going through configWith's + // boolean-typed helper. + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"observeFullEvaluationData\":" + + rawJsonValue + + "," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } + + private static String configWithFlags(final String... flags) { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{" + + String.join(",", flags) + + "}}"; + } + + private static String booleanFlag(final String key, final String allocations) { + return "\"" + + key + + "\":{" + + "\"key\":\"" + + key + + "\",\"enabled\":true,\"variationType\":\"BOOLEAN\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":true}}" + + allocations + + "}"; + } + + private static String allocation(final String key, final String rules) { + return ",\"allocations\":[{\"key\":\"" + + key + + "\"" + + (rules.isEmpty() ? "" : ",\"rules\":" + rules) + + ",\"splits\":[]}]"; + } + + private static String emptyConfig() { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java index 7d43c7a8b51..b733c90f256 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java @@ -18,294 +18,291 @@ class LRUExposureCacheTest { - @Test - void testAddingElements() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(1, cache.size()); - } - - @Test - void testAddingDuplicateEventsReturnsFalse() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - cache.add(event); - boolean duplicateAdded = cache.add(event); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = createEvent("flag", "subject", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag", "subject", "variant2", "allocation2"); - ExposureCache.Key key = new ExposureCache.Key(event1); - - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - ExposureCache.Value retrieved = cache.get(key); - - assertTrue(added1); - assertTrue(added2); - assertEquals(1, cache.size()); - assertEquals("variant2", retrieved.variant); - assertEquals("allocation2", retrieved.allocation); - } - - @Test - void testSerialIdAppearingEmitsANewExposure() { - assertEquals(2, emissions(null, 7)); - } - - @Test - void testSerialIdDisappearingEmitsANewExposure() { - assertEquals(2, emissions(7, null)); - } - - @Test - void testSerialIdChangingEmitsANewExposure() { - assertEquals(2, emissions(7, 8)); - } - - @Test - void testSerialIdCycleEmitsAnExposurePerTransition() { - assertEquals(3, emissions(7, 8, 7)); - } - - @Test - void testUnchangedSerialIdIsDeduplicated() { - assertEquals(1, emissions(7, 7)); - } - - @Test - void testUnchangedAbsentSerialIdIsDeduplicated() { - assertEquals(1, emissions(null, null)); - } - - @Test - void testSerialIdZeroIsDistinguishedFromAbsent() { - assertEquals(2, emissions(null, 0)); - assertEquals(2, emissions(0, null)); - } - - @Test - void testSerialIdIsRetainedOnTheCachedValue() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation", 0); - - cache.add(event); - - assertEquals(Integer.valueOf(0), cache.get(new ExposureCache.Key(event)).serialId); - } - - @Test - void testLruEvictionWhenCapacityExceeded() { - LRUExposureCache cache = new LRUExposureCache(2); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key3 = new ExposureCache.Key(event3); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - - assertEquals(2, cache.size()); - assertNull(cache.get(key1)); // event1 should be evicted - assertNotNull(cache.get(key3)); // event3 should be present - assertEquals("variant3", cache.get(key3).variant); - assertEquals("allocation3", cache.get(key3).allocation); - } - - @Test - void testSingleCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(1); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - - cache.add(event1); - cache.add(event2); - - assertEquals(1, cache.size()); - } - - @Test - void testZeroCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(0); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(0, cache.size()); - } - - @Test - void testEmptyCacheSize() { - LRUExposureCache cache = new LRUExposureCache(5); - - assertEquals(0, cache.size()); - } - - @Test - void testMultipleAdditionsWithSameFlagDifferentSubjects() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag", "subject" + index, "variant", "allocation")); + @Test + void testAddingElements() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + boolean added = cache.add(event); + + assertTrue(added); + assertEquals(1, cache.size()); + } + + @Test + void testAddingDuplicateEventsReturnsFalse() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + cache.add(event); + boolean duplicateAdded = cache.add(event); + + assertFalse(duplicateAdded); + assertEquals(1, cache.size()); + } + + @Test + void testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event1 = createEvent("flag", "subject", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag", "subject", "variant2", "allocation2"); + ExposureCache.Key key = new ExposureCache.Key(event1); + + boolean added1 = cache.add(event1); + boolean added2 = cache.add(event2); + ExposureCache.Value retrieved = cache.get(key); + + assertTrue(added1); + assertTrue(added2); + assertEquals(1, cache.size()); + assertEquals("variant2", retrieved.variant); + assertEquals("allocation2", retrieved.allocation); + } + + @Test + void testSerialIdAppearingEmitsANewExposure() { + assertEquals(2, emissions(null, 7)); + } + + @Test + void testSerialIdDisappearingEmitsANewExposure() { + assertEquals(2, emissions(7, null)); + } + + @Test + void testSerialIdChangingEmitsANewExposure() { + assertEquals(2, emissions(7, 8)); + } + + @Test + void testSerialIdCycleEmitsAnExposurePerTransition() { + assertEquals(3, emissions(7, 8, 7)); + } + + @Test + void testUnchangedSerialIdIsDeduplicated() { + assertEquals(1, emissions(7, 7)); + } + + @Test + void testUnchangedAbsentSerialIdIsDeduplicated() { + assertEquals(1, emissions(null, null)); + } + + @Test + void testSerialIdZeroIsDistinguishedFromAbsent() { + assertEquals(2, emissions(null, 0)); + assertEquals(2, emissions(0, null)); + } + + @Test + void testSerialIdIsRetainedOnTheCachedValue() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation", 0); + + cache.add(event); + + assertEquals(Integer.valueOf(0), cache.get(new ExposureCache.Key(event)).serialId); } - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); + @Test + void testLruEvictionWhenCapacityExceeded() { + LRUExposureCache cache = new LRUExposureCache(2); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key3 = new ExposureCache.Key(event3); + + cache.add(event1); + cache.add(event2); + cache.add(event3); + + assertEquals(2, cache.size()); + assertNull(cache.get(key1)); // event1 should be evicted + assertNotNull(cache.get(key3)); // event3 should be present + assertEquals("variant3", cache.get(key3).variant); + assertEquals("allocation3", cache.get(key3).allocation); } - assertEquals(5, cache.size()); - } + @Test + void testSingleCapacityCache() { + LRUExposureCache cache = new LRUExposureCache(1); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + + cache.add(event1); + cache.add(event2); + + assertEquals(1, cache.size()); + } + + @Test + void testZeroCapacityCache() { + LRUExposureCache cache = new LRUExposureCache(0); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + boolean added = cache.add(event); + + assertTrue(added); + assertEquals(0, cache.size()); + } + + @Test + void testEmptyCacheSize() { + LRUExposureCache cache = new LRUExposureCache(5); + + assertEquals(0, cache.size()); + } + + @Test + void testMultipleAdditionsWithSameFlagDifferentSubjects() { + LRUExposureCache cache = new LRUExposureCache(10); + List events = new ArrayList<>(); + for (int index = 0; index < 5; index++) { + events.add(createEvent("flag", "subject" + index, "variant", "allocation")); + } + + for (ExposureEvent event : events) { + assertTrue(cache.add(event)); + } + + assertEquals(5, cache.size()); + } + + @Test + void testMultipleAdditionsWithSameSubjectDifferentFlags() { + LRUExposureCache cache = new LRUExposureCache(10); + List events = new ArrayList<>(); + for (int index = 0; index < 5; index++) { + events.add(createEvent("flag" + index, "subject", "variant", "allocation")); + } + + for (ExposureEvent event : events) { + assertTrue(cache.add(event)); + } + + assertEquals(5, cache.size()); + } + + @Test + void testKeyEqualityWithNullValues() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event1 = new ExposureEvent( + System.currentTimeMillis(), + new Allocation("allocation"), + new Flag(null), + new Variant("variant"), + new Subject(null, emptyMap())); + ExposureEvent event2 = new ExposureEvent( + System.currentTimeMillis(), + new Allocation("allocation"), + new Flag(null), + new Variant("variant"), + new Subject(null, emptyMap())); + + cache.add(event1); + boolean duplicateAdded = cache.add(event2); + + assertFalse(duplicateAdded); + assertEquals(1, cache.size()); + } + + @Test + void testUpdatingExistingKeyMaintainsLruPosition() { + LRUExposureCache cache = new LRUExposureCache(3); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + ExposureEvent event1Updated = createEvent("flag1", "subject1", "variant2", "allocation2"); + ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key2 = new ExposureCache.Key(event2); + ExposureCache.Key key4 = new ExposureCache.Key(event4); + + cache.add(event1); + cache.add(event2); + cache.add(event3); + cache.add(event1Updated); // Updates event1, moves to most recent + cache.add(event4); // Should evict event2, not event1 + + assertEquals(3, cache.size()); + assertNotNull(cache.get(key1)); // event1 should be updated and present + assertEquals("variant2", cache.get(key1).variant); // verify it was updated + assertEquals("allocation2", cache.get(key1).allocation); + assertNull(cache.get(key2)); // event2 should be evicted + assertNotNull(cache.get(key4)); // event4 should be present + assertEquals("variant4", cache.get(key4).variant); + } + + @Test + void testDuplicateExposureKeepsSubjectHotInLruOrder() { + LRUExposureCache cache = new LRUExposureCache(3); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + // same key + same details as event1: will go through the "duplicate" path + ExposureEvent event1Duplicate = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); + + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key2 = new ExposureCache.Key(event2); + ExposureCache.Key key4 = new ExposureCache.Key(event4); + + // Fill cache + boolean added1 = cache.add(event1); + boolean added2 = cache.add(event2); + boolean added3 = cache.add(event3); + + // Duplicate exposure for subject1: should *not* change size, but *should* bump recency + boolean duplicateAdded = cache.add(event1Duplicate); + + // Now push over capacity: the least recently used *non-hot* entry (event2) should be evicted + boolean added4 = cache.add(event4); + + assertTrue(added1); + assertTrue(added2); + assertTrue(added3); + assertFalse(duplicateAdded); // dedup correctly + assertTrue(added4); + + assertEquals(3, cache.size()); + + assertNotNull(cache.get(key1)); // hot subject1 should still be present + assertNull(cache.get(key2)); // subject2 should be evicted + assertNotNull(cache.get(key4)); // newest subject4 should be present + + assertEquals("variant1", cache.get(key1).variant); + assertEquals("allocation1", cache.get(key1).allocation); + } - @Test - void testMultipleAdditionsWithSameSubjectDifferentFlags() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag" + index, "subject", "variant", "allocation")); + private static ExposureEvent createEvent(String flag, String subject, String variant, String allocation) { + return createEvent(flag, subject, variant, allocation, null); } - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); + private static ExposureEvent createEvent( + String flag, String subject, String variant, String allocation, Integer serialId) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation(allocation), + new Flag(flag), + new Variant(variant), + new Subject(subject, emptyMap()), + serialId); } - assertEquals(5, cache.size()); - } - - @Test - void testKeyEqualityWithNullValues() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap())); - ExposureEvent event2 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap())); - - cache.add(event1); - boolean duplicateAdded = cache.add(event2); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testUpdatingExistingKeyMaintainsLruPosition() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureEvent event1Updated = createEvent("flag1", "subject1", "variant2", "allocation2"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - cache.add(event1Updated); // Updates event1, moves to most recent - cache.add(event4); // Should evict event2, not event1 - - assertEquals(3, cache.size()); - assertNotNull(cache.get(key1)); // event1 should be updated and present - assertEquals("variant2", cache.get(key1).variant); // verify it was updated - assertEquals("allocation2", cache.get(key1).allocation); - assertNull(cache.get(key2)); // event2 should be evicted - assertNotNull(cache.get(key4)); // event4 should be present - assertEquals("variant4", cache.get(key4).variant); - } - - @Test - void testDuplicateExposureKeepsSubjectHotInLruOrder() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - // same key + same details as event1: will go through the "duplicate" path - ExposureEvent event1Duplicate = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - // Fill cache - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - boolean added3 = cache.add(event3); - - // Duplicate exposure for subject1: should *not* change size, but *should* bump recency - boolean duplicateAdded = cache.add(event1Duplicate); - - // Now push over capacity: the least recently used *non-hot* entry (event2) should be evicted - boolean added4 = cache.add(event4); - - assertTrue(added1); - assertTrue(added2); - assertTrue(added3); - assertFalse(duplicateAdded); // dedup correctly - assertTrue(added4); - - assertEquals(3, cache.size()); - - assertNotNull(cache.get(key1)); // hot subject1 should still be present - assertNull(cache.get(key2)); // subject2 should be evicted - assertNotNull(cache.get(key4)); // newest subject4 should be present - - assertEquals("variant1", cache.get(key1).variant); - assertEquals("allocation1", cache.get(key1).allocation); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation) { - return createEvent(flag, subject, variant, allocation, null); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation, Integer serialId) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation(allocation), - new Flag(flag), - new Variant(variant), - new Subject(subject, emptyMap()), - serialId); - } - - /** - * Number of exposures a run of same-flag, same-subject evaluations emits: the cache reports true - * only for the ones it does not suppress as duplicates. - */ - private static int emissions(final Integer... serialIds) { - LRUExposureCache cache = new LRUExposureCache(5); - int emitted = 0; - for (Integer serialId : serialIds) { - if (cache.add(createEvent("flag", "subject", "variant", "allocation", serialId))) { - emitted++; - } + /** + * Number of exposures a run of same-flag, same-subject evaluations emits: the cache reports true + * only for the ones it does not suppress as duplicates. + */ + private static int emissions(final Integer... serialIds) { + LRUExposureCache cache = new LRUExposureCache(5); + int emitted = 0; + for (Integer serialId : serialIds) { + if (cache.add(createEvent("flag", "subject", "variant", "allocation", serialId))) { + emitted++; + } + } + return emitted; } - return emitted; - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index f37599feeb0..03250d38262 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -54,350 +54,333 @@ @ExtendWith(MockitoExtension.class) class RemoteConfigServiceImplTest { - @Mock private FeatureFlaggingGateway.ConfigListener listener; - @Captor private ArgumentCaptor deserializerCaptor; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - } - - @Test - void testNewConfigReceived() throws Exception { - final ConfigurationPoller poller = mock(ConfigurationPoller.class); - final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); - when(sco.configurationPoller(any(Config.class))).thenReturn(poller); - FeatureFlaggingGateway.addConfigListener(listener); - final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(sco, Config.get()); - - service.init(); - - verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), deserializerCaptor.capture(), eq(service)); - - final ServerConfiguration config = deserializer().deserialize(emptyConfig().getBytes(UTF_8)); - service.accept("test", config, mock(PollingRateHinter.class)); - - verify(listener).accept(any(ServerConfiguration.class)); - - service.close(); - - verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).removeListeners(Product.FFE_FLAGS); - } - - @Test - void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("malformed-allocations.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void parsesSplitSerialId() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("340132")); - - assertNotNull(config); - assertEquals(Integer.valueOf(340132), serialIdOf(config)); - } - - @Test - void parsesSplitSerialIdZero() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("0")); - - assertNotNull(config); - assertEquals(Integer.valueOf(0), serialIdOf(config)); - } - - @Test - void parsesAbsentSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(null)); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void parsesNullSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("null")); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { - final ServerConfiguration config = deserialize(configWithSiblingSerialIds("true")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertEquals("invalid_flag", config.invalidFlags.get("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - /** - * Records how leniently the per-flag value reader coerces a serial id, so a future change to the - * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no - * validation of its own; what matters is that a bad one never rejects the sibling flag. - */ - @ParameterizedTest - @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) - void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) - throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); - - assertNotNull(config); - assertEquals(Integer.valueOf(expected), serialIdOf(config)); - } - - private static Integer serialIdOf(final ServerConfiguration config) { - return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; - } - - private static String configWithSerialId(final String serialIdJson) throws IOException { - return withSerialId(resource("serial-id.json"), serialIdJson); - } - - /** A malformed serial id must bind to its own flag and leave the sibling flag intact. */ - private static String configWithSiblingSerialIds(final String malformedSerialIdJson) - throws IOException { - return withSerialId(resource("sibling-serial-ids.json"), malformedSerialIdJson); - } - - /** Inserts the raw wire value so the parser sees its original JSON type; null omits the key. */ - private static String withSerialId(final String json, final String serialIdJson) { - return serialIdJson == null - ? json.replace("\"serialId\": \"${serialId}\",", "") - : json.replace("\"${serialId}\"", serialIdJson); - } - - @Test - void ignoresUnknownTopLevelFields() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-top-level-fields.json")); - - assertNotNull(config); - assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); - assertEquals("SERVER", config.format); - assertNotNull(config.environment); - assertEquals("Test", config.environment.name); - assertTrue(config.flags.isEmpty()); - } - - @Test - void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Exception { - final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); - - final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); - assertEquals(Date.class, Allocation.class.getField("startAt").getType()); - assertEquals(Date.class, Allocation.class.getField("endAt").getType()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123Z"), allocation.startAt.toInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987Z"), allocation.endAt.toInstant()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAtInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAtInstant()); - } - - @Test - void rejectsTrailingJson() { - assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); - } - - @Test - void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-operator.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("operator-grease-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterFactoryOnlyCreatesFlagMapAdapterForFlagMapType() { - final Moshi moshi = moshi(); - final Type flagsType = Types.newParameterizedType(Map.class, String.class, Flag.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(flagsType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FlagMapAdapter); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create( - flagsType, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterFactoryOnlyCreatesAdapterForUnannotatedBoxedBoolean() { - final Moshi moshi = moshi(); - - final JsonAdapter adapter = - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.LenientBooleanAdapter); - // Primitive boolean keeps Moshi's strict adapter so mandatory fields still reject bad values. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - boolean.class, emptySet(), moshi)); - // A qualified Boolean belongs to whichever adapter declared the qualifier, not to this one. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterIsReadOnly() { - final UniversalFlagConfigParser.LenientBooleanAdapter adapter = - new UniversalFlagConfigParser.LenientBooleanAdapter(); - - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), true)); - } - - @Test - void allowsNullFlagMap() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flags.json")); - - assertNotNull(config); - assertNull(config.flags); - } - - @Test - void skipsNullFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flag.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("null-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterIsReadOnly() { - final UniversalFlagConfigParser.FlagMapAdapter adapter = - new UniversalFlagConfigParser.FlagMapAdapter(moshi().adapter(Flag.class)); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); - } - - @Test - void allocationAdapterFactoryOnlyCreatesAllocationAdapterForAllocationType() { - final Moshi moshi = moshi(); - - final JsonAdapter adapter = - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - Allocation.class, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.AllocationAdapter); - assertNull( - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - Allocation.class, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void allocationAdapterHandlesNullAndIsReadOnly() throws Exception { - final UniversalFlagConfigParser.AllocationAdapter adapter = - new UniversalFlagConfigParser.AllocationAdapter( - moshi().adapter(UniversalFlagConfigParser.AllocationJson.class)); - - assertNull(adapter.fromJson("null")); - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), null)); - } - - @TableTest({ - "scenario | value | expectedInstant ", - "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", - "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", - "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", - "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", - "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", - "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", - "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", - "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", - "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", - "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", - "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", - "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", - "date only | '2023-01-01' | ", - "invalid | 'invalid-date' | ", - "empty string | '' | ", - "not a date | 'not-a-date' | ", - "slash date | '2023/01/01T00:00:00Z' | ", - "null | | " - }) - void testInstantParsing(final String value, final String expectedInstant) throws Exception { - final JsonReader reader = mock(JsonReader.class); - if (value == null) { - when(reader.peek()).thenReturn(JsonReader.Token.NULL); - when(reader.nextNull()).thenReturn(null); - } else { - when(reader.peek()).thenReturn(JsonReader.Token.STRING); - when(reader.nextString()).thenReturn(value); - } - final UniversalFlagConfigParser.InstantAdapter adapter = - new UniversalFlagConfigParser.InstantAdapter(); - - final Instant parsed = adapter.fromJson(reader); - if (value == null) { - verify(reader).nextNull(); - } - if (expectedInstant == null) { - assertNull(parsed); - } else { - assertNotNull(parsed); - assertEquals(expectedInstant, parsed.toString()); - } - } - - @Test - void testParsingOnlyAdapter() { - final UniversalFlagConfigParser.InstantAdapter adapter = - new UniversalFlagConfigParser.InstantAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); - } - - @SuppressWarnings("unchecked") - private ConfigurationDeserializer deserializer() { - return deserializerCaptor.getValue(); - } - - private static ServerConfiguration deserialize(final String json) throws Exception { - return UniversalFlagConfigParser.INSTANCE.deserialize(json.getBytes(UTF_8)); - } - - private static Moshi moshi() { - return new Moshi.Builder() - .add(Instant.class, new UniversalFlagConfigParser.InstantAdapter()) - .add(UniversalFlagConfigParser.AllocationAdapter.FACTORY) - .build(); - } - - private static String emptyConfig() throws IOException { - return resource("empty-config.json"); - } - - private static String resource(final String name) throws IOException { - try (final InputStream stream = - RemoteConfigServiceImplTest.class.getResourceAsStream("/remote-config/" + name)) { - assertNotNull(stream, "Missing remote-config fixture: " + name); - return new Buffer().readFrom(stream).readUtf8(); - } - } + @Mock + private FeatureFlaggingGateway.ConfigListener listener; + + @Captor + private ArgumentCaptor deserializerCaptor; + + @AfterEach + void cleanup() { + FeatureFlaggingGateway.removeConfigListener(listener); + } + + @Test + void testNewConfigReceived() throws Exception { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + when(sco.configurationPoller(any(Config.class))).thenReturn(poller); + FeatureFlaggingGateway.addConfigListener(listener); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(sco, Config.get()); + + service.init(); + + verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller).addListener(eq(Product.FFE_FLAGS), deserializerCaptor.capture(), eq(service)); + + final ServerConfiguration config = + deserializer().deserialize(emptyConfig().getBytes(UTF_8)); + service.accept("test", config, mock(PollingRateHinter.class)); + + verify(listener).accept(any(ServerConfiguration.class)); + + service.close(); + + verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller).removeListeners(Product.FFE_FLAGS); + } + + @Test + void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("malformed-allocations.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("malformed-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void parsesSplitSerialId() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("340132")); + + assertNotNull(config); + assertEquals(Integer.valueOf(340132), serialIdOf(config)); + } + + @Test + void parsesSplitSerialIdZero() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("0")); + + assertNotNull(config); + assertEquals(Integer.valueOf(0), serialIdOf(config)); + } + + @Test + void parsesAbsentSplitSerialIdAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId(null)); + + assertNotNull(config); + assertNull(serialIdOf(config)); + } + + @Test + void parsesNullSplitSerialIdAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("null")); + + assertNotNull(config); + assertNull(serialIdOf(config)); + } + + @Test + void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { + final ServerConfiguration config = deserialize(configWithSiblingSerialIds("true")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("malformed-flag")); + assertEquals("invalid_flag", config.invalidFlags.get("malformed-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + /** + * Records how leniently the per-flag value reader coerces a serial id, so a future change to the + * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no + * validation of its own; what matters is that a bad one never rejects the sibling flag. + */ + @ParameterizedTest + @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) + void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); + + assertNotNull(config); + assertEquals(Integer.valueOf(expected), serialIdOf(config)); + } + + private static Integer serialIdOf(final ServerConfiguration config) { + return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; + } + + private static String configWithSerialId(final String serialIdJson) throws IOException { + return withSerialId(resource("serial-id.json"), serialIdJson); + } + + /** A malformed serial id must bind to its own flag and leave the sibling flag intact. */ + private static String configWithSiblingSerialIds(final String malformedSerialIdJson) throws IOException { + return withSerialId(resource("sibling-serial-ids.json"), malformedSerialIdJson); + } + + /** Inserts the raw wire value so the parser sees its original JSON type; null omits the key. */ + private static String withSerialId(final String json, final String serialIdJson) { + return serialIdJson == null + ? json.replace("\"serialId\": \"${serialId}\",", "") + : json.replace("\"${serialId}\"", serialIdJson); + } + + @Test + void ignoresUnknownTopLevelFields() throws Exception { + final ServerConfiguration config = deserialize(resource("unknown-top-level-fields.json")); + + assertNotNull(config); + assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); + assertEquals("SERVER", config.format); + assertNotNull(config.environment); + assertEquals("Test", config.environment.name); + assertTrue(config.flags.isEmpty()); + } + + @Test + void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Exception { + final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); + + final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); + assertEquals(Date.class, Allocation.class.getField("startAt").getType()); + assertEquals(Date.class, Allocation.class.getField("endAt").getType()); + assertEquals(Instant.parse("2023-01-01T00:00:00.123Z"), allocation.startAt.toInstant()); + assertEquals(Instant.parse("2023-01-02T00:00:00.987Z"), allocation.endAt.toInstant()); + assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAtInstant()); + assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAtInstant()); + } + + @Test + void rejectsTrailingJson() { + assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); + } + + @Test + void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("unknown-operator.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("operator-grease-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void flagMapAdapterFactoryOnlyCreatesFlagMapAdapterForFlagMapType() { + final Moshi moshi = moshi(); + final Type flagsType = Types.newParameterizedType(Map.class, String.class, Flag.class); + + final JsonAdapter adapter = + UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(flagsType, emptySet(), moshi); + + assertNotNull(adapter); + assertTrue(adapter instanceof UniversalFlagConfigParser.FlagMapAdapter); + assertNull(UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(String.class, emptySet(), moshi)); + assertNull(UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create( + flagsType, singleton(mock(Annotation.class)), moshi)); + } + + @Test + void lenientBooleanAdapterFactoryOnlyCreatesAdapterForUnannotatedBoxedBoolean() { + final Moshi moshi = moshi(); + + final JsonAdapter adapter = + UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create(Boolean.class, emptySet(), moshi); + + assertNotNull(adapter); + assertTrue(adapter instanceof UniversalFlagConfigParser.LenientBooleanAdapter); + // Primitive boolean keeps Moshi's strict adapter so mandatory fields still reject bad values. + assertNull(UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create(boolean.class, emptySet(), moshi)); + // A qualified Boolean belongs to whichever adapter declared the qualifier, not to this one. + assertNull(UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( + Boolean.class, singleton(mock(Annotation.class)), moshi)); + } + + @Test + void lenientBooleanAdapterIsReadOnly() { + final UniversalFlagConfigParser.LenientBooleanAdapter adapter = + new UniversalFlagConfigParser.LenientBooleanAdapter(); + + assertThrows(UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), true)); + } + + @Test + void allowsNullFlagMap() throws Exception { + final ServerConfiguration config = deserialize(resource("null-flags.json")); + + assertNotNull(config); + assertNull(config.flags); + } + + @Test + void skipsNullFlagAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("null-flag.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("null-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void flagMapAdapterIsReadOnly() { + final UniversalFlagConfigParser.FlagMapAdapter adapter = + new UniversalFlagConfigParser.FlagMapAdapter(moshi().adapter(Flag.class)); + + assertThrows(UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); + } + + @Test + void allocationAdapterFactoryOnlyCreatesAllocationAdapterForAllocationType() { + final Moshi moshi = moshi(); + + final JsonAdapter adapter = + UniversalFlagConfigParser.AllocationAdapter.FACTORY.create(Allocation.class, emptySet(), moshi); + + assertNotNull(adapter); + assertTrue(adapter instanceof UniversalFlagConfigParser.AllocationAdapter); + assertNull(UniversalFlagConfigParser.AllocationAdapter.FACTORY.create(String.class, emptySet(), moshi)); + assertNull(UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( + Allocation.class, singleton(mock(Annotation.class)), moshi)); + } + + @Test + void allocationAdapterHandlesNullAndIsReadOnly() throws Exception { + final UniversalFlagConfigParser.AllocationAdapter adapter = new UniversalFlagConfigParser.AllocationAdapter( + moshi().adapter(UniversalFlagConfigParser.AllocationJson.class)); + + assertNull(adapter.fromJson("null")); + assertThrows(UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), null)); + } + + @TableTest({ + "scenario | value | expectedInstant ", + "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", + "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", + "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", + "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", + "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", + "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", + "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", + "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", + "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", + "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", + "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", + "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", + "date only | '2023-01-01' | ", + "invalid | 'invalid-date' | ", + "empty string | '' | ", + "not a date | 'not-a-date' | ", + "slash date | '2023/01/01T00:00:00Z' | ", + "null | | " + }) + void testInstantParsing(final String value, final String expectedInstant) throws Exception { + final JsonReader reader = mock(JsonReader.class); + if (value == null) { + when(reader.peek()).thenReturn(JsonReader.Token.NULL); + when(reader.nextNull()).thenReturn(null); + } else { + when(reader.peek()).thenReturn(JsonReader.Token.STRING); + when(reader.nextString()).thenReturn(value); + } + final UniversalFlagConfigParser.InstantAdapter adapter = new UniversalFlagConfigParser.InstantAdapter(); + + final Instant parsed = adapter.fromJson(reader); + if (value == null) { + verify(reader).nextNull(); + } + if (expectedInstant == null) { + assertNull(parsed); + } else { + assertNotNull(parsed); + assertEquals(expectedInstant, parsed.toString()); + } + } + + @Test + void testParsingOnlyAdapter() { + final UniversalFlagConfigParser.InstantAdapter adapter = new UniversalFlagConfigParser.InstantAdapter(); + + assertThrows(UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); + } + + @SuppressWarnings("unchecked") + private ConfigurationDeserializer deserializer() { + return deserializerCaptor.getValue(); + } + + private static ServerConfiguration deserialize(final String json) throws Exception { + return UniversalFlagConfigParser.INSTANCE.deserialize(json.getBytes(UTF_8)); + } + + private static Moshi moshi() { + return new Moshi.Builder() + .add(Instant.class, new UniversalFlagConfigParser.InstantAdapter()) + .add(UniversalFlagConfigParser.AllocationAdapter.FACTORY) + .build(); + } + + private static String emptyConfig() throws IOException { + return resource("empty-config.json"); + } + + private static String resource(final String name) throws IOException { + try (final InputStream stream = + RemoteConfigServiceImplTest.class.getResourceAsStream("/remote-config/" + name)) { + assertNotNull(stream, "Missing remote-config fixture: " + name); + return new Buffer().readFrom(stream).readUtf8(); + } + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java index ce6df50cfc0..d8318d3eaa7 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java @@ -18,230 +18,218 @@ */ class SpanEnrichmentAccumulatorTest { - // Test-only decode oracle for the ULEB128 delta-varint codec — the production code only encodes. - private static SortedSet decodeDeltaVarint(final String encoded) { - final SortedSet result = new TreeSet<>(); - if (encoded == null || encoded.isEmpty()) { - return result; - } - final byte[] bytes = Base64.getDecoder().decode(encoded); - int previous = 0; - int index = 0; - while (index < bytes.length) { - long value = 0; - int shift = 0; - while (true) { - final byte b = bytes[index++]; - value |= ((long) (b & 0x7F)) << shift; - if ((b & 0x80) == 0) { - break; + // Test-only decode oracle for the ULEB128 delta-varint codec — the production code only encodes. + private static SortedSet decodeDeltaVarint(final String encoded) { + final SortedSet result = new TreeSet<>(); + if (encoded == null || encoded.isEmpty()) { + return result; + } + final byte[] bytes = Base64.getDecoder().decode(encoded); + int previous = 0; + int index = 0; + while (index < bytes.length) { + long value = 0; + int shift = 0; + while (true) { + final byte b = bytes[index++]; + value |= ((long) (b & 0x7F)) << shift; + if ((b & 0x80) == 0) { + break; + } + shift += 7; + } + previous += (int) value; + result.add(previous); } - shift += 7; - } - previous += (int) value; - result.add(previous); + return result; } - return result; - } - - @Test - void codecGoldenVectorAndRoundTrip() { - final SortedSet ids = new TreeSet<>(); - ids.add(100); - ids.add(108); - ids.add(128); - ids.add(130); - final String encoded = ULeb128Encoder.encodeDeltaVarint(ids); - assertEquals("ZAgUAg==", encoded, "golden vector must match the frozen Node contract"); - assertEquals(ids, decodeDeltaVarint(encoded)); - assertEquals("", ULeb128Encoder.encodeDeltaVarint(Collections.emptySet())); - final SortedSet withDup = new TreeSet<>(ids); - withDup.add(100); - assertEquals( - encoded, ULeb128Encoder.encodeDeltaVarint(withDup), "duplicates do not change bytes"); - } - - @Test - void flagsEncTagFromAccumulatedSerialIds() { - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - acc.addSerialId(100); - acc.addSerialId(108); - acc.addSerialId(128); - acc.addSerialId(130); - acc.addSerialId(100); // dedupe - assertTrue(acc.hasData()); - assertEquals("ZAgUAg==", acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_FLAGS_ENC)); - } - - @Test - void max200SerialIdsEnforced() { - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - for (int i = 0; i < 300; i++) { - acc.addSerialId(i); + + @Test + void codecGoldenVectorAndRoundTrip() { + final SortedSet ids = new TreeSet<>(); + ids.add(100); + ids.add(108); + ids.add(128); + ids.add(130); + final String encoded = ULeb128Encoder.encodeDeltaVarint(ids); + assertEquals("ZAgUAg==", encoded, "golden vector must match the frozen Node contract"); + assertEquals(ids, decodeDeltaVarint(encoded)); + assertEquals("", ULeb128Encoder.encodeDeltaVarint(Collections.emptySet())); + final SortedSet withDup = new TreeSet<>(ids); + withDup.add(100); + assertEquals(encoded, ULeb128Encoder.encodeDeltaVarint(withDup), "duplicates do not change bytes"); } - assertEquals( - SpanEnrichmentAccumulator.MAX_SERIAL_IDS, - acc.serialIdsView().size(), - "serial ids must be capped at 200"); - } - - @Test - void subjectAndPerSubjectExperimentCaps() { - // per-subject experiment cap: 20 max - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - for (int i = 0; i < 25; i++) { - acc.addSubject("subjectX", i); + + @Test + void flagsEncTagFromAccumulatedSerialIds() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + acc.addSerialId(100); + acc.addSerialId(108); + acc.addSerialId(128); + acc.addSerialId(130); + acc.addSerialId(100); // dedupe + assertTrue(acc.hasData()); + assertEquals("ZAgUAg==", acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_FLAGS_ENC)); + } + + @Test + void max200SerialIdsEnforced() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 300; i++) { + acc.addSerialId(i); + } + assertEquals( + SpanEnrichmentAccumulator.MAX_SERIAL_IDS, + acc.serialIdsView().size(), + "serial ids must be capped at 200"); } - final SortedSet decoded = - decodeDeltaVarint( - acc.toSpanTags() + + @Test + void subjectAndPerSubjectExperimentCaps() { + // per-subject experiment cap: 20 max + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 25; i++) { + acc.addSubject("subjectX", i); + } + final SortedSet decoded = decodeDeltaVarint(acc.toSpanTags() .get(SpanEnrichmentAccumulator.TAG_SUBJECTS_ENC) .replaceAll("^\\{\"[a-f0-9]+\":\"", "") .replaceAll("\"\\}$", "")); - assertEquals(SpanEnrichmentAccumulator.MAX_EXPERIMENTS_PER_SUBJECT, decoded.size()); + assertEquals(SpanEnrichmentAccumulator.MAX_EXPERIMENTS_PER_SUBJECT, decoded.size()); + + // subject cap: 10 max distinct subjects + final SpanEnrichmentAccumulator acc2 = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 15; i++) { + acc2.addSubject("subject-" + i, i); + } + assertEquals(SpanEnrichmentAccumulator.MAX_SUBJECTS, acc2.subjectCount()); + } + + @Test + void runtimeDefaultJsonStringifyAndTruncation() { + // native object -> JSON, not toString + assertEquals("{\"a\":\"b\"}", SpanEnrichmentAccumulator.stringifyDefault(Collections.singletonMap("a", "b"))); + // scalar string -> as-is + assertEquals("hello", SpanEnrichmentAccumulator.stringifyDefault("hello")); + // null -> "null" + assertEquals("null", SpanEnrichmentAccumulator.stringifyDefault(null)); + // native list -> JSON array + assertEquals( + "[\"a\",2,true]", SpanEnrichmentAccumulator.stringifyDefault(java.util.Arrays.asList("a", 2, true))); + + // 64-char truncation + final StringBuilder longValue = new StringBuilder(); + for (int i = 0; i < 100; i++) { + longValue.append('x'); + } + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + acc.addDefault("flag", longValue.toString()); + final String tag = acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_RUNTIME_DEFAULTS); + final String expectedValue = longValue.substring(0, SpanEnrichmentAccumulator.MAX_DEFAULT_VALUE_LENGTH); + assertEquals("{\"flag\":\"" + expectedValue + "\"}", tag); + + // first-wins + acc.addDefault("flag", "second"); + assertEquals(1, acc.defaultCount()); + } + + @Test + void jsonUsesPlatformWriterEscaping() { + // The platform JsonWriter escapes '/' as backslash-slash and non-ASCII as a backslash-u escape. + // That differs from the JS reference bytes but is round-trip-equivalent: all consumers + // JSON-parse these tags (backend Jackson, system-tests json.loads), so byte-parity is not + // required. '/' matters in practice because ffe_subjects_enc values are base64 (may contain + // '/'). + assertEquals("{\"h\":\"a\\/b\"}", SpanEnrichmentAccumulator.toJsonObject(Collections.singletonMap("h", "a/b"))); + assertEquals( + "{\"k\":\"caf\\u00E9\"}", + SpanEnrichmentAccumulator.toJsonObject(Collections.singletonMap("k", "café"))); + // nested structured runtime default goes through the same writer + assertEquals( + "{\"a\":\"x\\/y\"}", SpanEnrichmentAccumulator.stringifyDefault(Collections.singletonMap("a", "x/y"))); + } - // subject cap: 10 max distinct subjects - final SpanEnrichmentAccumulator acc2 = new SpanEnrichmentAccumulator(); - for (int i = 0; i < 15; i++) { - acc2.addSubject("subject-" + i, i); + @Test + void maxDefaultsEnforced() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 10; i++) { + acc.addDefault("flag-" + i, "v"); + } + assertEquals(SpanEnrichmentAccumulator.MAX_DEFAULTS, acc.defaultCount()); } - assertEquals(SpanEnrichmentAccumulator.MAX_SUBJECTS, acc2.subjectCount()); - } - - @Test - void runtimeDefaultJsonStringifyAndTruncation() { - // native object -> JSON, not toString - assertEquals( - "{\"a\":\"b\"}", - SpanEnrichmentAccumulator.stringifyDefault(Collections.singletonMap("a", "b"))); - // scalar string -> as-is - assertEquals("hello", SpanEnrichmentAccumulator.stringifyDefault("hello")); - // null -> "null" - assertEquals("null", SpanEnrichmentAccumulator.stringifyDefault(null)); - // native list -> JSON array - assertEquals( - "[\"a\",2,true]", - SpanEnrichmentAccumulator.stringifyDefault(java.util.Arrays.asList("a", 2, true))); - - // 64-char truncation - final StringBuilder longValue = new StringBuilder(); - for (int i = 0; i < 100; i++) { - longValue.append('x'); + + @Test + void encoderNullAndNonSortedInput() { + assertEquals("", ULeb128Encoder.encodeDeltaVarint(null), "null → empty string"); + // A non-SortedSet input must still encode identically (it is sorted internally). + final java.util.Set unsorted = new java.util.HashSet<>(java.util.Arrays.asList(130, 100, 128, 108)); + assertEquals("ZAgUAg==", ULeb128Encoder.encodeDeltaVarint(unsorted)); + } + + @Test + void serialIdDedupeAtCap() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + for (int i = 0; i < SpanEnrichmentAccumulator.MAX_SERIAL_IDS; i++) { + acc.addSerialId(i); + } + acc.addSerialId(0); // already present + at cap → no-op, not dropped as "new" + acc.addSerialId(9999); // new + at cap → dropped + assertEquals( + SpanEnrichmentAccumulator.MAX_SERIAL_IDS, acc.serialIdsView().size()); + assertTrue(acc.serialIdsView().contains(0)); + assertFalse(acc.serialIdsView().contains(9999)); } - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - acc.addDefault("flag", longValue.toString()); - final String tag = acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_RUNTIME_DEFAULTS); - final String expectedValue = - longValue.substring(0, SpanEnrichmentAccumulator.MAX_DEFAULT_VALUE_LENGTH); - assertEquals("{\"flag\":\"" + expectedValue + "\"}", tag); - - // first-wins - acc.addDefault("flag", "second"); - assertEquals(1, acc.defaultCount()); - } - - @Test - void jsonUsesPlatformWriterEscaping() { - // The platform JsonWriter escapes '/' as backslash-slash and non-ASCII as a backslash-u escape. - // That differs from the JS reference bytes but is round-trip-equivalent: all consumers - // JSON-parse these tags (backend Jackson, system-tests json.loads), so byte-parity is not - // required. '/' matters in practice because ffe_subjects_enc values are base64 (may contain - // '/'). - assertEquals( - "{\"h\":\"a\\/b\"}", - SpanEnrichmentAccumulator.toJsonObject(Collections.singletonMap("h", "a/b"))); - assertEquals( - "{\"k\":\"caf\\u00E9\"}", - SpanEnrichmentAccumulator.toJsonObject(Collections.singletonMap("k", "café"))); - // nested structured runtime default goes through the same writer - assertEquals( - "{\"a\":\"x\\/y\"}", - SpanEnrichmentAccumulator.stringifyDefault(Collections.singletonMap("a", "x/y"))); - } - - @Test - void maxDefaultsEnforced() { - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - for (int i = 0; i < 10; i++) { - acc.addDefault("flag-" + i, "v"); + + @Test + void subjectNullKeyIgnoredAndPerSubjectDedupeAtCap() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + acc.addSubject(null, 1); // null targeting key → ignored + assertEquals(0, acc.subjectCount()); + for (int i = 0; i < SpanEnrichmentAccumulator.MAX_EXPERIMENTS_PER_SUBJECT; i++) { + acc.addSubject("s", i); + } + acc.addSubject("s", 0); // existing subject, at exp cap, id present → no-op + acc.addSubject("s", 9999); // existing subject, at exp cap, id new → dropped + assertEquals(1, acc.subjectCount()); } - assertEquals(SpanEnrichmentAccumulator.MAX_DEFAULTS, acc.defaultCount()); - } - - @Test - void encoderNullAndNonSortedInput() { - assertEquals("", ULeb128Encoder.encodeDeltaVarint(null), "null → empty string"); - // A non-SortedSet input must still encode identically (it is sorted internally). - final java.util.Set unsorted = - new java.util.HashSet<>(java.util.Arrays.asList(130, 100, 128, 108)); - assertEquals("ZAgUAg==", ULeb128Encoder.encodeDeltaVarint(unsorted)); - } - - @Test - void serialIdDedupeAtCap() { - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - for (int i = 0; i < SpanEnrichmentAccumulator.MAX_SERIAL_IDS; i++) { - acc.addSerialId(i); + + @Test + void stringifyDefaultCoversAllNativeShapes() { + // list containing Double, Long, Short, Byte, null, and a nested Map — exercises every + // writeJsonValue branch (Number/double, Integer|Long|Short|Byte/long, null, Map, Iterable). + final Object nested = + java.util.Arrays.asList(1.5d, 3L, (short) 7, (byte) 2, null, Collections.singletonMap("k", "v")); + assertEquals("[1.5,3,7,2,null,{\"k\":\"v\"}]", SpanEnrichmentAccumulator.stringifyDefault(nested)); + + // Java array routes through isArray → serialized via its (non-JSON) string form, but must not + // throw; assert it produced a quoted string. + final String arr = SpanEnrichmentAccumulator.stringifyDefault(new int[] {1, 2}); + assertTrue(arr.startsWith("\"") && arr.endsWith("\"")); + + // Character scalar (CharSequence/Character branch). + assertEquals("x", SpanEnrichmentAccumulator.stringifyDefault('x')); } - acc.addSerialId(0); // already present + at cap → no-op, not dropped as "new" - acc.addSerialId(9999); // new + at cap → dropped - assertEquals(SpanEnrichmentAccumulator.MAX_SERIAL_IDS, acc.serialIdsView().size()); - assertTrue(acc.serialIdsView().contains(0)); - assertFalse(acc.serialIdsView().contains(9999)); - } - - @Test - void subjectNullKeyIgnoredAndPerSubjectDedupeAtCap() { - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - acc.addSubject(null, 1); // null targeting key → ignored - assertEquals(0, acc.subjectCount()); - for (int i = 0; i < SpanEnrichmentAccumulator.MAX_EXPERIMENTS_PER_SUBJECT; i++) { - acc.addSubject("s", i); + + @Test + void runtimeDefaultTruncationIsSurrogateSafe() { + // 63 ASCII chars then an astral emoji (a surrogate pair): truncating to 64 would split the + // pair at index 63, so utf8SafeTruncate must drop the dangling high surrogate → 63 chars. + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 63; i++) { + sb.append('a'); + } + sb.append("😀"); // 😀 (high + low surrogate) + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + acc.addDefault("flag", sb.toString()); + final String tag = acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_RUNTIME_DEFAULTS); + assertEquals("{\"flag\":\"" + sb.substring(0, 63) + "\"}", tag); } - acc.addSubject("s", 0); // existing subject, at exp cap, id present → no-op - acc.addSubject("s", 9999); // existing subject, at exp cap, id new → dropped - assertEquals(1, acc.subjectCount()); - } - - @Test - void stringifyDefaultCoversAllNativeShapes() { - // list containing Double, Long, Short, Byte, null, and a nested Map — exercises every - // writeJsonValue branch (Number/double, Integer|Long|Short|Byte/long, null, Map, Iterable). - final Object nested = - java.util.Arrays.asList( - 1.5d, 3L, (short) 7, (byte) 2, null, Collections.singletonMap("k", "v")); - assertEquals( - "[1.5,3,7,2,null,{\"k\":\"v\"}]", SpanEnrichmentAccumulator.stringifyDefault(nested)); - - // Java array routes through isArray → serialized via its (non-JSON) string form, but must not - // throw; assert it produced a quoted string. - final String arr = SpanEnrichmentAccumulator.stringifyDefault(new int[] {1, 2}); - assertTrue(arr.startsWith("\"") && arr.endsWith("\"")); - - // Character scalar (CharSequence/Character branch). - assertEquals("x", SpanEnrichmentAccumulator.stringifyDefault('x')); - } - - @Test - void runtimeDefaultTruncationIsSurrogateSafe() { - // 63 ASCII chars then an astral emoji (a surrogate pair): truncating to 64 would split the - // pair at index 63, so utf8SafeTruncate must drop the dangling high surrogate → 63 chars. - final StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 63; i++) { - sb.append('a'); + + @Test + void noDataWhenEmpty() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + assertFalse(acc.hasData()); + final Map tags = acc.toSpanTags(); + assertTrue(tags.isEmpty()); } - sb.append("😀"); // 😀 (high + low surrogate) - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - acc.addDefault("flag", sb.toString()); - final String tag = acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_RUNTIME_DEFAULTS); - assertEquals("{\"flag\":\"" + sb.substring(0, 63) + "\"}", tag); - } - - @Test - void noDataWhenEmpty() { - final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); - assertFalse(acc.hasData()); - final Map tags = acc.toSpanTags(); - assertTrue(tags.isEmpty()); - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java index becbf4cd464..06c3824f302 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java @@ -26,192 +26,192 @@ */ class SpanEnrichmentInterceptorTest { - /** A mock local-root span reporting itself as its own local root. */ - private static AgentSpan rootSpan() { - final AgentSpan root = mock(AgentSpan.class); - when(root.getLocalRootSpan()).thenReturn(root); - return root; - } - - /** A mock child span whose local root is {@code root} but which is itself NOT the root. */ - private static AgentSpan childOf(final AgentSpan root) { - final AgentSpan child = mock(AgentSpan.class); - when(child.getLocalRootSpan()).thenReturn(root); - return child; - } - - @Test - void priorityIsUnique() { - assertEquals(4, new SpanEnrichmentInterceptor(new SpanEnrichmentStates()).priority()); - } - - @Test - void emptyOrNoStateIsANoOpFastPath() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - // empty trace - assertTrue(interceptor.onTraceComplete(Collections.emptyList()).isEmpty()); - // no accumulated state => fast path, never touches the span - final AgentSpan root = rootSpan(); - interceptor.onTraceComplete(Collections.singletonList(root)); - verify(root, never()).setTag(anyString(), anyString()); - } - - @Test - void finalFlushWithRootPresentWritesTags() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan root = rootSpan(); - states.getOrCreate(root).addSerialId(5); - - interceptor.onTraceComplete(Collections.singletonList(root)); - - verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 - assertTrue(states.isEmpty(), "state must be removed on the final flush"); - } - - @Test - void partialFlushExcludingRootPreservesState() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan root = rootSpan(); - states.getOrCreate(root).addSerialId(100); - states.getOrCreate(root).addSerialId(108); - - // PARTIAL flush: a fragment of children only — the open root is NOT in the collection. - final AgentSpan child1 = childOf(root); - final AgentSpan child2 = childOf(root); - interceptor.onTraceComplete(Arrays.asList(child1, child2)); - - verify(child1, never()).setTag(anyString(), anyString()); - verify(child2, never()).setTag(anyString(), anyString()); - verify(root, never()).setTag(anyString(), anyString()); - final SpanEnrichmentAccumulator surviving = states.peek(root); - assertNotNull(surviving, "partial flush must NOT remove the accumulator"); - assertTrue(surviving.serialIdsView().contains(100) && surviving.serialIdsView().contains(108)); - - // more evaluations, then the FINAL flush with the root present - states.getOrCreate(root).addSerialId(128); - states.getOrCreate(root).addSerialId(130); - final AgentSpan lateChild = childOf(root); - interceptor.onTraceComplete(Arrays.asList(lateChild, root)); - - verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ZAgUAg=="); // {100,108,128,130} - assertTrue(states.isEmpty(), "state removed only on the final flush"); - } - - @Test - void childOnlyFragmentNeverTagsAChildSpan() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan root = rootSpan(); - states.getOrCreate(root).addSerialId(7); - - final AgentSpan child = childOf(root); - interceptor.onTraceComplete(Collections.singletonList(child)); - - verify(child, never()).setTag(anyString(), anyString()); - verify(root, never()).setTag(anyString(), anyString()); - assertNotNull(states.peek(root), "child-only fragment must keep state"); - } - - @Test - void distinctTracesDoNotMerge() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan rootA = rootSpan(); - final AgentSpan rootB = rootSpan(); - states.getOrCreate(rootA).addSerialId(100); - states.getOrCreate(rootB).addSerialId(130); - assertEquals(2, states.size(), "distinct root spans must not share an accumulator"); - - interceptor.onTraceComplete(Collections.singletonList(rootA)); - verify(rootA).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ZA=="); // {100} -> 0x64 - interceptor.onTraceComplete(Collections.singletonList(rootB)); - verify(rootB).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ggE="); // {130} -> 0x82 0x01 - } - - @Test - void neverCompletingTracesAreNotCapped() { - // No cap / eviction: while their local-root spans are reachable, every never-completing trace - // keeps its accumulator. Weak-key reclamation (not a fixed count) bounds memory. - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final int churn = 20_000; // well beyond the old 4096 cap - final List liveRoots = new ArrayList<>(churn); - for (int i = 0; i < churn; i++) { - final AgentSpan root = rootSpan(); - liveRoots.add(root); - states.getOrCreate(root).addSerialId(i % 1000 + 1); + /** A mock local-root span reporting itself as its own local root. */ + private static AgentSpan rootSpan() { + final AgentSpan root = mock(AgentSpan.class); + when(root.getLocalRootSpan()).thenReturn(root); + return root; + } + + /** A mock child span whose local root is {@code root} but which is itself NOT the root. */ + private static AgentSpan childOf(final AgentSpan root) { + final AgentSpan child = mock(AgentSpan.class); + when(child.getLocalRootSpan()).thenReturn(root); + return child; + } + + @Test + void priorityIsUnique() { + assertEquals(4, new SpanEnrichmentInterceptor(new SpanEnrichmentStates()).priority()); + } + + @Test + void emptyOrNoStateIsANoOpFastPath() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + // empty trace + assertTrue(interceptor.onTraceComplete(Collections.emptyList()).isEmpty()); + // no accumulated state => fast path, never touches the span + final AgentSpan root = rootSpan(); + interceptor.onTraceComplete(Collections.singletonList(root)); + verify(root, never()).setTag(anyString(), anyString()); + } + + @Test + void finalFlushWithRootPresentWritesTags() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(5); + + interceptor.onTraceComplete(Collections.singletonList(root)); + + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 + assertTrue(states.isEmpty(), "state must be removed on the final flush"); + } + + @Test + void partialFlushExcludingRootPreservesState() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(100); + states.getOrCreate(root).addSerialId(108); + + // PARTIAL flush: a fragment of children only — the open root is NOT in the collection. + final AgentSpan child1 = childOf(root); + final AgentSpan child2 = childOf(root); + interceptor.onTraceComplete(Arrays.asList(child1, child2)); + + verify(child1, never()).setTag(anyString(), anyString()); + verify(child2, never()).setTag(anyString(), anyString()); + verify(root, never()).setTag(anyString(), anyString()); + final SpanEnrichmentAccumulator surviving = states.peek(root); + assertNotNull(surviving, "partial flush must NOT remove the accumulator"); + assertTrue(surviving.serialIdsView().contains(100) + && surviving.serialIdsView().contains(108)); + + // more evaluations, then the FINAL flush with the root present + states.getOrCreate(root).addSerialId(128); + states.getOrCreate(root).addSerialId(130); + final AgentSpan lateChild = childOf(root); + interceptor.onTraceComplete(Arrays.asList(lateChild, root)); + + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ZAgUAg=="); // {100,108,128,130} + assertTrue(states.isEmpty(), "state removed only on the final flush"); + } + + @Test + void childOnlyFragmentNeverTagsAChildSpan() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(7); + + final AgentSpan child = childOf(root); + interceptor.onTraceComplete(Collections.singletonList(child)); + + verify(child, never()).setTag(anyString(), anyString()); + verify(root, never()).setTag(anyString(), anyString()); + assertNotNull(states.peek(root), "child-only fragment must keep state"); + } + + @Test + void distinctTracesDoNotMerge() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan rootA = rootSpan(); + final AgentSpan rootB = rootSpan(); + states.getOrCreate(rootA).addSerialId(100); + states.getOrCreate(rootB).addSerialId(130); + assertEquals(2, states.size(), "distinct root spans must not share an accumulator"); + + interceptor.onTraceComplete(Collections.singletonList(rootA)); + verify(rootA).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ZA=="); // {100} -> 0x64 + interceptor.onTraceComplete(Collections.singletonList(rootB)); + verify(rootB).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ggE="); // {130} -> 0x82 0x01 + } + + @Test + void neverCompletingTracesAreNotCapped() { + // No cap / eviction: while their local-root spans are reachable, every never-completing trace + // keeps its accumulator. Weak-key reclamation (not a fixed count) bounds memory. + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final int churn = 20_000; // well beyond the old 4096 cap + final List liveRoots = new ArrayList<>(churn); + for (int i = 0; i < churn; i++) { + final AgentSpan root = rootSpan(); + liveRoots.add(root); + states.getOrCreate(root).addSerialId(i % 1000 + 1); + } + assertEquals(churn, states.size(), "no cap/eviction: every live root keeps its accumulator"); + } + + @Test + void nullTraceIsANoOp() { + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(new SpanEnrichmentStates()); + assertNull(interceptor.onTraceComplete(null)); + } + + @Test + void rootPresentButNoAccumulatorEntryWritesNothing() { + // states is non-empty (has rootA) but we flush rootB which has no entry → remove returns null. + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan rootA = rootSpan(); + states.getOrCreate(rootA).addSerialId(1); + final AgentSpan rootB = rootSpan(); + interceptor.onTraceComplete(Collections.singletonList(rootB)); + verify(rootB, never()).setTag(anyString(), anyString()); + } + + @Test + void emptyAccumulatorWritesNoTags() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root); // entry exists but has no data + interceptor.onTraceComplete(Collections.singletonList(root)); + verify(root, never()).setTag(anyString(), anyString()); + assertTrue(states.isEmpty(), "empty accumulator is still removed on the final flush"); + } + + @Test + void fallbackResolvesSelfRootWhenFirstSpanReportsNoLocalRoot() { + // First span reports a null local root; a later span that is its own local root is chosen. + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan noRootFirst = mock(AgentSpan.class); + when(noRootFirst.getLocalRootSpan()).thenReturn(null); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(5); + interceptor.onTraceComplete(Arrays.asList(noRootFirst, root)); + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); + } + + @Test + void neverThrowsWhenSetTagFails() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + when(root.setTag(anyString(), anyString())).thenThrow(new RuntimeException("boom")); + states.getOrCreate(root).addSerialId(5); + // Must swallow the exception — enrichment can never break trace finish. + interceptor.onTraceComplete(Collections.singletonList(root)); + } + + @Test + void keyingSymmetryRootResolvedFromFragmentMatchesCapturedRoot() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(5); + + // The interceptor resolves the root from the fragment (root + a late child); it must be the + // same object so the remove hits the captured accumulator. + final AgentSpan child = childOf(root); + interceptor.onTraceComplete(Arrays.asList(child, root)); + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 + assertTrue(states.isEmpty()); } - assertEquals(churn, states.size(), "no cap/eviction: every live root keeps its accumulator"); - } - - @Test - void nullTraceIsANoOp() { - final SpanEnrichmentInterceptor interceptor = - new SpanEnrichmentInterceptor(new SpanEnrichmentStates()); - assertNull(interceptor.onTraceComplete(null)); - } - - @Test - void rootPresentButNoAccumulatorEntryWritesNothing() { - // states is non-empty (has rootA) but we flush rootB which has no entry → remove returns null. - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan rootA = rootSpan(); - states.getOrCreate(rootA).addSerialId(1); - final AgentSpan rootB = rootSpan(); - interceptor.onTraceComplete(Collections.singletonList(rootB)); - verify(rootB, never()).setTag(anyString(), anyString()); - } - - @Test - void emptyAccumulatorWritesNoTags() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan root = rootSpan(); - states.getOrCreate(root); // entry exists but has no data - interceptor.onTraceComplete(Collections.singletonList(root)); - verify(root, never()).setTag(anyString(), anyString()); - assertTrue(states.isEmpty(), "empty accumulator is still removed on the final flush"); - } - - @Test - void fallbackResolvesSelfRootWhenFirstSpanReportsNoLocalRoot() { - // First span reports a null local root; a later span that is its own local root is chosen. - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan noRootFirst = mock(AgentSpan.class); - when(noRootFirst.getLocalRootSpan()).thenReturn(null); - final AgentSpan root = rootSpan(); - states.getOrCreate(root).addSerialId(5); - interceptor.onTraceComplete(Arrays.asList(noRootFirst, root)); - verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); - } - - @Test - void neverThrowsWhenSetTagFails() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan root = rootSpan(); - when(root.setTag(anyString(), anyString())).thenThrow(new RuntimeException("boom")); - states.getOrCreate(root).addSerialId(5); - // Must swallow the exception — enrichment can never break trace finish. - interceptor.onTraceComplete(Collections.singletonList(root)); - } - - @Test - void keyingSymmetryRootResolvedFromFragmentMatchesCapturedRoot() { - final SpanEnrichmentStates states = new SpanEnrichmentStates(); - final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); - final AgentSpan root = rootSpan(); - states.getOrCreate(root).addSerialId(5); - - // The interceptor resolves the root from the fragment (root + a late child); it must be the - // same object so the remove hits the captured accumulator. - final AgentSpan child = childOf(root); - interceptor.onTraceComplete(Arrays.asList(child, root)); - verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 - assertTrue(states.isEmpty()); - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java index 662fe928658..bda7d089ac1 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java @@ -23,190 +23,183 @@ */ class SpanEnrichmentWriterTest { - private static AgentSpan rootSpan() { - final AgentSpan root = mock(AgentSpan.class); - when(root.getLocalRootSpan()).thenReturn(root); - return root; - } - - private static SpanEnrichmentWriter writerFor(final AgentSpan root) { - return new SpanEnrichmentWriter(() -> root); - } - - @Test - void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(42, true, "user-1")); - - final SpanEnrichmentAccumulator state = writer.states().peek(root); - assertNotNull(state); - assertTrue(state.serialIdsView().contains(42)); - assertEquals(1, state.subjectCount(), "doLog=true + targeting key => subject recorded"); - } - - @Test - void serialIdWithoutDoLogRecordsNoSubject() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(7, false, "user-1")); - - final SpanEnrichmentAccumulator state = writer.states().peek(root); - assertTrue(state.serialIdsView().contains(7)); - assertEquals(0, state.subjectCount(), "doLog=false must not record a subject"); - } - - @Test - void serialIdWithNullTargetingKeyRecordsNoSubject() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(9, true, null)); - - final SpanEnrichmentAccumulator state = writer.states().peek(root); - assertTrue(state.serialIdsView().contains(9)); - assertEquals(0, state.subjectCount(), "no targeting key => no subject"); - } - - @Test - void runtimeDefaultRecordsDefault() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.runtimeDefault("flag", Collections.singletonMap("k", "v"))); - - final SpanEnrichmentAccumulator state = writer.states().peek(root); - assertNotNull(state); - assertEquals(1, state.defaultCount()); - } - - @Test - void noActiveSpanAccumulatesNothing() { - final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> null); - writer.accept(SpanEnrichmentEvent.serialId(1, true, "user-1")); - assertTrue(writer.states().isEmpty(), "no active span => no accumulator state"); - } - - @Test - void nullEventIsANoOp() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(null); - assertTrue(writer.states().isEmpty()); - } - - @Test - void endToEndAccumulateThenFlush() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); - - writer.interceptor().onTraceComplete(Collections.singletonList(root)); - verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 - assertTrue(writer.states().isEmpty(), "flush removes the accumulated state"); - } - - @Test - void doesNotAccumulateWhenInterceptorCannotRegister() { - final AgentSpan root = rootSpan(); - // Registrar always rejects → nothing would ever flush the state, so we must not accumulate. - final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> root, interceptor -> false); - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); - assertTrue( - writer.states().isEmpty(), "no accumulation when the interceptor cannot be registered"); - } - - @Test - void agentSingletonIsStableAcrossRestarts() { - // FeatureFlaggingSystem reuses this one instance across start/stop, so the (unremovable) trace - // interceptor is registered exactly once and never re-registered on restart. - final SpanEnrichmentWriter first = SpanEnrichmentWriter.getInstance(); - final SpanEnrichmentWriter second = SpanEnrichmentWriter.getInstance(); - assertSame(first, second, "agent wiring must reuse one writer across restarts"); - assertSame(first.interceptor(), second.interceptor(), "same interceptor across restarts"); - assertSame(first.states(), second.states(), "same state store across restarts"); - } - - @Test - void agentSingletonUsesProductionDependencies() { - final SpanEnrichmentWriter writer = SpanEnrichmentWriter.getInstance(); - assertNotNull(writer.rootSpanResolver()); - assertNotNull(writer.registrar()); - } - - @Test - void resolveLocalRootLogic() { - assertNull(SpanEnrichmentWriter.resolveLocalRoot(null)); - - final AgentSpan root = rootSpan(); // reports itself as its own local root - assertSame(root, SpanEnrichmentWriter.resolveLocalRoot(root)); - - final AgentSpan child = mock(AgentSpan.class); - final AgentSpan childRoot = rootSpan(); - when(child.getLocalRootSpan()).thenReturn(childRoot); - assertSame(childRoot, SpanEnrichmentWriter.resolveLocalRoot(child)); - - final AgentSpan noLocal = mock(AgentSpan.class); - when(noLocal.getLocalRootSpan()).thenReturn(null); - assertSame( - noLocal, SpanEnrichmentWriter.resolveLocalRoot(noLocal), "no local root → active span"); - } - - @Test - void initSubscribesAndCloseClearsState() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.init(); - try { - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); - assertNotNull(writer.states().peek(root)); - } finally { - writer.close(); - } - assertTrue(writer.states().isEmpty(), "close() clears accumulated state"); - } - - @Test - void runtimeDefaultWithNullFlagKeyIsIgnored() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.runtimeDefault(null, "v")); - // No serial id and null flag key → nothing recorded (getOrCreate ran, but no data added). - final SpanEnrichmentAccumulator state = writer.states().peek(root); - assertTrue(state == null || !state.hasData()); - } - - @Test - void acceptSwallowsResolverErrors() { - final SpanEnrichmentWriter writer = - new SpanEnrichmentWriter( - () -> { - throw new RuntimeException("resolver boom"); - }); - // Must not propagate — enrichment can never break flag evaluation. - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); - assertTrue(writer.states().isEmpty()); - } - - @Test - void registrarErrorIsSwallowedAndNotLatched() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = - new SpanEnrichmentWriter( - () -> root, - interceptor -> { - throw new RuntimeException("register boom"); - }); - // Registration throws → swallowed, not latched, and nothing accumulates (never flushable). - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); - assertTrue(writer.states().isEmpty()); - } - - @Test - void distinctEventsUnderSameRootShareAccumulator() { - final AgentSpan root = rootSpan(); - final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(100, false, null)); - writer.accept(SpanEnrichmentEvent.serialId(108, false, null)); - assertEquals(1, writer.states().size(), "same root => one shared accumulator"); - verify(root, never()).setTag(anyString(), anyString()); - } + private static AgentSpan rootSpan() { + final AgentSpan root = mock(AgentSpan.class); + when(root.getLocalRootSpan()).thenReturn(root); + return root; + } + + private static SpanEnrichmentWriter writerFor(final AgentSpan root) { + return new SpanEnrichmentWriter(() -> root); + } + + @Test + void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(42, true, "user-1")); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertNotNull(state); + assertTrue(state.serialIdsView().contains(42)); + assertEquals(1, state.subjectCount(), "doLog=true + targeting key => subject recorded"); + } + + @Test + void serialIdWithoutDoLogRecordsNoSubject() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(7, false, "user-1")); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertTrue(state.serialIdsView().contains(7)); + assertEquals(0, state.subjectCount(), "doLog=false must not record a subject"); + } + + @Test + void serialIdWithNullTargetingKeyRecordsNoSubject() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(9, true, null)); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertTrue(state.serialIdsView().contains(9)); + assertEquals(0, state.subjectCount(), "no targeting key => no subject"); + } + + @Test + void runtimeDefaultRecordsDefault() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.runtimeDefault("flag", Collections.singletonMap("k", "v"))); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertNotNull(state); + assertEquals(1, state.defaultCount()); + } + + @Test + void noActiveSpanAccumulatesNothing() { + final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> null); + writer.accept(SpanEnrichmentEvent.serialId(1, true, "user-1")); + assertTrue(writer.states().isEmpty(), "no active span => no accumulator state"); + } + + @Test + void nullEventIsANoOp() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(null); + assertTrue(writer.states().isEmpty()); + } + + @Test + void endToEndAccumulateThenFlush() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + + writer.interceptor().onTraceComplete(Collections.singletonList(root)); + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 + assertTrue(writer.states().isEmpty(), "flush removes the accumulated state"); + } + + @Test + void doesNotAccumulateWhenInterceptorCannotRegister() { + final AgentSpan root = rootSpan(); + // Registrar always rejects → nothing would ever flush the state, so we must not accumulate. + final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> root, interceptor -> false); + writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + assertTrue(writer.states().isEmpty(), "no accumulation when the interceptor cannot be registered"); + } + + @Test + void agentSingletonIsStableAcrossRestarts() { + // FeatureFlaggingSystem reuses this one instance across start/stop, so the (unremovable) trace + // interceptor is registered exactly once and never re-registered on restart. + final SpanEnrichmentWriter first = SpanEnrichmentWriter.getInstance(); + final SpanEnrichmentWriter second = SpanEnrichmentWriter.getInstance(); + assertSame(first, second, "agent wiring must reuse one writer across restarts"); + assertSame(first.interceptor(), second.interceptor(), "same interceptor across restarts"); + assertSame(first.states(), second.states(), "same state store across restarts"); + } + + @Test + void agentSingletonUsesProductionDependencies() { + final SpanEnrichmentWriter writer = SpanEnrichmentWriter.getInstance(); + assertNotNull(writer.rootSpanResolver()); + assertNotNull(writer.registrar()); + } + + @Test + void resolveLocalRootLogic() { + assertNull(SpanEnrichmentWriter.resolveLocalRoot(null)); + + final AgentSpan root = rootSpan(); // reports itself as its own local root + assertSame(root, SpanEnrichmentWriter.resolveLocalRoot(root)); + + final AgentSpan child = mock(AgentSpan.class); + final AgentSpan childRoot = rootSpan(); + when(child.getLocalRootSpan()).thenReturn(childRoot); + assertSame(childRoot, SpanEnrichmentWriter.resolveLocalRoot(child)); + + final AgentSpan noLocal = mock(AgentSpan.class); + when(noLocal.getLocalRootSpan()).thenReturn(null); + assertSame(noLocal, SpanEnrichmentWriter.resolveLocalRoot(noLocal), "no local root → active span"); + } + + @Test + void initSubscribesAndCloseClearsState() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.init(); + try { + writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + assertNotNull(writer.states().peek(root)); + } finally { + writer.close(); + } + assertTrue(writer.states().isEmpty(), "close() clears accumulated state"); + } + + @Test + void runtimeDefaultWithNullFlagKeyIsIgnored() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.runtimeDefault(null, "v")); + // No serial id and null flag key → nothing recorded (getOrCreate ran, but no data added). + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertTrue(state == null || !state.hasData()); + } + + @Test + void acceptSwallowsResolverErrors() { + final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> { + throw new RuntimeException("resolver boom"); + }); + // Must not propagate — enrichment can never break flag evaluation. + writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + assertTrue(writer.states().isEmpty()); + } + + @Test + void registrarErrorIsSwallowedAndNotLatched() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> root, interceptor -> { + throw new RuntimeException("register boom"); + }); + // Registration throws → swallowed, not latched, and nothing accumulates (never flushable). + writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + assertTrue(writer.states().isEmpty()); + } + + @Test + void distinctEventsUnderSameRootShareAccumulator() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(100, false, null)); + writer.accept(SpanEnrichmentEvent.serialId(108, false, null)); + assertEquals(1, writer.states().size(), "same root => one shared accumulator"); + verify(root, never()).setTag(anyString(), anyString()); + } } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ULeb128EncoderTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ULeb128EncoderTest.java index f47fdeaef1c..b1b634ca9b8 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ULeb128EncoderTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ULeb128EncoderTest.java @@ -8,32 +8,32 @@ class ULeb128EncoderTest { - @Test - void hashTargetingKeyMatchesCanonicalPiiVector() { - // Canonical vector shared across all SDK implementations — see system-tests PR #7316. - assertEquals( - "b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", - ULeb128Encoder.hashTargetingKey("jane.doe@datadoghq.com")); - } + @Test + void hashTargetingKeyMatchesCanonicalPiiVector() { + // Canonical vector shared across all SDK implementations — see system-tests PR #7316. + assertEquals( + "b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", + ULeb128Encoder.hashTargetingKey("jane.doe@datadoghq.com")); + } - @Test - void hashTargetingKeyPreservesWhitespaceExactly() { - assertNotEquals( - ULeb128Encoder.hashTargetingKey("jane.doe@datadoghq.com"), - ULeb128Encoder.hashTargetingKey(" jane.doe@datadoghq.com ")); - } + @Test + void hashTargetingKeyPreservesWhitespaceExactly() { + assertNotEquals( + ULeb128Encoder.hashTargetingKey("jane.doe@datadoghq.com"), + ULeb128Encoder.hashTargetingKey(" jane.doe@datadoghq.com ")); + } - @Test - void hashTargetingKeyPreservesCaseExactly() { - assertNotEquals( - ULeb128Encoder.hashTargetingKey("jane.doe@datadoghq.com"), - ULeb128Encoder.hashTargetingKey("JANE.DOE@DATADOGHQ.COM")); - } + @Test + void hashTargetingKeyPreservesCaseExactly() { + assertNotEquals( + ULeb128Encoder.hashTargetingKey("jane.doe@datadoghq.com"), + ULeb128Encoder.hashTargetingKey("JANE.DOE@DATADOGHQ.COM")); + } - @Test - void hashTargetingKeyIsLowercase64CharHex() { - final String hash = ULeb128Encoder.hashTargetingKey("some-arbitrary-key"); - assertEquals(64, hash.length()); - assertTrue(hash.matches("[0-9a-f]{64}")); - } + @Test + void hashTargetingKeyIsLowercase64CharHex() { + final String hash = ULeb128Encoder.hashTargetingKey("some-arbitrary-key"); + assertEquals(64, hash.length()); + assertTrue(hash.matches("[0-9a-f]{64}")); + } } diff --git a/products/metrics/metrics-agent/src/main/java/datadog/metrics/agent/AgentMeter.java b/products/metrics/metrics-agent/src/main/java/datadog/metrics/agent/AgentMeter.java index 2ad193dfeda..bd5659e85d7 100644 --- a/products/metrics/metrics-agent/src/main/java/datadog/metrics/agent/AgentMeter.java +++ b/products/metrics/metrics-agent/src/main/java/datadog/metrics/agent/AgentMeter.java @@ -8,31 +8,30 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; public class AgentMeter { - private static volatile StatsDClient statsdClient = StatsDClient.NO_OP; - private static volatile Monitoring monitoring = DISABLED; + private static volatile StatsDClient statsdClient = StatsDClient.NO_OP; + private static volatile Monitoring monitoring = DISABLED; - // StatsD client connection - public static StatsDClient statsDClient() { - return statsdClient; - } - - // Health metrics monitoring - public static Monitoring monitoring() { - return monitoring; - } + // StatsD client connection + public static StatsDClient statsDClient() { + return statsdClient; + } - @SuppressFBWarnings( - value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", - justification = - "Agent-internal holder; AgentMeter.class is not exposed to instrumented app code") - public static synchronized void registerIfAbsent( - StatsDClient statsDClient, Monitoring monitoring, Histograms.Factory historgramFactory) { - if (statsDClient != null && AgentMeter.statsdClient == StatsDClient.NO_OP) { - AgentMeter.statsdClient = statsDClient; + // Health metrics monitoring + public static Monitoring monitoring() { + return monitoring; } - if (monitoring != null && monitoring != DISABLED) { - AgentMeter.monitoring = monitoring; + + @SuppressFBWarnings( + value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", + justification = "Agent-internal holder; AgentMeter.class is not exposed to instrumented app code") + public static synchronized void registerIfAbsent( + StatsDClient statsDClient, Monitoring monitoring, Histograms.Factory historgramFactory) { + if (statsDClient != null && AgentMeter.statsdClient == StatsDClient.NO_OP) { + AgentMeter.statsdClient = statsDClient; + } + if (monitoring != null && monitoring != DISABLED) { + AgentMeter.monitoring = monitoring; + } + Histograms.register(historgramFactory); } - Histograms.register(historgramFactory); - } } diff --git a/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorBenchmark.java b/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorBenchmark.java index f5ab5147c1c..801aa32708c 100644 --- a/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorBenchmark.java +++ b/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorBenchmark.java @@ -133,383 +133,383 @@ @Fork(5) public class AccumulatorBenchmark { - enum Counter { - HITS - } - - /** - * An 8-constant counterpart to {@link Counter}, used only by the {@code *8_*} benchmarks below. - * Unlike {@link Counter}, where every thread hits the single {@code HITS} constant (the worst - * case for {@code longAdderGroup}'s per-counter locking -- one lock shared by every thread, - * regardless of core count), these benchmarks spread writes across all 8 constants: each JMH - * worker thread is pinned to one fixed counter for its lifetime (see {@link - * #threadCounterIndex}), so under high contention, threads split into up to 8 groups each - * contending on their own lock instead of all threads sharing one. This is the topology where - * {@code longAdderGroup}'s distributed locking should actually pay off, and where {@link - * Accumulator}'s thread-striped design has to earn its win on the write side rather than facing a - * single-counter worst case. {@code accumulateAndReset}/{@code groupAccumulateAnd} also now walk - * 8 slots per drain instead of 1, sizing the drain cost closer to {@code TracerHealthMetric}'s - * 54-constant production shape. - */ - enum Counter8 { - COUNTER_0, - COUNTER_1, - COUNTER_2, - COUNTER_3, - COUNTER_4, - COUNTER_5, - COUNTER_6, - COUNTER_7 - } - - private static final Counter8[] COUNTER8_VALUES = Counter8.values(); - - private final LongAdder adder = new LongAdder(); - private final LongAdder deltaAdder = new LongAdder(); - private long deltaPrevious; - private final Accumulator accumulator = Accumulator.of(Counter.class); - private final Accumulator accumulator8 = Accumulator.of(Counter8.class); - private final ConcurrentHashMap chm = new ConcurrentHashMap<>(); - private final AtomicLongArray atomicLongArray = new AtomicLongArray(1); - private final AtomicLongArray atomicLongArray8 = new AtomicLongArray(8); - private final LongAdder[] longAdderGroup = {new LongAdder()}; - private final LongAdder[] longAdderGroup8 = { - new LongAdder(), - new LongAdder(), - new LongAdder(), - new LongAdder(), - new LongAdder(), - new LongAdder(), - new LongAdder(), - new LongAdder() - }; - - /** - * Assigns each JMH worker thread a fixed {@code Counter8} index (round-robin over 8) the first - * time it calls into any {@code *8_*} benchmark, and keeps returning that same index for the - * thread's lifetime -- so under {@code Threads.MAX}, writes spread across all 8 counters instead - * of every thread hammering one. - */ - private final AtomicInteger threadIndexAssigner = new AtomicInteger(); - - private final ThreadLocal threadCounterIndex = - ThreadLocal.withInitial(() -> threadIndexAssigner.getAndIncrement() % COUNTER8_VALUES.length); - - /** - * The natural "just use LongAdder" fix for the reset hazard: one {@code LongAdder} per counter, - * with a per-counter lock guarding both the increment and the drain -- external locking around - * only the drain does nothing, since {@code sumThenReset()}'s internal race is against the {@code - * LongAdder}'s own CAS-based {@code add()}, not against any lock a caller takes. This is the fair - * comparison point: it closes the same reset hazard {@link Accumulator} does, but stripes by - * counter (one lock per enum constant) instead of by thread (one shared table - * across all counters) -- so N threads hammering the *same* counter contend on one lock - * regardless of core count, with no thread-bucket distribution at all. - */ - private static void groupInc(LongAdder[] group, int ordinal) { - LongAdder counter = group[ordinal]; - synchronized (counter) { - counter.add(1L); - } - } - - private static long[] groupAccumulateAnd(LongAdder[] group) { - long[] acc = new long[group.length]; - for (int i = 0; i < group.length; i++) { - LongAdder counter = group[i]; - synchronized (counter) { - acc[i] = counter.sumThenReset(); - } - } - return acc; - } - - /** - * The unstriped baseline: a single shared {@code AtomicLongArray}, one slot per counter, with no - * per-thread distribution at all -- isolates the cost of {@link Accumulator}'s thread-striping - * itself from the cost of correctness (unlike {@code longAdderGroup}, this has no lock: {@code - * getAndAdd} and {@code getAndSet} are each already atomic per-slot, so no coordination is needed - * to give the same "no increment lost across a drain" guarantee). - */ - private static long[] arrayAccumulateAndReset(AtomicLongArray array) { - long[] acc = new long[array.length()]; - for (int i = 0; i < array.length(); i++) { - acc[i] = array.getAndSet(i, 0L); - } - return acc; - } - - /** - * The safe, lock-free alternative to {@code sumThenReset()} that pre-migration {@code - * TracerHealthMetrics} actually used (via {@code previousCounts}/{@code countIndex}): never reset - * the {@code LongAdder} at all, and have a single differ thread track the last observed {@code - * sum()} to compute its own delta. {@code sum()} alone never loses an update permanently -- a - * miss just shows up in the next {@code sum()} -- so this closes {@code sumThenReset()}'s reset - * race without any lock, at the cost of one extra subtraction per drain. The catch is the "single - * differ" part: {@code deltaPrevious} is unsynchronized plain state, correct only because exactly - * one thread ever calls this method between increments. Unlike {@code sumThenReset()}, which - * degrades gracefully (just an occasional dropped delta) if called from multiple threads at once, - * concurrent callers here would race on {@code deltaPrevious} itself and corrupt it -- so there - * is deliberately no {@code longAdderDelta_highContention} mirroring {@code - * longAdderSumThenReset_highContention}'s "every thread both writes and drains" shape; see {@code - * longAdderDeltaMixed_write}/{@code _drain} below for the one topology (many writers, one - * dedicated drainer) this baseline is actually valid under. - */ - private long deltaSumAndReset() { - long current = deltaAdder.sum(); - long delta = current - deltaPrevious; - deltaPrevious = current; - return delta; - } - - @Benchmark - @Threads(1) - public void longAdderIncrement_lowContention() { - adder.increment(); - } - - @Benchmark - @Threads(Threads.MAX) - public void longAdderIncrement_highContention() { - adder.increment(); - } - - @Benchmark - @Threads(1) - public void accumulatorIncrement_lowContention() { - accumulator.inc(Counter.HITS); - } - - @Benchmark - @Threads(Threads.MAX) - public void accumulatorIncrement_highContention() { - accumulator.inc(Counter.HITS); - } - - @Benchmark - @Threads(1) - public void chmAtomicLongIncrement_lowContention() { - chm.computeIfAbsent("hits", k -> new AtomicLong()).incrementAndGet(); - } - - @Benchmark - @Threads(Threads.MAX) - public void chmAtomicLongIncrement_highContention() { - chm.computeIfAbsent("hits", k -> new AtomicLong()).incrementAndGet(); - } - - @Benchmark - @Threads(1) - public void longAdderSumThenReset_lowContention(Blackhole blackhole) { - adder.increment(); - blackhole.consume(adder.sumThenReset()); - } - - @Benchmark - @Threads(Threads.MAX) - public void longAdderSumThenReset_highContention(Blackhole blackhole) { - adder.increment(); - blackhole.consume(adder.sumThenReset()); - } - - @Benchmark - @Threads(1) - public void longAdderDelta_lowContention(Blackhole blackhole) { - deltaAdder.increment(); - blackhole.consume(deltaSumAndReset()); - } - - /** - * The realistic, valid topology for {@link #deltaSumAndReset} -- many writers, one dedicated - * differ -- mirroring {@code accumulatorMixed_write}/{@code _drain} below so the two can be - * compared directly: this is the fairest single-counter match for {@link Accumulator}, since both - * are lock-free on the write side and both give the same no-lost-update guarantee, just by - * different means (per-slot atomic {@code getAndSet} vs. a single differ's own bookkeeping). - */ - @Benchmark - @Group("longAdderDeltaMixed") - @GroupThreads(4) - public void longAdderDeltaMixed_write() { - deltaAdder.increment(); - } - - @Benchmark - @Group("longAdderDeltaMixed") - @GroupThreads(1) - public void longAdderDeltaMixed_drain(Blackhole blackhole) { - blackhole.consume(deltaSumAndReset()); - } - - @Benchmark - @Threads(1) - public void accumulatorAccumulateAndReset_lowContention(Blackhole blackhole) { - accumulator.inc(Counter.HITS); - blackhole.consume(accumulator.accumulateAndReset()); - } - - /** - * A deliberately pessimistic topology: every thread both writes and drains on every op, so {@code - * Threads.MAX} threads are all draining concurrently. Real callers don't do this -- see {@code - * accumulatorMixed-write}/{@code accumulatorMixed-drain} below for the "many writers, one rare - * drainer" shape this class actually targets. Kept as the worst-case upper bound: no production - * topology should be more contended on {@link Accumulator#accumulateAndReset} than this. - */ - @Benchmark - @Threads(Threads.MAX) - public void accumulatorAccumulateAndReset_highContention(Blackhole blackhole) { - accumulator.inc(Counter.HITS); - blackhole.consume(accumulator.accumulateAndReset()); - } - - /** - * The realistic counterpart to {@code accumulatorAccumulateAndReset_highContention}: many writer - * threads incrementing, and a single dedicated thread polling {@link - * Accumulator#accumulateAndReset} -- not every thread doing both on every op. {@code - * accumulatorMixed-write} measures increment cost while a drain is actively running; {@code - * accumulatorMixed-drain} measures the drain's own cost under that same live write pressure. The - * 4:1 writer:drainer ratio is illustrative of "many writers, rare drain," not tuned to a specific - * core count. - */ - @Benchmark - @Group("accumulatorMixed") - @GroupThreads(4) - public void accumulatorMixed_write() { - accumulator.inc(Counter.HITS); - } - - @Benchmark - @Group("accumulatorMixed") - @GroupThreads(1) - public void accumulatorMixed_drain(Blackhole blackhole) { - blackhole.consume(accumulator.accumulateAndReset()); - } - - @Benchmark - @Threads(1) - public void longAdderGroupIncrement_lowContention() { - groupInc(longAdderGroup, Counter.HITS.ordinal()); - } - - @Benchmark - @Threads(Threads.MAX) - public void longAdderGroupIncrement_highContention() { - groupInc(longAdderGroup, Counter.HITS.ordinal()); - } - - @Benchmark - @Threads(1) - public void longAdderGroupAccumulateAnd_lowContention(Blackhole blackhole) { - groupInc(longAdderGroup, Counter.HITS.ordinal()); - blackhole.consume(groupAccumulateAnd(longAdderGroup)); - } - - @Benchmark - @Threads(Threads.MAX) - public void longAdderGroupAccumulateAnd_highContention(Blackhole blackhole) { - groupInc(longAdderGroup, Counter.HITS.ordinal()); - blackhole.consume(groupAccumulateAnd(longAdderGroup)); - } - - @Benchmark - @Threads(1) - public void atomicLongArrayIncrement_lowContention() { - atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); - } - - @Benchmark - @Threads(Threads.MAX) - public void atomicLongArrayIncrement_highContention() { - atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); - } - - @Benchmark - @Threads(1) - public void atomicLongArrayAccumulateAndReset_lowContention(Blackhole blackhole) { - atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); - blackhole.consume(arrayAccumulateAndReset(atomicLongArray)); - } - - @Benchmark - @Threads(Threads.MAX) - public void atomicLongArrayAccumulateAndReset_highContention(Blackhole blackhole) { - atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); - blackhole.consume(arrayAccumulateAndReset(atomicLongArray)); - } - - @Benchmark - @Threads(1) - public void atomicLongArrayIncrement8_lowContention() { - atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); - } - - @Benchmark - @Threads(Threads.MAX) - public void atomicLongArrayIncrement8_highContention() { - atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); - } - - @Benchmark - @Threads(1) - public void atomicLongArrayAccumulateAndReset8_lowContention(Blackhole blackhole) { - atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); - blackhole.consume(arrayAccumulateAndReset(atomicLongArray8)); - } - - @Benchmark - @Threads(Threads.MAX) - public void atomicLongArrayAccumulateAndReset8_highContention(Blackhole blackhole) { - atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); - blackhole.consume(arrayAccumulateAndReset(atomicLongArray8)); - } - - @Benchmark - @Threads(1) - public void accumulatorIncrement8_lowContention() { - accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); - } - - @Benchmark - @Threads(Threads.MAX) - public void accumulatorIncrement8_highContention() { - accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); - } - - @Benchmark - @Threads(1) - public void accumulatorAccumulateAndReset8_lowContention(Blackhole blackhole) { - accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); - blackhole.consume(accumulator8.accumulateAndReset()); - } - - @Benchmark - @Threads(Threads.MAX) - public void accumulatorAccumulateAndReset8_highContention(Blackhole blackhole) { - accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); - blackhole.consume(accumulator8.accumulateAndReset()); - } - - @Benchmark - @Threads(1) - public void longAdderGroupIncrement8_lowContention() { - groupInc(longAdderGroup8, threadCounterIndex.get()); - } - - @Benchmark - @Threads(Threads.MAX) - public void longAdderGroupIncrement8_highContention() { - groupInc(longAdderGroup8, threadCounterIndex.get()); - } - - @Benchmark - @Threads(1) - public void longAdderGroupAccumulateAnd8_lowContention(Blackhole blackhole) { - groupInc(longAdderGroup8, threadCounterIndex.get()); - blackhole.consume(groupAccumulateAnd(longAdderGroup8)); - } - - @Benchmark - @Threads(Threads.MAX) - public void longAdderGroupAccumulateAnd8_highContention(Blackhole blackhole) { - groupInc(longAdderGroup8, threadCounterIndex.get()); - blackhole.consume(groupAccumulateAnd(longAdderGroup8)); - } + enum Counter { + HITS + } + + /** + * An 8-constant counterpart to {@link Counter}, used only by the {@code *8_*} benchmarks below. + * Unlike {@link Counter}, where every thread hits the single {@code HITS} constant (the worst + * case for {@code longAdderGroup}'s per-counter locking -- one lock shared by every thread, + * regardless of core count), these benchmarks spread writes across all 8 constants: each JMH + * worker thread is pinned to one fixed counter for its lifetime (see {@link + * #threadCounterIndex}), so under high contention, threads split into up to 8 groups each + * contending on their own lock instead of all threads sharing one. This is the topology where + * {@code longAdderGroup}'s distributed locking should actually pay off, and where {@link + * Accumulator}'s thread-striped design has to earn its win on the write side rather than facing a + * single-counter worst case. {@code accumulateAndReset}/{@code groupAccumulateAnd} also now walk + * 8 slots per drain instead of 1, sizing the drain cost closer to {@code TracerHealthMetric}'s + * 54-constant production shape. + */ + enum Counter8 { + COUNTER_0, + COUNTER_1, + COUNTER_2, + COUNTER_3, + COUNTER_4, + COUNTER_5, + COUNTER_6, + COUNTER_7 + } + + private static final Counter8[] COUNTER8_VALUES = Counter8.values(); + + private final LongAdder adder = new LongAdder(); + private final LongAdder deltaAdder = new LongAdder(); + private long deltaPrevious; + private final Accumulator accumulator = Accumulator.of(Counter.class); + private final Accumulator accumulator8 = Accumulator.of(Counter8.class); + private final ConcurrentHashMap chm = new ConcurrentHashMap<>(); + private final AtomicLongArray atomicLongArray = new AtomicLongArray(1); + private final AtomicLongArray atomicLongArray8 = new AtomicLongArray(8); + private final LongAdder[] longAdderGroup = {new LongAdder()}; + private final LongAdder[] longAdderGroup8 = { + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder() + }; + + /** + * Assigns each JMH worker thread a fixed {@code Counter8} index (round-robin over 8) the first + * time it calls into any {@code *8_*} benchmark, and keeps returning that same index for the + * thread's lifetime -- so under {@code Threads.MAX}, writes spread across all 8 counters instead + * of every thread hammering one. + */ + private final AtomicInteger threadIndexAssigner = new AtomicInteger(); + + private final ThreadLocal threadCounterIndex = + ThreadLocal.withInitial(() -> threadIndexAssigner.getAndIncrement() % COUNTER8_VALUES.length); + + /** + * The natural "just use LongAdder" fix for the reset hazard: one {@code LongAdder} per counter, + * with a per-counter lock guarding both the increment and the drain -- external locking around + * only the drain does nothing, since {@code sumThenReset()}'s internal race is against the {@code + * LongAdder}'s own CAS-based {@code add()}, not against any lock a caller takes. This is the fair + * comparison point: it closes the same reset hazard {@link Accumulator} does, but stripes by + * counter (one lock per enum constant) instead of by thread (one shared table + * across all counters) -- so N threads hammering the *same* counter contend on one lock + * regardless of core count, with no thread-bucket distribution at all. + */ + private static void groupInc(LongAdder[] group, int ordinal) { + LongAdder counter = group[ordinal]; + synchronized (counter) { + counter.add(1L); + } + } + + private static long[] groupAccumulateAnd(LongAdder[] group) { + long[] acc = new long[group.length]; + for (int i = 0; i < group.length; i++) { + LongAdder counter = group[i]; + synchronized (counter) { + acc[i] = counter.sumThenReset(); + } + } + return acc; + } + + /** + * The unstriped baseline: a single shared {@code AtomicLongArray}, one slot per counter, with no + * per-thread distribution at all -- isolates the cost of {@link Accumulator}'s thread-striping + * itself from the cost of correctness (unlike {@code longAdderGroup}, this has no lock: {@code + * getAndAdd} and {@code getAndSet} are each already atomic per-slot, so no coordination is needed + * to give the same "no increment lost across a drain" guarantee). + */ + private static long[] arrayAccumulateAndReset(AtomicLongArray array) { + long[] acc = new long[array.length()]; + for (int i = 0; i < array.length(); i++) { + acc[i] = array.getAndSet(i, 0L); + } + return acc; + } + + /** + * The safe, lock-free alternative to {@code sumThenReset()} that pre-migration {@code + * TracerHealthMetrics} actually used (via {@code previousCounts}/{@code countIndex}): never reset + * the {@code LongAdder} at all, and have a single differ thread track the last observed {@code + * sum()} to compute its own delta. {@code sum()} alone never loses an update permanently -- a + * miss just shows up in the next {@code sum()} -- so this closes {@code sumThenReset()}'s reset + * race without any lock, at the cost of one extra subtraction per drain. The catch is the "single + * differ" part: {@code deltaPrevious} is unsynchronized plain state, correct only because exactly + * one thread ever calls this method between increments. Unlike {@code sumThenReset()}, which + * degrades gracefully (just an occasional dropped delta) if called from multiple threads at once, + * concurrent callers here would race on {@code deltaPrevious} itself and corrupt it -- so there + * is deliberately no {@code longAdderDelta_highContention} mirroring {@code + * longAdderSumThenReset_highContention}'s "every thread both writes and drains" shape; see {@code + * longAdderDeltaMixed_write}/{@code _drain} below for the one topology (many writers, one + * dedicated drainer) this baseline is actually valid under. + */ + private long deltaSumAndReset() { + long current = deltaAdder.sum(); + long delta = current - deltaPrevious; + deltaPrevious = current; + return delta; + } + + @Benchmark + @Threads(1) + public void longAdderIncrement_lowContention() { + adder.increment(); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderIncrement_highContention() { + adder.increment(); + } + + @Benchmark + @Threads(1) + public void accumulatorIncrement_lowContention() { + accumulator.inc(Counter.HITS); + } + + @Benchmark + @Threads(Threads.MAX) + public void accumulatorIncrement_highContention() { + accumulator.inc(Counter.HITS); + } + + @Benchmark + @Threads(1) + public void chmAtomicLongIncrement_lowContention() { + chm.computeIfAbsent("hits", k -> new AtomicLong()).incrementAndGet(); + } + + @Benchmark + @Threads(Threads.MAX) + public void chmAtomicLongIncrement_highContention() { + chm.computeIfAbsent("hits", k -> new AtomicLong()).incrementAndGet(); + } + + @Benchmark + @Threads(1) + public void longAdderSumThenReset_lowContention(Blackhole blackhole) { + adder.increment(); + blackhole.consume(adder.sumThenReset()); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderSumThenReset_highContention(Blackhole blackhole) { + adder.increment(); + blackhole.consume(adder.sumThenReset()); + } + + @Benchmark + @Threads(1) + public void longAdderDelta_lowContention(Blackhole blackhole) { + deltaAdder.increment(); + blackhole.consume(deltaSumAndReset()); + } + + /** + * The realistic, valid topology for {@link #deltaSumAndReset} -- many writers, one dedicated + * differ -- mirroring {@code accumulatorMixed_write}/{@code _drain} below so the two can be + * compared directly: this is the fairest single-counter match for {@link Accumulator}, since both + * are lock-free on the write side and both give the same no-lost-update guarantee, just by + * different means (per-slot atomic {@code getAndSet} vs. a single differ's own bookkeeping). + */ + @Benchmark + @Group("longAdderDeltaMixed") + @GroupThreads(4) + public void longAdderDeltaMixed_write() { + deltaAdder.increment(); + } + + @Benchmark + @Group("longAdderDeltaMixed") + @GroupThreads(1) + public void longAdderDeltaMixed_drain(Blackhole blackhole) { + blackhole.consume(deltaSumAndReset()); + } + + @Benchmark + @Threads(1) + public void accumulatorAccumulateAndReset_lowContention(Blackhole blackhole) { + accumulator.inc(Counter.HITS); + blackhole.consume(accumulator.accumulateAndReset()); + } + + /** + * A deliberately pessimistic topology: every thread both writes and drains on every op, so {@code + * Threads.MAX} threads are all draining concurrently. Real callers don't do this -- see {@code + * accumulatorMixed-write}/{@code accumulatorMixed-drain} below for the "many writers, one rare + * drainer" shape this class actually targets. Kept as the worst-case upper bound: no production + * topology should be more contended on {@link Accumulator#accumulateAndReset} than this. + */ + @Benchmark + @Threads(Threads.MAX) + public void accumulatorAccumulateAndReset_highContention(Blackhole blackhole) { + accumulator.inc(Counter.HITS); + blackhole.consume(accumulator.accumulateAndReset()); + } + + /** + * The realistic counterpart to {@code accumulatorAccumulateAndReset_highContention}: many writer + * threads incrementing, and a single dedicated thread polling {@link + * Accumulator#accumulateAndReset} -- not every thread doing both on every op. {@code + * accumulatorMixed-write} measures increment cost while a drain is actively running; {@code + * accumulatorMixed-drain} measures the drain's own cost under that same live write pressure. The + * 4:1 writer:drainer ratio is illustrative of "many writers, rare drain," not tuned to a specific + * core count. + */ + @Benchmark + @Group("accumulatorMixed") + @GroupThreads(4) + public void accumulatorMixed_write() { + accumulator.inc(Counter.HITS); + } + + @Benchmark + @Group("accumulatorMixed") + @GroupThreads(1) + public void accumulatorMixed_drain(Blackhole blackhole) { + blackhole.consume(accumulator.accumulateAndReset()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupIncrement_lowContention() { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupIncrement_highContention() { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupAccumulateAnd_lowContention(Blackhole blackhole) { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + blackhole.consume(groupAccumulateAnd(longAdderGroup)); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupAccumulateAnd_highContention(Blackhole blackhole) { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + blackhole.consume(groupAccumulateAnd(longAdderGroup)); + } + + @Benchmark + @Threads(1) + public void atomicLongArrayIncrement_lowContention() { + atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); + } + + @Benchmark + @Threads(Threads.MAX) + public void atomicLongArrayIncrement_highContention() { + atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); + } + + @Benchmark + @Threads(1) + public void atomicLongArrayAccumulateAndReset_lowContention(Blackhole blackhole) { + atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); + blackhole.consume(arrayAccumulateAndReset(atomicLongArray)); + } + + @Benchmark + @Threads(Threads.MAX) + public void atomicLongArrayAccumulateAndReset_highContention(Blackhole blackhole) { + atomicLongArray.getAndAdd(Counter.HITS.ordinal(), 1L); + blackhole.consume(arrayAccumulateAndReset(atomicLongArray)); + } + + @Benchmark + @Threads(1) + public void atomicLongArrayIncrement8_lowContention() { + atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); + } + + @Benchmark + @Threads(Threads.MAX) + public void atomicLongArrayIncrement8_highContention() { + atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); + } + + @Benchmark + @Threads(1) + public void atomicLongArrayAccumulateAndReset8_lowContention(Blackhole blackhole) { + atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); + blackhole.consume(arrayAccumulateAndReset(atomicLongArray8)); + } + + @Benchmark + @Threads(Threads.MAX) + public void atomicLongArrayAccumulateAndReset8_highContention(Blackhole blackhole) { + atomicLongArray8.getAndAdd(threadCounterIndex.get(), 1L); + blackhole.consume(arrayAccumulateAndReset(atomicLongArray8)); + } + + @Benchmark + @Threads(1) + public void accumulatorIncrement8_lowContention() { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); + } + + @Benchmark + @Threads(Threads.MAX) + public void accumulatorIncrement8_highContention() { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); + } + + @Benchmark + @Threads(1) + public void accumulatorAccumulateAndReset8_lowContention(Blackhole blackhole) { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); + blackhole.consume(accumulator8.accumulateAndReset()); + } + + @Benchmark + @Threads(Threads.MAX) + public void accumulatorAccumulateAndReset8_highContention(Blackhole blackhole) { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); + blackhole.consume(accumulator8.accumulateAndReset()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupIncrement8_lowContention() { + groupInc(longAdderGroup8, threadCounterIndex.get()); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupIncrement8_highContention() { + groupInc(longAdderGroup8, threadCounterIndex.get()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupAccumulateAnd8_lowContention(Blackhole blackhole) { + groupInc(longAdderGroup8, threadCounterIndex.get()); + blackhole.consume(groupAccumulateAnd(longAdderGroup8)); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupAccumulateAnd8_highContention(Blackhole blackhole) { + groupInc(longAdderGroup8, threadCounterIndex.get()); + blackhole.consume(groupAccumulateAnd(longAdderGroup8)); + } } diff --git a/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorVsCounterBenchmark.java b/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorVsCounterBenchmark.java index 46306a4fffc..da2e2935743 100644 --- a/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorVsCounterBenchmark.java +++ b/products/metrics/metrics-api/src/jmh/java/datadog/metrics/api/AccumulatorVsCounterBenchmark.java @@ -74,119 +74,118 @@ @Fork(5) public class AccumulatorVsCounterBenchmark { - enum Metric { - HITS - } - - private final Accumulator accumulator = Accumulator.of(Metric.class); - private final Counter counter = new SynchronousStatsDCounter("hits", new LockingStatsDClient()); - - /** - * Mirrors {@code StatsDCounter}'s real shape: every {@link #increment} forwards straight to the - * client, with no batching of its own. Reimplemented here rather than depending on {@code - * metrics-lib} because {@code StatsDCounter}'s constructor is package-private. - */ - private static final class SynchronousStatsDCounter implements Counter { - private static final String[] NO_TAGS = new String[0]; - private final String name; - private final StatsDClient statsd; - - SynchronousStatsDCounter(String name, StatsDClient statsd) { - this.name = name; - this.statsd = statsd; + enum Metric { + HITS } - @Override - public void increment(int delta) { - statsd.count(name, delta, NO_TAGS); + private final Accumulator accumulator = Accumulator.of(Metric.class); + private final Counter counter = new SynchronousStatsDCounter("hits", new LockingStatsDClient()); + + /** + * Mirrors {@code StatsDCounter}'s real shape: every {@link #increment} forwards straight to the + * client, with no batching of its own. Reimplemented here rather than depending on {@code + * metrics-lib} because {@code StatsDCounter}'s constructor is package-private. + */ + private static final class SynchronousStatsDCounter implements Counter { + private static final String[] NO_TAGS = new String[0]; + private final String name; + private final StatsDClient statsd; + + SynchronousStatsDCounter(String name, StatsDClient statsd) { + this.name = name; + this.statsd = statsd; + } + + @Override + public void increment(int delta) { + statsd.count(name, delta, NO_TAGS); + } + + @Override + public void incrementErrorCount(String cause, int delta) { + statsd.count(name, delta, new String[] {"cause:" + cause}); + } } - @Override - public void incrementErrorCount(String cause, int delta) { - statsd.count(name, delta, new String[] {"cause:" + cause}); - } - } - - /** - * A {@link StatsDClient} stand-in that pays a real, serialized per-call cost instead of a true - * no-op: every real {@code StatsDClient} forwards {@code count} through one shared connection (a - * lock or an offer to a single non-blocking queue), so a true no-op would measure only - * virtual-dispatch overhead and understate the cost this benchmark exists to isolate. A {@code - * synchronized} increment of a shared counter is a reasonable stand-in for that shared - * serialization point without pulling in a real socket/queue implementation this benchmark - * doesn't need. - */ - private static final class LockingStatsDClient implements StatsDClient { - private final Object lock = new Object(); - private long total; - - @Override - public void incrementCounter(String metricName, String... tags) { - count(metricName, 1L, tags); + /** + * A {@link StatsDClient} stand-in that pays a real, serialized per-call cost instead of a true + * no-op: every real {@code StatsDClient} forwards {@code count} through one shared connection (a + * lock or an offer to a single non-blocking queue), so a true no-op would measure only + * virtual-dispatch overhead and understate the cost this benchmark exists to isolate. A {@code + * synchronized} increment of a shared counter is a reasonable stand-in for that shared + * serialization point without pulling in a real socket/queue implementation this benchmark + * doesn't need. + */ + private static final class LockingStatsDClient implements StatsDClient { + private final Object lock = new Object(); + private long total; + + @Override + public void incrementCounter(String metricName, String... tags) { + count(metricName, 1L, tags); + } + + @Override + public void count(String metricName, long delta, String... tags) { + synchronized (lock) { + total += delta; + } + } + + @Override + public void gauge(String metricName, long value, String... tags) {} + + @Override + public void gauge(String metricName, double value, String... tags) {} + + @Override + public void histogram(String metricName, long value, String... tags) {} + + @Override + public void histogram(String metricName, double value, String... tags) {} + + @Override + public void distribution(String metricName, long value, String... tags) {} + + @Override + public void distribution(String metricName, double value, String... tags) {} + + @Override + public void serviceCheck(String serviceCheckName, String status, String message, String... tags) {} + + @Override + public void error(Exception error) {} + + @Override + public int getErrorCount() { + return 0; + } + + @Override + public void close() {} } - @Override - public void count(String metricName, long delta, String... tags) { - synchronized (lock) { - total += delta; - } + @Benchmark + @Threads(1) + public void accumulatorIncrement_lowContention() { + accumulator.inc(Metric.HITS); } - @Override - public void gauge(String metricName, long value, String... tags) {} - - @Override - public void gauge(String metricName, double value, String... tags) {} - - @Override - public void histogram(String metricName, long value, String... tags) {} - - @Override - public void histogram(String metricName, double value, String... tags) {} - - @Override - public void distribution(String metricName, long value, String... tags) {} - - @Override - public void distribution(String metricName, double value, String... tags) {} - - @Override - public void serviceCheck( - String serviceCheckName, String status, String message, String... tags) {} - - @Override - public void error(Exception error) {} + @Benchmark + @Threads(Threads.MAX) + public void accumulatorIncrement_highContention() { + accumulator.inc(Metric.HITS); + } - @Override - public int getErrorCount() { - return 0; + @Benchmark + @Threads(1) + public void counterIncrement_lowContention() { + counter.increment(1); } - @Override - public void close() {} - } - - @Benchmark - @Threads(1) - public void accumulatorIncrement_lowContention() { - accumulator.inc(Metric.HITS); - } - - @Benchmark - @Threads(Threads.MAX) - public void accumulatorIncrement_highContention() { - accumulator.inc(Metric.HITS); - } - - @Benchmark - @Threads(1) - public void counterIncrement_lowContention() { - counter.increment(1); - } - - @Benchmark - @Threads(Threads.MAX) - public void counterIncrement_highContention() { - counter.increment(1); - } + @Benchmark + @Threads(Threads.MAX) + public void counterIncrement_highContention() { + counter.increment(1); + } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Accumulator.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Accumulator.java index 4f2c618534e..e220922c2ec 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Accumulator.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Accumulator.java @@ -37,252 +37,252 @@ */ @ThreadSafe public final class Accumulator> { - /** One full cache line of {@code long}s (64 bytes), used to pad each stripe row. */ - private static final int CACHE_LINE_LONGS = 8; + /** One full cache line of {@code long}s (64 bytes), used to pad each stripe row. */ + private static final int CACHE_LINE_LONGS = 8; - /** Upper bound on {@link #stripeCount()}, regardless of core count. */ - private static final int MAX_STRIPES = 64; + /** Upper bound on {@link #stripeCount()}, regardless of core count. */ + private static final int MAX_STRIPES = 64; - private final AtomicLongArray[] data; - private final int width; - private final E[] keys; - - private Accumulator(AtomicLongArray[] data, int width, E[] keys) { - this.data = data; - this.width = width; - this.keys = keys; - } - - /** - * @param enumType the enum naming each counter, e.g. {@code MyCounters.class} - */ - public static > Accumulator of(Class enumType) { - E[] keys = enumType.getEnumConstants(); - int width = keys.length; - int paddedWidth = paddedWidth(width); - int stripes = stripeCount(); - AtomicLongArray[] data = new AtomicLongArray[stripes]; - for (int i = 0; i < stripes; i++) { - data[i] = new AtomicLongArray(paddedWidth); - } - return new Accumulator<>(data, width, keys); - } - - /** Increments the counter named by {@code key} in the calling thread's stripe by one. */ - public void inc(E key) { - add(key, 1L); - } - - /** Adds {@code delta} to the counter named by {@code key} in the calling thread's stripe. */ - public void add(E key, long delta) { - stripeOf(data).getAndAdd(key.ordinal(), delta); - } - - /** - * Combines and resets every stripe, returning the sum as a typed view. Each counter is - * read-and-zeroed with one atomic {@code getAndSet} -- see the class-level note on what atomicity - * this does and doesn't provide across different counters. - * - * @return the sum, keyed by the enum's {@code ordinal()} - */ - public Counts accumulateAndReset() { - long[] acc = new long[width]; - for (AtomicLongArray stripe : data) { - for (int i = 0; i < width; i++) { - acc[i] += stripe.getAndSet(i, 0L); - } - } - return new Counts<>(acc, keys); - } - - /** - * Combines every stripe without resetting it, returning the sum as a typed view -- a live, - * non-destructive snapshot for a diagnostic read (e.g. {@code summary()}) that must not perturb - * the delta a concurrent {@link #accumulateAndReset} on a reporting cadence is about to report. - * - * @return the sum, keyed by the enum's {@code ordinal()} - */ - public Counts sum() { - long[] acc = new long[width]; - for (AtomicLongArray stripe : data) { - for (int i = 0; i < width; i++) { - acc[i] += stripe.get(i); - } - } - return new Counts<>(acc, keys); - } - - /** - * A typed view over a drained snapshot, returned by {@link #accumulateAndReset} or {@link #sum}: - * the same enum-ordinal type checking {@link Accumulator} provides on writes, applied to the read - * side too. - */ - public static final class Counts> { - private final long[] counts; + private final AtomicLongArray[] data; + private final int width; private final E[] keys; - private Counts(long[] counts, E[] keys) { - this.counts = counts; - this.keys = keys; + private Accumulator(AtomicLongArray[] data, int width, E[] keys) { + this.data = data; + this.width = width; + this.keys = keys; } /** - * An all-zero {@link Counts}, sized for {@code enumType} -- for seeding a running total before - * any real drain has happened, without needing a scratch {@link Accumulator} just to call - * {@link Accumulator#sum()} on it. - * * @param enumType the enum naming each counter, e.g. {@code MyCounters.class} */ - public static > Counts create(Class enumType) { - E[] keys = enumType.getEnumConstants(); - return new Counts<>(new long[keys.length], keys); + public static > Accumulator of(Class enumType) { + E[] keys = enumType.getEnumConstants(); + int width = keys.length; + int paddedWidth = paddedWidth(width); + int stripes = stripeCount(); + AtomicLongArray[] data = new AtomicLongArray[stripes]; + for (int i = 0; i < stripes; i++) { + data[i] = new AtomicLongArray(paddedWidth); + } + return new Accumulator<>(data, width, keys); } - /** The counter named by {@code key}. */ - public long get(E key) { - return counts[key.ordinal()]; + /** Increments the counter named by {@code key} in the calling thread's stripe by one. */ + public void inc(E key) { + add(key, 1L); + } + + /** Adds {@code delta} to the counter named by {@code key} in the calling thread's stripe. */ + public void add(E key, long delta) { + stripeOf(data).getAndAdd(key.ordinal(), delta); } /** - * The enum constants this {@link Counts} is keyed by, in declaration order -- for a caller that - * wants to iterate every counter (e.g. reporting each one) without separately having to pass - * {@code E.values()} alongside this object. An unmodifiable view over the same backing array - * every {@link Counts} from the same {@link Accumulator} shares -- no copy, but a caller can't - * corrupt that shared array's ordering for every other snapshot the way a raw array reference - * would let it. + * Combines and resets every stripe, returning the sum as a typed view. Each counter is + * read-and-zeroed with one atomic {@code getAndSet} -- see the class-level note on what atomicity + * this does and doesn't provide across different counters. + * + * @return the sum, keyed by the enum's {@code ordinal()} */ - public List keys() { - return Collections.unmodifiableList(Arrays.asList(keys)); + public Counts accumulateAndReset() { + long[] acc = new long[width]; + for (AtomicLongArray stripe : data) { + for (int i = 0; i < width; i++) { + acc[i] += stripe.getAndSet(i, 0L); + } + } + return new Counts<>(acc, keys); } /** - * Adds {@code other} to this, key by key, returning a new {@link Counts} rather than mutating - * either input -- combines a stored running total with a fresh, non-destructive {@link #sum} to - * answer "what's the live total right now" without ever resetting anything. + * Combines every stripe without resetting it, returning the sum as a typed view -- a live, + * non-destructive snapshot for a diagnostic read (e.g. {@code summary()}) that must not perturb + * the delta a concurrent {@link #accumulateAndReset} on a reporting cadence is about to report. + * + * @return the sum, keyed by the enum's {@code ordinal()} */ - public Counts plus(Counts other) { - long[] combined = counts.clone(); - for (int i = 0; i < combined.length; i++) { - combined[i] += other.counts[i]; - } - return new Counts<>(combined, keys); + public Counts sum() { + long[] acc = new long[width]; + for (AtomicLongArray stripe : data) { + for (int i = 0; i < width; i++) { + acc[i] += stripe.get(i); + } + } + return new Counts<>(acc, keys); } /** - * A copy of this {@link Counts} with every entry before {@code keys()[fromIndex]} zeroed out -- - * for a caller that partially delivered a batch downstream and wants to represent "what's left" - * to retry, without re-counting the part that already made it. + * A typed view over a drained snapshot, returned by {@link #accumulateAndReset} or {@link #sum}: + * the same enum-ordinal type checking {@link Accumulator} provides on writes, applied to the read + * side too. */ - public Counts from(int fromIndex) { - long[] remaining = new long[counts.length]; - System.arraycopy(counts, fromIndex, remaining, fromIndex, counts.length - fromIndex); - return new Counts<>(remaining, keys); - } - } + public static final class Counts> { + private final long[] counts; + private final E[] keys; + + private Counts(long[] counts, E[] keys) { + this.counts = counts; + this.keys = keys; + } + + /** + * An all-zero {@link Counts}, sized for {@code enumType} -- for seeding a running total before + * any real drain has happened, without needing a scratch {@link Accumulator} just to call + * {@link Accumulator#sum()} on it. + * + * @param enumType the enum naming each counter, e.g. {@code MyCounters.class} + */ + public static > Counts create(Class enumType) { + E[] keys = enumType.getEnumConstants(); + return new Counts<>(new long[keys.length], keys); + } + + /** The counter named by {@code key}. */ + public long get(E key) { + return counts[key.ordinal()]; + } - /** - * An opt-in, safe-by-default composition for the one recurring shape {@link Accumulator} itself - * deliberately doesn't try to make safe on its own: a periodic destructive drain for reporting - * (e.g. to statsd) alongside a separate, non-destructive live read for diagnostics (e.g. a {@code - * summary()} or {@code toString()}). Read independently, {@link #drain} and {@link #live} are - * each individually correct -- the hazard is between them: a {@link #live} call landing between a - * {@link #drain}'s reset and its caller publishing the delta into a running total would combine - * the pre-publish total with the post-drain (zeroed) {@link #sum}, silently under-reporting by - * the just-drained delta. {@code RunningTotal} closes that gap with one lock shared by {@link - * #drain} and {@link #live} -- most callers don't need this (a plain drain-only reporter, or a - * live-read-only diagnostic, needs no coordination at all), so it's a separate, opt-in type - * rather than baked into every {@link Accumulator}. - */ - @ThreadSafe - public static final class RunningTotal> { - private final Accumulator accumulator; - private final Object lock = new Object(); - private Counts total; + /** + * The enum constants this {@link Counts} is keyed by, in declaration order -- for a caller that + * wants to iterate every counter (e.g. reporting each one) without separately having to pass + * {@code E.values()} alongside this object. An unmodifiable view over the same backing array + * every {@link Counts} from the same {@link Accumulator} shares -- no copy, but a caller can't + * corrupt that shared array's ordering for every other snapshot the way a raw array reference + * would let it. + */ + public List keys() { + return Collections.unmodifiableList(Arrays.asList(keys)); + } - private RunningTotal(Accumulator accumulator) { - this.accumulator = accumulator; - // accumulateAndReset(), not sum() -- sum() doesn't reset, so seeding from it would double - // count whatever's already there once a later live() adds a fresh sum() on top of it. - this.total = accumulator.accumulateAndReset(); + /** + * Adds {@code other} to this, key by key, returning a new {@link Counts} rather than mutating + * either input -- combines a stored running total with a fresh, non-destructive {@link #sum} to + * answer "what's the live total right now" without ever resetting anything. + */ + public Counts plus(Counts other) { + long[] combined = counts.clone(); + for (int i = 0; i < combined.length; i++) { + combined[i] += other.counts[i]; + } + return new Counts<>(combined, keys); + } + + /** + * A copy of this {@link Counts} with every entry before {@code keys()[fromIndex]} zeroed out -- + * for a caller that partially delivered a batch downstream and wants to represent "what's left" + * to retry, without re-counting the part that already made it. + */ + public Counts from(int fromIndex) { + long[] remaining = new long[counts.length]; + System.arraycopy(counts, fromIndex, remaining, fromIndex, counts.length - fromIndex); + return new Counts<>(remaining, keys); + } } /** - * Wraps {@code accumulator}, seeding the running total from a drain of whatever it currently - * holds. That seed becomes visible through {@link #live}, but -- being folded in at - * construction, before any caller can observe it -- it is never returned by a later {@link - * #drain}. A caller that reports each {@link #drain} delta (e.g. to statsd) would therefore - * silently never report pre-existing counts. Always construct this from a freshly created - * {@code accumulator} (as every current caller does) so there is nothing to seed. + * An opt-in, safe-by-default composition for the one recurring shape {@link Accumulator} itself + * deliberately doesn't try to make safe on its own: a periodic destructive drain for reporting + * (e.g. to statsd) alongside a separate, non-destructive live read for diagnostics (e.g. a {@code + * summary()} or {@code toString()}). Read independently, {@link #drain} and {@link #live} are + * each individually correct -- the hazard is between them: a {@link #live} call landing between a + * {@link #drain}'s reset and its caller publishing the delta into a running total would combine + * the pre-publish total with the post-drain (zeroed) {@link #sum}, silently under-reporting by + * the just-drained delta. {@code RunningTotal} closes that gap with one lock shared by {@link + * #drain} and {@link #live} -- most callers don't need this (a plain drain-only reporter, or a + * live-read-only diagnostic, needs no coordination at all), so it's a separate, opt-in type + * rather than baked into every {@link Accumulator}. */ - public static > RunningTotal of(Accumulator accumulator) { - return new RunningTotal<>(accumulator); + @ThreadSafe + public static final class RunningTotal> { + private final Accumulator accumulator; + private final Object lock = new Object(); + private Counts total; + + private RunningTotal(Accumulator accumulator) { + this.accumulator = accumulator; + // accumulateAndReset(), not sum() -- sum() doesn't reset, so seeding from it would double + // count whatever's already there once a later live() adds a fresh sum() on top of it. + this.total = accumulator.accumulateAndReset(); + } + + /** + * Wraps {@code accumulator}, seeding the running total from a drain of whatever it currently + * holds. That seed becomes visible through {@link #live}, but -- being folded in at + * construction, before any caller can observe it -- it is never returned by a later {@link + * #drain}. A caller that reports each {@link #drain} delta (e.g. to statsd) would therefore + * silently never report pre-existing counts. Always construct this from a freshly created + * {@code accumulator} (as every current caller does) so there is nothing to seed. + */ + public static > RunningTotal of(Accumulator accumulator) { + return new RunningTotal<>(accumulator); + } + + /** + * Atomically drains the accumulator and folds the delta into the running total -- call this + * right before reporting the delta to a downstream sink on a reporting cadence. + * + * @return the delta just drained + */ + public Counts drain() { + synchronized (lock) { + Counts delta = accumulator.accumulateAndReset(); + total = total.plus(delta); + return delta; + } + } + + /** + * The live total: the running total as of the last {@link #drain}, folded with whatever's + * accumulated since -- never resets anything, safe to call at any time without perturbing a + * concurrent {@link #drain}. + */ + public Counts live() { + synchronized (lock) { + return total.plus(accumulator.sum()); + } + } } /** - * Atomically drains the accumulator and folds the delta into the running total -- call this - * right before reporting the delta to a downstream sink on a reporting cadence. + * The calling thread's stripe: cheap masking, no allocation, no map lookup. * - * @return the delta just drained + *

Multiple threads can map to the same stripe (this is masking, not a bijection); each + * counter's own atomic slot makes that safe, just not maximally scalable under a hash collision. */ - public Counts drain() { - synchronized (lock) { - Counts delta = accumulator.accumulateAndReset(); - total = total.plus(delta); - return delta; - } + private static AtomicLongArray stripeOf(AtomicLongArray[] data) { + int mask = data.length - 1; + int idx = (int) (ThreadSupport.threadId() & mask); + return data[idx]; } /** - * The live total: the running total as of the last {@link #drain}, folded with whatever's - * accumulated since -- never resets anything, safe to call at any time without perturbing a - * concurrent {@link #drain}. + * A fixed, power-of-two stripe count deliberately oversized to roughly 2x {@link + * Runtime#availableProcessors()} (minimum 4, maximum {@link #MAX_STRIPES}). Not exposed as a + * per-call override: a mandatory sizing knob on every caller fails the "print test" of + * self-explanatory API design. + * + *

Sizing to exactly the core count leaves stripe collisions likely under real contention + * (birthday-paradox math: with {@code n} contending threads and {@code m} stripes, expected + * colliding pairs are {@code n(n-1)/(2m)}) -- and a collision costs CAS-retry/cache-line-bounce + * cost, the same problem {@code LongAdder}'s own {@code Cell[]} table exists to avoid. Doubling + * the stripe count roughly halves that collision count for a one-time, per-accumulator memory + * cost, at the price of a slightly more expensive (but far rarer) {@link #accumulateAndReset} + * drain -- the right trade given {@link #inc}/{@link #add} run on every call while {@link + * #accumulateAndReset} runs on a reporting cadence. + * + *

Capped at {@link #MAX_STRIPES} so a single {@link Accumulator} on a very-high-core-count + * host can't grow its fixed table without bound: past that many contending threads, collision + * math has already flattened out, so the memory cost of chasing it further isn't worth paying. */ - public Counts live() { - synchronized (lock) { - return total.plus(accumulator.sum()); - } + private static int stripeCount() { + int cpus = Runtime.getRuntime().availableProcessors(); + return Math.min(MAX_STRIPES, Math.max(4, 2 * Integer.highestOneBit(Math.max(1, cpus)))); } - } - /** - * The calling thread's stripe: cheap masking, no allocation, no map lookup. - * - *

Multiple threads can map to the same stripe (this is masking, not a bijection); each - * counter's own atomic slot makes that safe, just not maximally scalable under a hash collision. - */ - private static AtomicLongArray stripeOf(AtomicLongArray[] data) { - int mask = data.length - 1; - int idx = (int) (ThreadSupport.threadId() & mask); - return data[idx]; - } - - /** - * A fixed, power-of-two stripe count deliberately oversized to roughly 2x {@link - * Runtime#availableProcessors()} (minimum 4, maximum {@link #MAX_STRIPES}). Not exposed as a - * per-call override: a mandatory sizing knob on every caller fails the "print test" of - * self-explanatory API design. - * - *

Sizing to exactly the core count leaves stripe collisions likely under real contention - * (birthday-paradox math: with {@code n} contending threads and {@code m} stripes, expected - * colliding pairs are {@code n(n-1)/(2m)}) -- and a collision costs CAS-retry/cache-line-bounce - * cost, the same problem {@code LongAdder}'s own {@code Cell[]} table exists to avoid. Doubling - * the stripe count roughly halves that collision count for a one-time, per-accumulator memory - * cost, at the price of a slightly more expensive (but far rarer) {@link #accumulateAndReset} - * drain -- the right trade given {@link #inc}/{@link #add} run on every call while {@link - * #accumulateAndReset} runs on a reporting cadence. - * - *

Capped at {@link #MAX_STRIPES} so a single {@link Accumulator} on a very-high-core-count - * host can't grow its fixed table without bound: past that many contending threads, collision - * math has already flattened out, so the memory cost of chasing it further isn't worth paying. - */ - private static int stripeCount() { - int cpus = Runtime.getRuntime().availableProcessors(); - return Math.min(MAX_STRIPES, Math.max(4, 2 * Integer.highestOneBit(Math.max(1, cpus)))); - } - - /** Rounds {@code width} up to a whole number of cache lines, plus one full trailing line. */ - private static int paddedWidth(int width) { - int wholeLines = ((width + CACHE_LINE_LONGS - 1) / CACHE_LINE_LONGS) * CACHE_LINE_LONGS; - return wholeLines + CACHE_LINE_LONGS; - } + /** Rounds {@code width} up to a whole number of cache lines, plus one full trailing line. */ + private static int paddedWidth(int width) { + int wholeLines = ((width + CACHE_LINE_LONGS - 1) / CACHE_LINE_LONGS) * CACHE_LINE_LONGS; + return wholeLines + CACHE_LINE_LONGS; + } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Counter.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Counter.java index 4adc603753f..465d0d5fc76 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Counter.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Counter.java @@ -2,7 +2,7 @@ public interface Counter { - void increment(int delta); + void increment(int delta); - void incrementErrorCount(String cause, int delta); + void incrementErrorCount(String cause, int delta); } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histogram.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histogram.java index db2ecfdd9e9..0063d4085fa 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histogram.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histogram.java @@ -5,45 +5,45 @@ public interface Histogram { - double getCount(); + double getCount(); - boolean isEmpty(); + boolean isEmpty(); - void accept(double value); + void accept(double value); - void accept(double value, double count); + void accept(double value, double count); - double getValueAtQuantile(double quantile); + double getValueAtQuantile(double quantile); - double getMinValue(); + double getMinValue(); - double getMaxValue(); + double getMaxValue(); - List getBinBoundaries(); + List getBinBoundaries(); - List getBinCounts(); + List getBinCounts(); - void clear(); + void clear(); - ByteBuffer serialize(); + ByteBuffer serialize(); - static Histogram newHistogram() { - return Histograms.factory.newHistogram(); - } + static Histogram newHistogram() { + return Histograms.factory.newHistogram(); + } - static Histogram newLogHistogram() { - return Histograms.factory.newLogHistogram(); - } + static Histogram newLogHistogram() { + return Histograms.factory.newLogHistogram(); + } - static Histogram newHistogram(double relativeAccuracy, int maxNumBins) { - return Histograms.factory.newHistogram(relativeAccuracy, maxNumBins); - } + static Histogram newHistogram(double relativeAccuracy, int maxNumBins) { + return Histograms.factory.newHistogram(relativeAccuracy, maxNumBins); + } - static HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins) { - return Histograms.factory.newHistogramWithSum(relativeAccuracy, maxNumBins); - } + static HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins) { + return Histograms.factory.newHistogramWithSum(relativeAccuracy, maxNumBins); + } - static HistogramWithSum newHistogramWithSum(List binBoundaries) { - return Histograms.factory.newHistogramWithSum(binBoundaries); - } + static HistogramWithSum newHistogramWithSum(List binBoundaries) { + return Histograms.factory.newHistogramWithSum(binBoundaries); + } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/HistogramWithSum.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/HistogramWithSum.java index c71dee53d55..1e2928d8c1c 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/HistogramWithSum.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/HistogramWithSum.java @@ -2,5 +2,5 @@ /** Adds exact summary statistics to the histogram */ public interface HistogramWithSum extends Histogram { - double getSum(); + double getSum(); } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histograms.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histograms.java index ff7a2cd33f9..fef01d73c2f 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histograms.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Histograms.java @@ -3,28 +3,28 @@ import java.util.List; public class Histograms { - static final Factory NO_OP = new NoOpHistogramsFactory(); - static volatile Factory factory = NO_OP; - - /** - * Register a Histograms implementation. This is called by metrics-lib to provide the DDSketch - * implementation. - */ - public static void register(Factory histograms) { - if (histograms != null) { - Histograms.factory = histograms; + static final Factory NO_OP = new NoOpHistogramsFactory(); + static volatile Factory factory = NO_OP; + + /** + * Register a Histograms implementation. This is called by metrics-lib to provide the DDSketch + * implementation. + */ + public static void register(Factory histograms) { + if (histograms != null) { + Histograms.factory = histograms; + } } - } - public interface Factory { - Histogram newHistogram(); + public interface Factory { + Histogram newHistogram(); - Histogram newLogHistogram(); + Histogram newLogHistogram(); - Histogram newHistogram(double relativeAccuracy, int maxNumBins); + Histogram newHistogram(double relativeAccuracy, int maxNumBins); - HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins); + HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins); - HistogramWithSum newHistogramWithSum(List binBoundaries); - } + HistogramWithSum newHistogramWithSum(List binBoundaries); + } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Monitoring.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Monitoring.java index 9ea3741403e..825ea6f2ea1 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Monitoring.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Monitoring.java @@ -1,13 +1,13 @@ package datadog.metrics.api; public interface Monitoring { - Monitoring DISABLED = new NoOpMonitoring(); + Monitoring DISABLED = new NoOpMonitoring(); - Recording newTimer(String name); + Recording newTimer(String name); - Recording newTimer(String name, String... tags); + Recording newTimer(String name, String... tags); - Recording newThreadLocalTimer(String name); + Recording newThreadLocalTimer(String name); - Counter newCounter(String name); + Counter newCounter(String name); } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpCounter.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpCounter.java index fd627ad15cf..588b1201da3 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpCounter.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpCounter.java @@ -2,9 +2,9 @@ final class NoOpCounter implements Counter { - public static final Counter NO_OP = new NoOpCounter(); + public static final Counter NO_OP = new NoOpCounter(); - public void increment(int delta) {} + public void increment(int delta) {} - public void incrementErrorCount(String cause, int delta) {} + public void incrementErrorCount(String cause, int delta) {} } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogram.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogram.java index cbe214aafcd..f6a020adc4a 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogram.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogram.java @@ -5,59 +5,59 @@ import java.util.List; class NoOpHistogram implements HistogramWithSum { - public static final HistogramWithSum INSTANCE = new NoOpHistogram(); - - @Override - public double getCount() { - return 0; - } - - @Override - public double getSum() { - return 0; - } - - @Override - public boolean isEmpty() { - return true; - } - - @Override - public void accept(double value) {} - - @Override - public void accept(double value, double count) {} - - @Override - public double getValueAtQuantile(double quantile) { - return 0; - } - - @Override - public double getMinValue() { - return 0; - } - - @Override - public double getMaxValue() { - return 0; - } - - @Override - public List getBinBoundaries() { - return Collections.emptyList(); - } - - @Override - public List getBinCounts() { - return Collections.emptyList(); - } - - @Override - public void clear() {} - - @Override - public ByteBuffer serialize() { - return ByteBuffer.allocate(0); - } + public static final HistogramWithSum INSTANCE = new NoOpHistogram(); + + @Override + public double getCount() { + return 0; + } + + @Override + public double getSum() { + return 0; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public void accept(double value) {} + + @Override + public void accept(double value, double count) {} + + @Override + public double getValueAtQuantile(double quantile) { + return 0; + } + + @Override + public double getMinValue() { + return 0; + } + + @Override + public double getMaxValue() { + return 0; + } + + @Override + public List getBinBoundaries() { + return Collections.emptyList(); + } + + @Override + public List getBinCounts() { + return Collections.emptyList(); + } + + @Override + public void clear() {} + + @Override + public ByteBuffer serialize() { + return ByteBuffer.allocate(0); + } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogramsFactory.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogramsFactory.java index 83e31b0e7fa..993f1abe7af 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogramsFactory.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpHistogramsFactory.java @@ -3,28 +3,28 @@ import java.util.List; final class NoOpHistogramsFactory implements Histograms.Factory { - @Override - public Histogram newHistogram() { - return NoOpHistogram.INSTANCE; - } + @Override + public Histogram newHistogram() { + return NoOpHistogram.INSTANCE; + } - @Override - public Histogram newLogHistogram() { - return NoOpHistogram.INSTANCE; - } + @Override + public Histogram newLogHistogram() { + return NoOpHistogram.INSTANCE; + } - @Override - public Histogram newHistogram(double relativeAccuracy, int maxNumBins) { - return NoOpHistogram.INSTANCE; - } + @Override + public Histogram newHistogram(double relativeAccuracy, int maxNumBins) { + return NoOpHistogram.INSTANCE; + } - @Override - public HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins) { - return NoOpHistogram.INSTANCE; - } + @Override + public HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins) { + return NoOpHistogram.INSTANCE; + } - @Override - public HistogramWithSum newHistogramWithSum(List binBoundaries) { - return NoOpHistogram.INSTANCE; - } + @Override + public HistogramWithSum newHistogramWithSum(List binBoundaries) { + return NoOpHistogram.INSTANCE; + } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpMonitoring.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpMonitoring.java index e9f63723a77..63bf2599284 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpMonitoring.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpMonitoring.java @@ -1,25 +1,25 @@ package datadog.metrics.api; final class NoOpMonitoring implements Monitoring { - NoOpMonitoring() {} + NoOpMonitoring() {} - @Override - public Recording newTimer(String name) { - return NoOpRecording.NO_OP; - } + @Override + public Recording newTimer(String name) { + return NoOpRecording.NO_OP; + } - @Override - public Recording newTimer(String name, String... tags) { - return NoOpRecording.NO_OP; - } + @Override + public Recording newTimer(String name, String... tags) { + return NoOpRecording.NO_OP; + } - @Override - public Recording newThreadLocalTimer(String name) { - return NoOpRecording.NO_OP; - } + @Override + public Recording newThreadLocalTimer(String name) { + return NoOpRecording.NO_OP; + } - @Override - public Counter newCounter(String name) { - return NoOpCounter.NO_OP; - } + @Override + public Counter newCounter(String name) { + return NoOpCounter.NO_OP; + } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpRecording.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpRecording.java index fa09c1f69ab..7b2fd2eb3e5 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpRecording.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/NoOpRecording.java @@ -2,19 +2,19 @@ final class NoOpRecording extends Recording { - public static final Recording NO_OP = new NoOpRecording(); + public static final Recording NO_OP = new NoOpRecording(); - @Override - public Recording start() { - return this; - } + @Override + public Recording start() { + return this; + } - @Override - public void reset() {} + @Override + public void reset() {} - @Override - public void stop() {} + @Override + public void stop() {} - @Override - public void flush() {} + @Override + public void flush() {} } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Recording.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Recording.java index a367e308bf0..bf394d610b7 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Recording.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/Recording.java @@ -1,16 +1,16 @@ package datadog.metrics.api; public abstract class Recording implements AutoCloseable { - @Override - public void close() { - stop(); - } + @Override + public void close() { + stop(); + } - public abstract Recording start(); + public abstract Recording start(); - public abstract void reset(); + public abstract void reset(); - public abstract void stop(); + public abstract void stop(); - public abstract void flush(); + public abstract void flush(); } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/NoOpStatsDClient.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/NoOpStatsDClient.java index adab664fa2d..12229b414e6 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/NoOpStatsDClient.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/NoOpStatsDClient.java @@ -2,45 +2,42 @@ final class NoOpStatsDClient implements StatsDClient { - @Override - public void incrementCounter(final String metricName, final String... tags) {} + @Override + public void incrementCounter(final String metricName, final String... tags) {} - @Override - public void count(final String metricName, final long delta, final String... tags) {} + @Override + public void count(final String metricName, final long delta, final String... tags) {} - @Override - public void gauge(final String metricName, final long value, final String... tags) {} + @Override + public void gauge(final String metricName, final long value, final String... tags) {} - @Override - public void gauge(final String metricName, final double value, final String... tags) {} + @Override + public void gauge(final String metricName, final double value, final String... tags) {} - @Override - public void histogram(final String metricName, final long value, final String... tags) {} + @Override + public void histogram(final String metricName, final long value, final String... tags) {} - @Override - public void histogram(final String metricName, final double value, final String... tags) {} + @Override + public void histogram(final String metricName, final double value, final String... tags) {} - @Override - public void distribution(String metricName, long value, String... tags) {} + @Override + public void distribution(String metricName, long value, String... tags) {} - @Override - public void distribution(String metricName, double value, String... tags) {} + @Override + public void distribution(String metricName, double value, String... tags) {} - @Override - public void serviceCheck( - final String serviceCheckName, - final String status, - final String message, - final String... tags) {} + @Override + public void serviceCheck( + final String serviceCheckName, final String status, final String message, final String... tags) {} - @Override - public void error(Exception error) {} + @Override + public void error(Exception error) {} - @Override - public int getErrorCount() { - return 0; - } + @Override + public int getErrorCount() { + return 0; + } - @Override - public void close() {} + @Override + public void close() {} } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClient.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClient.java index 5947fb6656f..5f0dffd607c 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClient.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClient.java @@ -3,42 +3,41 @@ import java.io.Closeable; public interface StatsDClient extends Closeable { - StatsDClient NO_OP = new NoOpStatsDClient(); + StatsDClient NO_OP = new NoOpStatsDClient(); - void incrementCounter(String metricName, String... tags); + void incrementCounter(String metricName, String... tags); - void count(String metricName, long delta, String... tags); + void count(String metricName, long delta, String... tags); - void gauge(String metricName, long value, String... tags); + void gauge(String metricName, long value, String... tags); - void gauge(String metricName, double value, String... tags); + void gauge(String metricName, double value, String... tags); - void histogram(String metricName, long value, String... tags); + void histogram(String metricName, long value, String... tags); - void histogram(String metricName, double value, String... tags); + void histogram(String metricName, double value, String... tags); - void distribution(String metricName, long value, String... tags); + void distribution(String metricName, long value, String... tags); - void distribution(String metricName, double value, String... tags); + void distribution(String metricName, double value, String... tags); - void serviceCheck(String serviceCheckName, String status, String message, String... tags); + void serviceCheck(String serviceCheckName, String status, String message, String... tags); - void error(Exception error); + void error(Exception error); - int getErrorCount(); + int getErrorCount(); - /** - * Record a statsd event - * - * @param type the type of event (error, warning, info, success - @see Event.AlertType) - * @param source the source of the event (e.g. java, myapp, CrashTracking, Telemetry, etc) - * @param eventName the name of the event (or title) - * @param message the message of the event - * @param tags the tags to attach to the event - */ - default void recordEvent( - String type, String source, String eventName, String message, String... tags) {} + /** + * Record a statsd event + * + * @param type the type of event (error, warning, info, success - @see Event.AlertType) + * @param source the source of the event (e.g. java, myapp, CrashTracking, Telemetry, etc) + * @param eventName the name of the event (or title) + * @param message the message of the event + * @param tags the tags to attach to the event + */ + default void recordEvent(String type, String source, String eventName, String message, String... tags) {} - @Override - void close(); + @Override + void close(); } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClientManager.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClientManager.java index 55fd11e3b05..be77a0210be 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClientManager.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDClientManager.java @@ -1,16 +1,16 @@ package datadog.metrics.api.statsd; public interface StatsDClientManager { - default StatsDClient statsDClient( - String host, Integer port, String namedPipe, String namespace, String[] constantTags) { - return statsDClient(host, port, namedPipe, namespace, constantTags, true); - } + default StatsDClient statsDClient( + String host, Integer port, String namedPipe, String namespace, String[] constantTags) { + return statsDClient(host, port, namedPipe, namespace, constantTags, true); + } - StatsDClient statsDClient( - String host, - Integer port, - String namedPipe, - String namespace, - String[] constantTags, - boolean useAggregation); + StatsDClient statsDClient( + String host, + Integer port, + String namedPipe, + String namespace, + String[] constantTags, + boolean useAggregation); } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java index b149b422f4c..b611fa68c86 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java @@ -29,94 +29,90 @@ */ @ThreadSafe public final class StatsDCountReporter & StatsDCounterKey> { - private static final Logger log = LoggerFactory.getLogger(StatsDCountReporter.class); + private static final Logger log = LoggerFactory.getLogger(StatsDCountReporter.class); - private final StatsDClient statsDClient; - private final Accumulator accumulator; - private final Accumulator.RunningTotal runningTotal; + private final StatsDClient statsDClient; + private final Accumulator accumulator; + private final Accumulator.RunningTotal runningTotal; - /** Undelivered from the last {@link #flush}, to retry on the next one; {@code null} if none. */ - private Accumulator.Counts pending; + /** Undelivered from the last {@link #flush}, to retry on the next one; {@code null} if none. */ + private Accumulator.Counts pending; - /** - * @param enumType the enum naming each counter, e.g. {@code MyCounters.class} - */ - public static & StatsDCounterKey> StatsDCountReporter of( - StatsDClient statsDClient, Class enumType) { - return new StatsDCountReporter<>(statsDClient, Accumulator.of(enumType)); - } + /** + * @param enumType the enum naming each counter, e.g. {@code MyCounters.class} + */ + public static & StatsDCounterKey> StatsDCountReporter of( + StatsDClient statsDClient, Class enumType) { + return new StatsDCountReporter<>(statsDClient, Accumulator.of(enumType)); + } - private StatsDCountReporter(StatsDClient statsDClient, Accumulator accumulator) { - this.statsDClient = statsDClient; - this.accumulator = accumulator; - this.runningTotal = Accumulator.RunningTotal.of(accumulator); - } + private StatsDCountReporter(StatsDClient statsDClient, Accumulator accumulator) { + this.statsDClient = statsDClient; + this.accumulator = accumulator; + this.runningTotal = Accumulator.RunningTotal.of(accumulator); + } - /** Increments the counter named by {@code key} by one. */ - public void inc(E key) { - accumulator.inc(key); - } + /** Increments the counter named by {@code key} by one. */ + public void inc(E key) { + accumulator.inc(key); + } - /** Adds {@code delta} to the counter named by {@code key}. */ - public void add(E key, long delta) { - accumulator.add(key, delta); - } + /** Adds {@code delta} to the counter named by {@code key}. */ + public void add(E key, long delta) { + accumulator.add(key, delta); + } - /** - * The live total -- see {@link Accumulator.RunningTotal#live}. For a diagnostic read (e.g. a - * {@code summary()}), never for deciding what to report: {@link #flush} owns that. - */ - public Accumulator.Counts live() { - return runningTotal.live(); - } + /** + * The live total -- see {@link Accumulator.RunningTotal#live}. For a diagnostic read (e.g. a + * {@code summary()}), never for deciding what to report: {@link #flush} owns that. + */ + public Accumulator.Counts live() { + return runningTotal.live(); + } - /** - * Drains the accumulator and reports the delta (plus anything still owed from a prior failed - * attempt), holding onto whatever isn't confirmed delivered this time for the next {@link - * #flush}. - */ - public void flush() { - Accumulator.Counts drained = runningTotal.drain(); - Accumulator.Counts toReport = pending == null ? drained : pending.plus(drained); - pending = report(toReport); - } + /** + * Drains the accumulator and reports the delta (plus anything still owed from a prior failed + * attempt), holding onto whatever isn't confirmed delivered this time for the next {@link + * #flush}. + */ + public void flush() { + Accumulator.Counts drained = runningTotal.drain(); + Accumulator.Counts toReport = pending == null ? drained : pending.plus(drained); + pending = report(toReport); + } - /** - * @return {@code null} if every counter was delivered, otherwise a {@link Accumulator.Counts} - * holding whatever wasn't attempted or confirmed sent - */ - private Accumulator.Counts report(Accumulator.Counts counts) { - List keys = counts.keys(); - for (int i = 0; i < keys.size(); i++) { - E key = keys.get(i); - long delta = counts.get(key); - if (delta != 0) { - try { - statsDClient.count(key.getMetricName(), delta, key.getTags()); - } catch (RuntimeException e) { - log.debug( - "Failed to report {}, compensating {} undelivered counter(s)", - key, - keys.size() - i, - e); - return counts.from(i); + /** + * @return {@code null} if every counter was delivered, otherwise a {@link Accumulator.Counts} + * holding whatever wasn't attempted or confirmed sent + */ + private Accumulator.Counts report(Accumulator.Counts counts) { + List keys = counts.keys(); + for (int i = 0; i < keys.size(); i++) { + E key = keys.get(i); + long delta = counts.get(key); + if (delta != 0) { + try { + statsDClient.count(key.getMetricName(), delta, key.getTags()); + } catch (RuntimeException e) { + log.debug("Failed to report {}, compensating {} undelivered counter(s)", key, keys.size() - i, e); + return counts.from(i); + } + } } - } + return null; } - return null; - } - /** - * Reports every nonzero entry of {@code values}/{@code counts} directly -- no accumulator, no - * compensation. - */ - public static & StatsDCounterKey> void report( - StatsDClient statsDClient, E[] values, ToLongFunction counts) { - for (E value : values) { - long delta = counts.applyAsLong(value); - if (delta != 0) { - statsDClient.count(value.getMetricName(), delta, value.getTags()); - } + /** + * Reports every nonzero entry of {@code values}/{@code counts} directly -- no accumulator, no + * compensation. + */ + public static & StatsDCounterKey> void report( + StatsDClient statsDClient, E[] values, ToLongFunction counts) { + for (E value : values) { + long delta = counts.applyAsLong(value); + if (delta != 0) { + statsDClient.count(value.getMetricName(), delta, value.getTags()); + } + } } - } } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java index 3aadbfc24f5..6e539526243 100644 --- a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java @@ -2,7 +2,7 @@ /** A counter identity: the dogstatsd metric name and tags a batch of counts should report under. */ public interface StatsDCounterKey { - String getMetricName(); + String getMetricName(); - String[] getTags(); + String[] getTags(); } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorFootprintTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorFootprintTest.java index b0b2f7b59fd..dc1a69be64f 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorFootprintTest.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorFootprintTest.java @@ -38,103 +38,102 @@ */ class AccumulatorFootprintTest { - enum Counters { - REQUESTS, - ERRORS, - RETRIES, - BYTES_SENT - } + enum Counters { + REQUESTS, + ERRORS, + RETRIES, + BYTES_SENT + } - @BeforeAll - static void assumeNotJ9Jvm() { - // JOL's GraphLayout relies on HotSpot-specific Unsafe internals and throws - // IllegalStateException on J9-based JVMs (IBM/Semeru) -- same guard as - // StringIndexFootprintTest / ScopeAndContinuationLayoutTest. - assumeFalse(JavaVirtualMachine.isJ9()); - } + @BeforeAll + static void assumeNotJ9Jvm() { + // JOL's GraphLayout relies on HotSpot-specific Unsafe internals and throws + // IllegalStateException on J9-based JVMs (IBM/Semeru) -- same guard as + // StringIndexFootprintTest / ScopeAndContinuationLayoutTest. + assumeFalse(JavaVirtualMachine.isJ9()); + } - static long bytes(Object root) { - return GraphLayout.parseInstance(root).totalSize(); - } + static long bytes(Object root) { + return GraphLayout.parseInstance(root).totalSize(); + } - static LongAdder[] freshAdders() { - LongAdder[] adders = new LongAdder[Counters.values().length]; - for (int i = 0; i < adders.length; i++) { - adders[i] = new LongAdder(); + static LongAdder[] freshAdders() { + LongAdder[] adders = new LongAdder[Counters.values().length]; + for (int i = 0; i < adders.length; i++) { + adders[i] = new LongAdder(); + } + return adders; } - return adders; - } - @Test - void freshFootprint() { - LongAdder[] adders = freshAdders(); - Accumulator accumulator = Accumulator.of(Counters.class); + @Test + void freshFootprint() { + LongAdder[] adders = freshAdders(); + Accumulator accumulator = Accumulator.of(Counters.class); - long adderBytes = bytes((Object) adders); - long accumulatorBytes = bytes(accumulator); + long adderBytes = bytes((Object) adders); + long accumulatorBytes = bytes(accumulator); - System.out.printf( - "fresh: %d LongAdders = %6d bytes, Accumulator = %6d bytes%n", - adders.length, adderBytes, accumulatorBytes); - } + System.out.printf( + "fresh: %d LongAdders = %6d bytes, Accumulator = %6d bytes%n", + adders.length, adderBytes, accumulatorBytes); + } - /** - * Drives real multi-threaded contention against a fresh set of {@code LongAdder}s to force their - * {@code Cell[]} tables to grow, then compares against {@link Accumulator}'s fixed footprint -- - * the realistic comparison, since production callers write to these counters concurrently rather - * than leaving them untouched. - * - *

Cell-table growth is driven by JVM-internal CAS-collision detection, not something this test - * controls directly, so the exact grown size can vary by run/JVM; the one invariant asserted is - * monotonic growth (a contended footprint can only be at least the fresh one). - */ - @Test - void contendedFootprint() throws InterruptedException { - LongAdder[] adders = freshAdders(); - long freshAdderBytes = bytes((Object) adders); + /** + * Drives real multi-threaded contention against a fresh set of {@code LongAdder}s to force their + * {@code Cell[]} tables to grow, then compares against {@link Accumulator}'s fixed footprint -- + * the realistic comparison, since production callers write to these counters concurrently rather + * than leaving them untouched. + * + *

Cell-table growth is driven by JVM-internal CAS-collision detection, not something this test + * controls directly, so the exact grown size can vary by run/JVM; the one invariant asserted is + * monotonic growth (a contended footprint can only be at least the fresh one). + */ + @Test + void contendedFootprint() throws InterruptedException { + LongAdder[] adders = freshAdders(); + long freshAdderBytes = bytes((Object) adders); - int threads = Math.min(16, Math.max(4, Runtime.getRuntime().availableProcessors())); - ExecutorService pool = Executors.newFixedThreadPool(threads); - CountDownLatch start = new CountDownLatch(1); - CountDownLatch done = new CountDownLatch(threads); - try { - for (int t = 0; t < threads; t++) { - pool.execute( - () -> { - try { - start.await(); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (System.nanoTime() < deadline) { - for (LongAdder adder : adders) { - adder.increment(); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - done.countDown(); - } - }); - } - start.countDown(); - assertTrue(done.await(30, TimeUnit.SECONDS)); - } finally { - pool.shutdown(); - } + int threads = Math.min(16, Math.max(4, Runtime.getRuntime().availableProcessors())); + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + try { + for (int t = 0; t < threads; t++) { + pool.execute(() -> { + try { + start.await(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + for (LongAdder adder : adders) { + adder.increment(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + pool.shutdown(); + } - long contendedAdderBytes = bytes((Object) adders); - Accumulator accumulator = Accumulator.of(Counters.class); - long accumulatorBytes = bytes(accumulator); + long contendedAdderBytes = bytes((Object) adders); + Accumulator accumulator = Accumulator.of(Counters.class); + long accumulatorBytes = bytes(accumulator); - System.out.printf( - "contended: %d LongAdders = %6d bytes, Accumulator = %6d bytes%n", - adders.length, contendedAdderBytes, accumulatorBytes); + System.out.printf( + "contended: %d LongAdders = %6d bytes, Accumulator = %6d bytes%n", + adders.length, contendedAdderBytes, accumulatorBytes); - assertTrue( - contendedAdderBytes >= freshAdderBytes, - "contended LongAdder footprint should never shrink below the fresh footprint"); - assertTrue( - accumulatorBytes < contendedAdderBytes, - "Accumulator's fixed footprint should be smaller than N contended LongAdders"); - } + assertTrue( + contendedAdderBytes >= freshAdderBytes, + "contended LongAdder footprint should never shrink below the fresh footprint"); + assertTrue( + accumulatorBytes < contendedAdderBytes, + "Accumulator's fixed footprint should be smaller than N contended LongAdders"); + } } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorTest.java index adb203fdd1c..23022b4ef69 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorTest.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/AccumulatorTest.java @@ -16,281 +16,277 @@ class AccumulatorTest { - enum Counters { - FOO, - BAR, - BAZ - } - - @Test - void freshAccumulatorSumsToZero() { - Accumulator counters = Accumulator.of(Counters.class); - Accumulator.Counts drained = counters.accumulateAndReset(); - for (Counters c : Counters.values()) { - assertEquals(0L, drained.get(c)); + enum Counters { + FOO, + BAR, + BAZ } - } - - @Test - void incIncrementsByOne() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - counters.inc(Counters.FOO); - counters.inc(Counters.BAR); - - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(2L, drained.get(Counters.FOO)); - assertEquals(1L, drained.get(Counters.BAR)); - assertEquals(0L, drained.get(Counters.BAZ)); - } - - @Test - void addAppliesArbitraryDelta() { - Accumulator counters = Accumulator.of(Counters.class); - counters.add(Counters.BAZ, 41L); - counters.add(Counters.BAZ, 1L); - - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(42L, drained.get(Counters.BAZ)); - } - - @Test - void accumulateAndResetsSoASecondDrainIsZero() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - - Accumulator.Counts first = counters.accumulateAndReset(); - assertEquals(1L, first.get(Counters.FOO)); - - Accumulator.Counts second = counters.accumulateAndReset(); - for (Counters c : Counters.values()) { - assertEquals(0L, second.get(c)); + + @Test + void freshAccumulatorSumsToZero() { + Accumulator counters = Accumulator.of(Counters.class); + Accumulator.Counts drained = counters.accumulateAndReset(); + for (Counters c : Counters.values()) { + assertEquals(0L, drained.get(c)); + } } - } - - @Test - void concurrentIncrementsAreNotLost() throws InterruptedException { - Accumulator counters = Accumulator.of(Counters.class); - int threadCount = 16; - int incrementsPerThread = 10_000; - - ExecutorService pool = Executors.newFixedThreadPool(threadCount); - CountDownLatch start = new CountDownLatch(1); - CountDownLatch done = new CountDownLatch(threadCount); - try { - for (int t = 0; t < threadCount; t++) { - pool.execute( - () -> { - try { - start.await(); - for (int i = 0; i < incrementsPerThread; i++) { - counters.inc(Counters.FOO); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - done.countDown(); - } - }); - } - start.countDown(); - assertTrue(done.await(30, TimeUnit.SECONDS)); - } finally { - pool.shutdown(); + + @Test + void incIncrementsByOne() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + counters.inc(Counters.FOO); + counters.inc(Counters.BAR); + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(2L, drained.get(Counters.FOO)); + assertEquals(1L, drained.get(Counters.BAR)); + assertEquals(0L, drained.get(Counters.BAZ)); + } + + @Test + void addAppliesArbitraryDelta() { + Accumulator counters = Accumulator.of(Counters.class); + counters.add(Counters.BAZ, 41L); + counters.add(Counters.BAZ, 1L); + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(42L, drained.get(Counters.BAZ)); + } + + @Test + void accumulateAndResetsSoASecondDrainIsZero() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + + Accumulator.Counts first = counters.accumulateAndReset(); + assertEquals(1L, first.get(Counters.FOO)); + + Accumulator.Counts second = counters.accumulateAndReset(); + for (Counters c : Counters.values()) { + assertEquals(0L, second.get(c)); + } + } + + @Test + void concurrentIncrementsAreNotLost() throws InterruptedException { + Accumulator counters = Accumulator.of(Counters.class); + int threadCount = 16; + int incrementsPerThread = 10_000; + + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + try { + for (int t = 0; t < threadCount; t++) { + pool.execute(() -> { + try { + start.await(); + for (int i = 0; i < incrementsPerThread; i++) { + counters.inc(Counters.FOO); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + pool.shutdown(); + } + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals((long) threadCount * incrementsPerThread, drained.get(Counters.FOO)); } - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals((long) threadCount * incrementsPerThread, drained.get(Counters.FOO)); - } - - @Test - void concurrentAccumulateAndDuringWritesNeverExceedsWritten() - throws InterruptedException, ExecutionException, TimeoutException { - Accumulator counters = Accumulator.of(Counters.class); - int threadCount = 8; - int incrementsPerThread = 5_000; - - ExecutorService pool = Executors.newFixedThreadPool(threadCount + 1); - CountDownLatch done = new CountDownLatch(threadCount); - AtomicBoolean stop = new AtomicBoolean(false); - long[] runningTotal = {0L}; - - try { - Future drainer = - pool.submit( - () -> { + @Test + void concurrentAccumulateAndDuringWritesNeverExceedsWritten() + throws InterruptedException, ExecutionException, TimeoutException { + Accumulator counters = Accumulator.of(Counters.class); + int threadCount = 8; + int incrementsPerThread = 5_000; + + ExecutorService pool = Executors.newFixedThreadPool(threadCount + 1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicBoolean stop = new AtomicBoolean(false); + long[] runningTotal = {0L}; + + try { + Future drainer = pool.submit(() -> { while (!stop.get()) { - Accumulator.Counts drained = counters.accumulateAndReset(); - synchronized (runningTotal) { - runningTotal[0] += drained.get(Counters.FOO); - } + Accumulator.Counts drained = counters.accumulateAndReset(); + synchronized (runningTotal) { + runningTotal[0] += drained.get(Counters.FOO); + } } - }); - - for (int t = 0; t < threadCount; t++) { - pool.execute( - () -> { - for (int i = 0; i < incrementsPerThread; i++) { - counters.inc(Counters.FOO); - } - done.countDown(); }); - } - assertTrue(done.await(30, TimeUnit.SECONDS)); - stop.set(true); - drainer.get(30, TimeUnit.SECONDS); + for (int t = 0; t < threadCount; t++) { + pool.execute(() -> { + for (int i = 0; i < incrementsPerThread; i++) { + counters.inc(Counters.FOO); + } + done.countDown(); + }); + } + + assertTrue(done.await(30, TimeUnit.SECONDS)); + stop.set(true); + drainer.get(30, TimeUnit.SECONDS); + + Accumulator.Counts finalDrain = counters.accumulateAndReset(); + synchronized (runningTotal) { + runningTotal[0] += finalDrain.get(Counters.FOO); + } + + assertEquals((long) threadCount * incrementsPerThread, runningTotal[0]); + } finally { + pool.shutdown(); + } + } + + @Test + void sumDoesNotResetStripes() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + + Accumulator.Counts first = counters.sum(); + assertEquals(1L, first.get(Counters.FOO)); + + // sum() didn't reset anything, so a second sum() sees the same total + Accumulator.Counts second = counters.sum(); + assertEquals(1L, second.get(Counters.FOO)); + + // and a real drain afterwards still sees the value sum() didn't consume + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(1L, drained.get(Counters.FOO)); + } + + @Test + void sumReflectsIncrementsMadeAfterAnEarlierSum() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + counters.sum(); + + counters.inc(Counters.FOO); + Accumulator.Counts second = counters.sum(); + assertEquals(2L, second.get(Counters.FOO)); + } + + @Test + void createSeedsAnAllZeroCountsWithoutAScratchAccumulator() { + Accumulator.Counts zero = Accumulator.Counts.create(Counters.class); + assertEquals(0L, zero.get(Counters.FOO)); + assertEquals(0L, zero.get(Counters.BAR)); + + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); - Accumulator.Counts finalDrain = counters.accumulateAndReset(); - synchronized (runningTotal) { - runningTotal[0] += finalDrain.get(Counters.FOO); - } + Accumulator.Counts live = zero.plus(counters.sum()); + assertEquals(1L, live.get(Counters.FOO)); + } + + @Test + void countsExposesItsOwnKeysWithoutASeparateValuesArray() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + counters.add(Counters.BAR, 5L); + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(Counters.values().length, drained.keys().size()); + + long total = 0L; + for (Counters c : drained.keys()) { + total += drained.get(c); + } + assertEquals(6L, total); + } + + @Test + void keysIsUnmodifiable() { + Accumulator counters = Accumulator.of(Counters.class); + Accumulator.Counts drained = counters.accumulateAndReset(); + assertThrows(UnsupportedOperationException.class, () -> drained.keys().set(0, Counters.BAZ)); + } + + @Test + void plusCombinesAStoredRunningTotalWithALiveSumWithoutMutatingEither() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + counters.add(Counters.BAR, 5L); + + // drain once, e.g. as if a reporting cycle already ran and stored this total + Accumulator.Counts storedTotal = counters.accumulateAndReset(); + + // more activity happens after that drain, before the next one + counters.inc(Counters.FOO); + + Accumulator.Counts live = storedTotal.plus(counters.sum()); + assertEquals(2L, live.get(Counters.FOO)); + assertEquals(5L, live.get(Counters.BAR)); + + // neither input was mutated by combining them + assertEquals(1L, storedTotal.get(Counters.FOO)); + assertEquals(1L, counters.sum().get(Counters.FOO)); + } - assertEquals((long) threadCount * incrementsPerThread, runningTotal[0]); - } finally { - pool.shutdown(); + @Test + void fromZeroesEveryEntryBeforeTheGivenIndex() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + counters.add(Counters.BAR, 4L); + counters.add(Counters.BAZ, 2L); + + Accumulator.Counts drained = counters.accumulateAndReset(); + Accumulator.Counts remaining = drained.from(Counters.BAR.ordinal()); + + assertEquals(0L, remaining.get(Counters.FOO)); + assertEquals(4L, remaining.get(Counters.BAR)); + assertEquals(2L, remaining.get(Counters.BAZ)); + + // the original Counts is untouched by taking a remainder from it + assertEquals(1L, drained.get(Counters.FOO)); } - } - - @Test - void sumDoesNotResetStripes() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - - Accumulator.Counts first = counters.sum(); - assertEquals(1L, first.get(Counters.FOO)); - - // sum() didn't reset anything, so a second sum() sees the same total - Accumulator.Counts second = counters.sum(); - assertEquals(1L, second.get(Counters.FOO)); - - // and a real drain afterwards still sees the value sum() didn't consume - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(1L, drained.get(Counters.FOO)); - } - - @Test - void sumReflectsIncrementsMadeAfterAnEarlierSum() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - counters.sum(); - - counters.inc(Counters.FOO); - Accumulator.Counts second = counters.sum(); - assertEquals(2L, second.get(Counters.FOO)); - } - - @Test - void createSeedsAnAllZeroCountsWithoutAScratchAccumulator() { - Accumulator.Counts zero = Accumulator.Counts.create(Counters.class); - assertEquals(0L, zero.get(Counters.FOO)); - assertEquals(0L, zero.get(Counters.BAR)); - - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - - Accumulator.Counts live = zero.plus(counters.sum()); - assertEquals(1L, live.get(Counters.FOO)); - } - - @Test - void countsExposesItsOwnKeysWithoutASeparateValuesArray() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - counters.add(Counters.BAR, 5L); - - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(Counters.values().length, drained.keys().size()); - - long total = 0L; - for (Counters c : drained.keys()) { - total += drained.get(c); + + @Test + void runningTotalSeedsFromTheAccumulatorsCurrentSum() { + Accumulator counters = Accumulator.of(Counters.class); + counters.inc(Counters.FOO); + + Accumulator.RunningTotal runningTotal = Accumulator.RunningTotal.of(counters); + assertEquals(1L, runningTotal.live().get(Counters.FOO)); + } + + @Test + void runningTotalDrainReturnsTheDeltaAndFoldsItIntoTheTotal() { + Accumulator counters = Accumulator.of(Counters.class); + Accumulator.RunningTotal runningTotal = Accumulator.RunningTotal.of(counters); + + counters.inc(Counters.FOO); + Accumulator.Counts delta = runningTotal.drain(); + assertEquals(1L, delta.get(Counters.FOO)); + assertEquals(1L, runningTotal.live().get(Counters.FOO)); + + counters.inc(Counters.FOO); + Accumulator.Counts secondDelta = runningTotal.drain(); + assertEquals(1L, secondDelta.get(Counters.FOO)); + assertEquals(2L, runningTotal.live().get(Counters.FOO)); + } + + @Test + void runningTotalLiveReflectsActivitySinceTheLastDrainWithoutDraining() { + Accumulator counters = Accumulator.of(Counters.class); + Accumulator.RunningTotal runningTotal = Accumulator.RunningTotal.of(counters); + + counters.inc(Counters.FOO); + runningTotal.drain(); + + counters.inc(Counters.FOO); + assertEquals(2L, runningTotal.live().get(Counters.FOO)); + // live() didn't drain anything, so a real drain afterwards still sees the pending increment + assertEquals(1L, runningTotal.drain().get(Counters.FOO)); } - assertEquals(6L, total); - } - - @Test - void keysIsUnmodifiable() { - Accumulator counters = Accumulator.of(Counters.class); - Accumulator.Counts drained = counters.accumulateAndReset(); - assertThrows(UnsupportedOperationException.class, () -> drained.keys().set(0, Counters.BAZ)); - } - - @Test - void plusCombinesAStoredRunningTotalWithALiveSumWithoutMutatingEither() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - counters.add(Counters.BAR, 5L); - - // drain once, e.g. as if a reporting cycle already ran and stored this total - Accumulator.Counts storedTotal = counters.accumulateAndReset(); - - // more activity happens after that drain, before the next one - counters.inc(Counters.FOO); - - Accumulator.Counts live = storedTotal.plus(counters.sum()); - assertEquals(2L, live.get(Counters.FOO)); - assertEquals(5L, live.get(Counters.BAR)); - - // neither input was mutated by combining them - assertEquals(1L, storedTotal.get(Counters.FOO)); - assertEquals(1L, counters.sum().get(Counters.FOO)); - } - - @Test - void fromZeroesEveryEntryBeforeTheGivenIndex() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - counters.add(Counters.BAR, 4L); - counters.add(Counters.BAZ, 2L); - - Accumulator.Counts drained = counters.accumulateAndReset(); - Accumulator.Counts remaining = drained.from(Counters.BAR.ordinal()); - - assertEquals(0L, remaining.get(Counters.FOO)); - assertEquals(4L, remaining.get(Counters.BAR)); - assertEquals(2L, remaining.get(Counters.BAZ)); - - // the original Counts is untouched by taking a remainder from it - assertEquals(1L, drained.get(Counters.FOO)); - } - - @Test - void runningTotalSeedsFromTheAccumulatorsCurrentSum() { - Accumulator counters = Accumulator.of(Counters.class); - counters.inc(Counters.FOO); - - Accumulator.RunningTotal runningTotal = Accumulator.RunningTotal.of(counters); - assertEquals(1L, runningTotal.live().get(Counters.FOO)); - } - - @Test - void runningTotalDrainReturnsTheDeltaAndFoldsItIntoTheTotal() { - Accumulator counters = Accumulator.of(Counters.class); - Accumulator.RunningTotal runningTotal = Accumulator.RunningTotal.of(counters); - - counters.inc(Counters.FOO); - Accumulator.Counts delta = runningTotal.drain(); - assertEquals(1L, delta.get(Counters.FOO)); - assertEquals(1L, runningTotal.live().get(Counters.FOO)); - - counters.inc(Counters.FOO); - Accumulator.Counts secondDelta = runningTotal.drain(); - assertEquals(1L, secondDelta.get(Counters.FOO)); - assertEquals(2L, runningTotal.live().get(Counters.FOO)); - } - - @Test - void runningTotalLiveReflectsActivitySinceTheLastDrainWithoutDraining() { - Accumulator counters = Accumulator.of(Counters.class); - Accumulator.RunningTotal runningTotal = Accumulator.RunningTotal.of(counters); - - counters.inc(Counters.FOO); - runningTotal.drain(); - - counters.inc(Counters.FOO); - assertEquals(2L, runningTotal.live().get(Counters.FOO)); - // live() didn't drain anything, so a real drain afterwards still sees the pending increment - assertEquals(1L, runningTotal.drain().get(Counters.FOO)); - } } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/HistogramsTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/HistogramsTest.java index ae5b4c783bb..ba6938b1a5b 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/HistogramsTest.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/HistogramsTest.java @@ -12,52 +12,52 @@ class HistogramsTest { - @AfterEach - void resetFactory() { - Histograms.factory = Histograms.NO_OP; - } - - @Test - void constructorIsUsable() { - assertNotNull(new Histograms()); - } - - @Test - void registerIgnoresNull() { - Histograms.Factory before = Histograms.factory; - Histograms.register(null); - assertSame(before, Histograms.factory); - } - - @Test - void registerReplacesFactory() { - Histograms.Factory factory = mock(Histograms.Factory.class); - Histograms.register(factory); - assertSame(factory, Histograms.factory); - } - - @Test - void staticFactoryMethodsDelegateToRegisteredFactory() { - Histograms.Factory factory = mock(Histograms.Factory.class); - Histogram histogram = mock(Histogram.class); - Histogram logHistogram = mock(Histogram.class); - Histogram histogramWithBins = mock(Histogram.class); - HistogramWithSum histogramWithSum = mock(HistogramWithSum.class); - HistogramWithSum histogramWithSumFromBoundaries = mock(HistogramWithSum.class); - - when(factory.newHistogram()).thenReturn(histogram); - when(factory.newLogHistogram()).thenReturn(logHistogram); - when(factory.newHistogram(0.01, 10)).thenReturn(histogramWithBins); - when(factory.newHistogramWithSum(0.01, 10)).thenReturn(histogramWithSum); - List boundaries = Arrays.asList(1.0, 2.0); - when(factory.newHistogramWithSum(boundaries)).thenReturn(histogramWithSumFromBoundaries); - - Histograms.register(factory); - - assertSame(histogram, Histogram.newHistogram()); - assertSame(logHistogram, Histogram.newLogHistogram()); - assertSame(histogramWithBins, Histogram.newHistogram(0.01, 10)); - assertSame(histogramWithSum, Histogram.newHistogramWithSum(0.01, 10)); - assertSame(histogramWithSumFromBoundaries, Histogram.newHistogramWithSum(boundaries)); - } + @AfterEach + void resetFactory() { + Histograms.factory = Histograms.NO_OP; + } + + @Test + void constructorIsUsable() { + assertNotNull(new Histograms()); + } + + @Test + void registerIgnoresNull() { + Histograms.Factory before = Histograms.factory; + Histograms.register(null); + assertSame(before, Histograms.factory); + } + + @Test + void registerReplacesFactory() { + Histograms.Factory factory = mock(Histograms.Factory.class); + Histograms.register(factory); + assertSame(factory, Histograms.factory); + } + + @Test + void staticFactoryMethodsDelegateToRegisteredFactory() { + Histograms.Factory factory = mock(Histograms.Factory.class); + Histogram histogram = mock(Histogram.class); + Histogram logHistogram = mock(Histogram.class); + Histogram histogramWithBins = mock(Histogram.class); + HistogramWithSum histogramWithSum = mock(HistogramWithSum.class); + HistogramWithSum histogramWithSumFromBoundaries = mock(HistogramWithSum.class); + + when(factory.newHistogram()).thenReturn(histogram); + when(factory.newLogHistogram()).thenReturn(logHistogram); + when(factory.newHistogram(0.01, 10)).thenReturn(histogramWithBins); + when(factory.newHistogramWithSum(0.01, 10)).thenReturn(histogramWithSum); + List boundaries = Arrays.asList(1.0, 2.0); + when(factory.newHistogramWithSum(boundaries)).thenReturn(histogramWithSumFromBoundaries); + + Histograms.register(factory); + + assertSame(histogram, Histogram.newHistogram()); + assertSame(logHistogram, Histogram.newLogHistogram()); + assertSame(histogramWithBins, Histogram.newHistogram(0.01, 10)); + assertSame(histogramWithSum, Histogram.newHistogramWithSum(0.01, 10)); + assertSame(histogramWithSumFromBoundaries, Histogram.newHistogramWithSum(boundaries)); + } } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/RecordingTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/RecordingTest.java index a4c4906cd2d..ebe84f5ab36 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/RecordingTest.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/RecordingTest.java @@ -6,32 +6,32 @@ class RecordingTest { - @Test - void closeDelegatesToStop() { - StoppableRecording recording = new StoppableRecording(); + @Test + void closeDelegatesToStop() { + StoppableRecording recording = new StoppableRecording(); - recording.close(); + recording.close(); - assertTrue(recording.stopped); - } + assertTrue(recording.stopped); + } - private static final class StoppableRecording extends Recording { - boolean stopped; + private static final class StoppableRecording extends Recording { + boolean stopped; - @Override - public Recording start() { - return this; - } + @Override + public Recording start() { + return this; + } - @Override - public void reset() {} + @Override + public void reset() {} - @Override - public void stop() { - stopped = true; - } + @Override + public void stop() { + stopped = true; + } - @Override - public void flush() {} - } + @Override + public void flush() {} + } } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java index f017a134086..8584905d0ee 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java @@ -6,58 +6,57 @@ /** Test fake that records every {@link #count} call for assertion. */ final class RecordingStatsDClient implements StatsDClient { - static final class Count { - final String metricName; - final long delta; - final String[] tags; - - Count(String metricName, long delta, String[] tags) { - this.metricName = metricName; - this.delta = delta; - this.tags = tags; + static final class Count { + final String metricName; + final long delta; + final String[] tags; + + Count(String metricName, long delta, String[] tags) { + this.metricName = metricName; + this.delta = delta; + this.tags = tags; + } } - } - final List counts = new ArrayList<>(); + final List counts = new ArrayList<>(); - @Override - public void incrementCounter(String metricName, String... tags) {} + @Override + public void incrementCounter(String metricName, String... tags) {} - @Override - public void count(String metricName, long delta, String... tags) { - counts.add(new Count(metricName, delta, tags)); - } + @Override + public void count(String metricName, long delta, String... tags) { + counts.add(new Count(metricName, delta, tags)); + } - @Override - public void gauge(String metricName, long value, String... tags) {} + @Override + public void gauge(String metricName, long value, String... tags) {} - @Override - public void gauge(String metricName, double value, String... tags) {} + @Override + public void gauge(String metricName, double value, String... tags) {} - @Override - public void histogram(String metricName, long value, String... tags) {} + @Override + public void histogram(String metricName, long value, String... tags) {} - @Override - public void histogram(String metricName, double value, String... tags) {} + @Override + public void histogram(String metricName, double value, String... tags) {} - @Override - public void distribution(String metricName, long value, String... tags) {} + @Override + public void distribution(String metricName, long value, String... tags) {} - @Override - public void distribution(String metricName, double value, String... tags) {} + @Override + public void distribution(String metricName, double value, String... tags) {} - @Override - public void serviceCheck( - String serviceCheckName, String status, String message, String... tags) {} + @Override + public void serviceCheck(String serviceCheckName, String status, String message, String... tags) {} - @Override - public void error(Exception error) {} + @Override + public void error(Exception error) {} - @Override - public int getErrorCount() { - return 0; - } + @Override + public int getErrorCount() { + return 0; + } - @Override - public void close() {} + @Override + public void close() {} } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientManagerTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientManagerTest.java index e37497e8293..98f0a21ede9 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientManagerTest.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientManagerTest.java @@ -6,35 +6,35 @@ class StatsDClientManagerTest { - @Test - void defaultFiveArgOverloadDelegatesWithAggregationEnabled() { - StatsDClient client = StatsDClient.NO_OP; - RecordingStatsDClientManager manager = new RecordingStatsDClientManager(client); + @Test + void defaultFiveArgOverloadDelegatesWithAggregationEnabled() { + StatsDClient client = StatsDClient.NO_OP; + RecordingStatsDClientManager manager = new RecordingStatsDClientManager(client); - StatsDClient result = manager.statsDClient("host", 8125, null, "ns", new String[] {"tag"}); + StatsDClient result = manager.statsDClient("host", 8125, null, "ns", new String[] {"tag"}); - assertSame(client, result); - assertSame(Boolean.TRUE, manager.lastUseAggregation); - } - - private static final class RecordingStatsDClientManager implements StatsDClientManager { - private final StatsDClient client; - private Boolean lastUseAggregation; - - RecordingStatsDClientManager(StatsDClient client) { - this.client = client; + assertSame(client, result); + assertSame(Boolean.TRUE, manager.lastUseAggregation); } - @Override - public StatsDClient statsDClient( - String host, - Integer port, - String namedPipe, - String namespace, - String[] constantTags, - boolean useAggregation) { - this.lastUseAggregation = useAggregation; - return client; + private static final class RecordingStatsDClientManager implements StatsDClientManager { + private final StatsDClient client; + private Boolean lastUseAggregation; + + RecordingStatsDClientManager(StatsDClient client) { + this.client = client; + } + + @Override + public StatsDClient statsDClient( + String host, + Integer port, + String namedPipe, + String namespace, + String[] constantTags, + boolean useAggregation) { + this.lastUseAggregation = useAggregation; + return client; + } } - } } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientTest.java index 9624e7b41bf..981da1da3e0 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientTest.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDClientTest.java @@ -6,56 +6,55 @@ class StatsDClientTest { - @Test - void noOpConstantIsUsable() { - assertNotNull(StatsDClient.NO_OP); - } + @Test + void noOpConstantIsUsable() { + assertNotNull(StatsDClient.NO_OP); + } + + @Test + void defaultRecordEventIsNoOp() { + StatsDClient client = new MinimalStatsDClient(); - @Test - void defaultRecordEventIsNoOp() { - StatsDClient client = new MinimalStatsDClient(); + client.recordEvent("type", "source", "eventName", "message", "tag"); + } - client.recordEvent("type", "source", "eventName", "message", "tag"); - } + private static final class MinimalStatsDClient implements StatsDClient { + @Override + public void incrementCounter(String metricName, String... tags) {} - private static final class MinimalStatsDClient implements StatsDClient { - @Override - public void incrementCounter(String metricName, String... tags) {} + @Override + public void count(String metricName, long delta, String... tags) {} - @Override - public void count(String metricName, long delta, String... tags) {} + @Override + public void gauge(String metricName, long value, String... tags) {} - @Override - public void gauge(String metricName, long value, String... tags) {} + @Override + public void gauge(String metricName, double value, String... tags) {} - @Override - public void gauge(String metricName, double value, String... tags) {} + @Override + public void histogram(String metricName, long value, String... tags) {} - @Override - public void histogram(String metricName, long value, String... tags) {} + @Override + public void histogram(String metricName, double value, String... tags) {} - @Override - public void histogram(String metricName, double value, String... tags) {} + @Override + public void distribution(String metricName, long value, String... tags) {} - @Override - public void distribution(String metricName, long value, String... tags) {} + @Override + public void distribution(String metricName, double value, String... tags) {} - @Override - public void distribution(String metricName, double value, String... tags) {} + @Override + public void serviceCheck(String serviceCheckName, String status, String message, String... tags) {} - @Override - public void serviceCheck( - String serviceCheckName, String status, String message, String... tags) {} + @Override + public void error(Exception error) {} - @Override - public void error(Exception error) {} + @Override + public int getErrorCount() { + return 0; + } - @Override - public int getErrorCount() { - return 0; + @Override + public void close() {} } - - @Override - public void close() {} - } } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java index 9324fa62aa3..2ab0fd518f9 100644 --- a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java @@ -14,194 +14,192 @@ class StatsDCountReporterTest { - private static final String[] TAG_A = {"env:a"}; - private static final String[] TAG_B = {"env:b"}; + private static final String[] TAG_A = {"env:a"}; + private static final String[] TAG_B = {"env:b"}; + + enum Counters implements StatsDCounterKey { + FOO("foo.total", TAG_A), + BAR("bar.total", TAG_A), + SHARED_A("shared.total", TAG_A), + SHARED_B("shared.total", TAG_B); + + private final String metricName; + private final String[] tags; + + Counters(String metricName, String[] tags) { + this.metricName = metricName; + this.tags = tags; + } + + @Override + public String getMetricName() { + return metricName; + } + + @Override + public String[] getTags() { + return tags; + } + } + + @Test + void reportsNonZeroCounterWithItsOwnMetricNameAndTags() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + Map deltas = new HashMap<>(); + deltas.put(Counters.FOO, 3L); + + StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); + + assertEquals(1, statsD.counts.size()); + RecordingStatsDClient.Count count = statsD.counts.get(0); + assertEquals("foo.total", count.metricName); + assertEquals(3L, count.delta); + assertArrayEquals(TAG_A, count.tags); + } + + @Test + void skipsZeroDeltaCounters() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); - enum Counters implements StatsDCounterKey { - FOO("foo.total", TAG_A), - BAR("bar.total", TAG_A), - SHARED_A("shared.total", TAG_A), - SHARED_B("shared.total", TAG_B); + StatsDCountReporter.report(statsD, Counters.values(), c -> 0L); - private final String metricName; - private final String[] tags; + assertTrue(statsD.counts.isEmpty()); + } + + @Test + void reportsNothingWhenEveryCounterIsZero() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + Map deltas = new HashMap<>(); + + StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); - Counters(String metricName, String[] tags) { - this.metricName = metricName; - this.tags = tags; + assertTrue(statsD.counts.isEmpty()); } - @Override - public String getMetricName() { - return metricName; + @Test + void reportsConstantsSharingAMetricNameIndependentlyByTag() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + Map deltas = new HashMap<>(); + deltas.put(Counters.SHARED_A, 5L); + deltas.put(Counters.SHARED_B, 7L); + + StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); + + assertEquals(2, statsD.counts.size()); + RecordingStatsDClient.Count a = statsD.counts.get(0); + RecordingStatsDClient.Count b = statsD.counts.get(1); + assertEquals("shared.total", a.metricName); + assertEquals(5L, a.delta); + assertArrayEquals(TAG_A, a.tags); + assertEquals("shared.total", b.metricName); + assertEquals(7L, b.delta); + assertArrayEquals(TAG_B, b.tags); } - @Override - public String[] getTags() { - return tags; + @Test + void flushDrainsAndReportsWithoutPerturbingTheCumulativeLiveTotal() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + StatsDCountReporter reporter = StatsDCountReporter.of(statsD, Counters.class); + reporter.inc(Counters.FOO); + reporter.add(Counters.BAR, 4L); + + reporter.flush(); + + assertEquals(2, statsD.counts.size()); + // live() is a cumulative total (real events observed), not "since the last flush" -- it + // doesn't reset just because a flush drained and reported the delta. + Accumulator.Counts live = reporter.live(); + assertEquals(1L, live.get(Counters.FOO)); + assertEquals(4L, live.get(Counters.BAR)); + + reporter.flush(); + + // a second flush with nothing new to report doesn't inflate the cumulative total either. + assertEquals(2, statsD.counts.size()); + live = reporter.live(); + assertEquals(1L, live.get(Counters.FOO)); + assertEquals(4L, live.get(Counters.BAR)); } - } - - @Test - void reportsNonZeroCounterWithItsOwnMetricNameAndTags() { - RecordingStatsDClient statsD = new RecordingStatsDClient(); - Map deltas = new HashMap<>(); - deltas.put(Counters.FOO, 3L); - - StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); - - assertEquals(1, statsD.counts.size()); - RecordingStatsDClient.Count count = statsD.counts.get(0); - assertEquals("foo.total", count.metricName); - assertEquals(3L, count.delta); - assertArrayEquals(TAG_A, count.tags); - } - - @Test - void skipsZeroDeltaCounters() { - RecordingStatsDClient statsD = new RecordingStatsDClient(); - - StatsDCountReporter.report(statsD, Counters.values(), c -> 0L); - - assertTrue(statsD.counts.isEmpty()); - } - - @Test - void reportsNothingWhenEveryCounterIsZero() { - RecordingStatsDClient statsD = new RecordingStatsDClient(); - Map deltas = new HashMap<>(); - - StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); - - assertTrue(statsD.counts.isEmpty()); - } - - @Test - void reportsConstantsSharingAMetricNameIndependentlyByTag() { - RecordingStatsDClient statsD = new RecordingStatsDClient(); - Map deltas = new HashMap<>(); - deltas.put(Counters.SHARED_A, 5L); - deltas.put(Counters.SHARED_B, 7L); - - StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); - - assertEquals(2, statsD.counts.size()); - RecordingStatsDClient.Count a = statsD.counts.get(0); - RecordingStatsDClient.Count b = statsD.counts.get(1); - assertEquals("shared.total", a.metricName); - assertEquals(5L, a.delta); - assertArrayEquals(TAG_A, a.tags); - assertEquals("shared.total", b.metricName); - assertEquals(7L, b.delta); - assertArrayEquals(TAG_B, b.tags); - } - - @Test - void flushDrainsAndReportsWithoutPerturbingTheCumulativeLiveTotal() { - RecordingStatsDClient statsD = new RecordingStatsDClient(); - StatsDCountReporter reporter = StatsDCountReporter.of(statsD, Counters.class); - reporter.inc(Counters.FOO); - reporter.add(Counters.BAR, 4L); - - reporter.flush(); - - assertEquals(2, statsD.counts.size()); - // live() is a cumulative total (real events observed), not "since the last flush" -- it - // doesn't reset just because a flush drained and reported the delta. - Accumulator.Counts live = reporter.live(); - assertEquals(1L, live.get(Counters.FOO)); - assertEquals(4L, live.get(Counters.BAR)); - - reporter.flush(); - - // a second flush with nothing new to report doesn't inflate the cumulative total either. - assertEquals(2, statsD.counts.size()); - live = reporter.live(); - assertEquals(1L, live.get(Counters.FOO)); - assertEquals(4L, live.get(Counters.BAR)); - } - - @Test - void flushCompensatesCountersNotConfirmedDeliveredAfterAnException() { - List delivered = new ArrayList<>(); - AtomicBoolean failNextBar = new AtomicBoolean(true); - StatsDClient failsOnBarOnce = - new StatsDClient() { - @Override - public void incrementCounter(String metricName, String... tags) {} - - @Override - public void count(String metricName, long delta, String... tags) { - if (metricName.equals("bar.total") && failNextBar.compareAndSet(true, false)) { - throw new RuntimeException("boom"); + + @Test + void flushCompensatesCountersNotConfirmedDeliveredAfterAnException() { + List delivered = new ArrayList<>(); + AtomicBoolean failNextBar = new AtomicBoolean(true); + StatsDClient failsOnBarOnce = new StatsDClient() { + @Override + public void incrementCounter(String metricName, String... tags) {} + + @Override + public void count(String metricName, long delta, String... tags) { + if (metricName.equals("bar.total") && failNextBar.compareAndSet(true, false)) { + throw new RuntimeException("boom"); + } + delivered.add(new RecordingStatsDClient.Count(metricName, delta, tags)); } - delivered.add(new RecordingStatsDClient.Count(metricName, delta, tags)); - } - @Override - public void gauge(String metricName, long value, String... tags) {} + @Override + public void gauge(String metricName, long value, String... tags) {} - @Override - public void gauge(String metricName, double value, String... tags) {} + @Override + public void gauge(String metricName, double value, String... tags) {} - @Override - public void histogram(String metricName, long value, String... tags) {} + @Override + public void histogram(String metricName, long value, String... tags) {} - @Override - public void histogram(String metricName, double value, String... tags) {} + @Override + public void histogram(String metricName, double value, String... tags) {} - @Override - public void distribution(String metricName, long value, String... tags) {} + @Override + public void distribution(String metricName, long value, String... tags) {} - @Override - public void distribution(String metricName, double value, String... tags) {} + @Override + public void distribution(String metricName, double value, String... tags) {} - @Override - public void serviceCheck( - String serviceCheckName, String status, String message, String... tags) {} + @Override + public void serviceCheck(String serviceCheckName, String status, String message, String... tags) {} - @Override - public void error(Exception error) {} + @Override + public void error(Exception error) {} - @Override - public int getErrorCount() { - return 0; - } + @Override + public int getErrorCount() { + return 0; + } - @Override - public void close() {} + @Override + public void close() {} }; - StatsDCountReporter reporter = StatsDCountReporter.of(failsOnBarOnce, Counters.class); - reporter.inc(Counters.FOO); - reporter.add(Counters.BAR, 4L); - reporter.add(Counters.SHARED_A, 2L); - - reporter.flush(); - - assertEquals(1, delivered.size()); - assertEquals("foo.total", delivered.get(0).metricName); - - // the cumulative live total counts every real event exactly once, whether or not statsd ever - // received it -- compensating for the failed send must not inflate this. - Accumulator.Counts live = reporter.live(); - assertEquals(1L, live.get(Counters.FOO)); - assertEquals(4L, live.get(Counters.BAR)); - assertEquals(2L, live.get(Counters.SHARED_A)); - - delivered.clear(); - reporter.flush(); - - assertEquals(2, delivered.size()); - assertEquals("bar.total", delivered.get(0).metricName); - assertEquals(4L, delivered.get(0).delta); - assertEquals("shared.total", delivered.get(1).metricName); - assertEquals(2L, delivered.get(1).delta); - - // still exactly-once in the cumulative total after the retry succeeds. - live = reporter.live(); - assertEquals(1L, live.get(Counters.FOO)); - assertEquals(4L, live.get(Counters.BAR)); - assertEquals(2L, live.get(Counters.SHARED_A)); - } + StatsDCountReporter reporter = StatsDCountReporter.of(failsOnBarOnce, Counters.class); + reporter.inc(Counters.FOO); + reporter.add(Counters.BAR, 4L); + reporter.add(Counters.SHARED_A, 2L); + + reporter.flush(); + + assertEquals(1, delivered.size()); + assertEquals("foo.total", delivered.get(0).metricName); + + // the cumulative live total counts every real event exactly once, whether or not statsd ever + // received it -- compensating for the failed send must not inflate this. + Accumulator.Counts live = reporter.live(); + assertEquals(1L, live.get(Counters.FOO)); + assertEquals(4L, live.get(Counters.BAR)); + assertEquals(2L, live.get(Counters.SHARED_A)); + + delivered.clear(); + reporter.flush(); + + assertEquals(2, delivered.size()); + assertEquals("bar.total", delivered.get(0).metricName); + assertEquals(4L, delivered.get(0).delta); + assertEquals("shared.total", delivered.get(1).metricName); + assertEquals(2L, delivered.get(1).delta); + + // still exactly-once in the cumulative total after the retry succeeds. + live = reporter.live(); + assertEquals(1L, live.get(Counters.FOO)); + assertEquals(4L, live.get(Counters.BAR)); + assertEquals(2L, live.get(Counters.SHARED_A)); + } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogram.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogram.java index bbfab62e3b4..bc491be7172 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogram.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogram.java @@ -11,96 +11,96 @@ /** Wrapper around the DDSketch library so that it can be used in an instrumentation */ public class DDSketchHistogram implements Histogram { - private final DDSketch sketch; + private final DDSketch sketch; - public DDSketchHistogram(DDSketch sketch) { - this.sketch = sketch; - } + public DDSketchHistogram(DDSketch sketch) { + this.sketch = sketch; + } - @Override - public final double getCount() { - return sketch.getCount(); - } + @Override + public final double getCount() { + return sketch.getCount(); + } - @Override - public final boolean isEmpty() { - return sketch.isEmpty(); - } + @Override + public final boolean isEmpty() { + return sketch.isEmpty(); + } - @Override - public void accept(double value) { - sketch.accept(value); - } + @Override + public void accept(double value) { + sketch.accept(value); + } - @Override - public void accept(double value, double count) { - sketch.accept(value, count); - } + @Override + public void accept(double value, double count) { + sketch.accept(value, count); + } - @Override - public final double getValueAtQuantile(double quantile) { - return sketch.getValueAtQuantile(quantile); - } + @Override + public final double getValueAtQuantile(double quantile) { + return sketch.getValueAtQuantile(quantile); + } - @Override - public double getMinValue() { - return sketch.getMinValue(); - } + @Override + public double getMinValue() { + return sketch.getMinValue(); + } - @Override - public double getMaxValue() { - return sketch.getMaxValue(); - } + @Override + public double getMaxValue() { + return sketch.getMaxValue(); + } - @Override - public final List getBinBoundaries() { - List boundaries = new ArrayList<>(); - // Bin boundaries are defined as a sequence of upper bounds. When our sketch contains gaps we - // must introduce extra bounds to represent the lower bound of any bins after a gap; otherwise - // it would look like that bin covers not just its original range, but the gap as well. - int lastBinIndex = -1; - IndexMapping indexMapping = sketch.getIndexMapping(); - Iterator bins = sketch.getPositiveValueStore().getAscendingIterator(); - while (bins.hasNext()) { - Bin bin = bins.next(); - int binIndex = bin.getIndex(); - if (lastBinIndex < binIndex - 1) { - // gap detected, introduce boundary representing current bin's lower bound - boundaries.add(indexMapping.upperBound(binIndex - 1)); - } - boundaries.add(indexMapping.upperBound(binIndex)); - lastBinIndex = binIndex; + @Override + public final List getBinBoundaries() { + List boundaries = new ArrayList<>(); + // Bin boundaries are defined as a sequence of upper bounds. When our sketch contains gaps we + // must introduce extra bounds to represent the lower bound of any bins after a gap; otherwise + // it would look like that bin covers not just its original range, but the gap as well. + int lastBinIndex = -1; + IndexMapping indexMapping = sketch.getIndexMapping(); + Iterator bins = sketch.getPositiveValueStore().getAscendingIterator(); + while (bins.hasNext()) { + Bin bin = bins.next(); + int binIndex = bin.getIndex(); + if (lastBinIndex < binIndex - 1) { + // gap detected, introduce boundary representing current bin's lower bound + boundaries.add(indexMapping.upperBound(binIndex - 1)); + } + boundaries.add(indexMapping.upperBound(binIndex)); + lastBinIndex = binIndex; + } + return boundaries; } - return boundaries; - } - @Override - public final List getBinCounts() { - List counts = new ArrayList<>(); - // to maintain alignment with getBoundaries we must introduce zero counts for - // boundaries inserted to represent the lower bound of any bins after a gap. - int lastBinIndex = -1; - Iterator bins = sketch.getPositiveValueStore().getAscendingIterator(); - while (bins.hasNext()) { - Bin bin = bins.next(); - int binIndex = bin.getIndex(); - if (lastBinIndex < binIndex - 1) { - // gap detected, insert zero count for boundary introduced by getBoundaries - counts.add(0d); - } - counts.add(bin.getCount()); - lastBinIndex = binIndex; + @Override + public final List getBinCounts() { + List counts = new ArrayList<>(); + // to maintain alignment with getBoundaries we must introduce zero counts for + // boundaries inserted to represent the lower bound of any bins after a gap. + int lastBinIndex = -1; + Iterator bins = sketch.getPositiveValueStore().getAscendingIterator(); + while (bins.hasNext()) { + Bin bin = bins.next(); + int binIndex = bin.getIndex(); + if (lastBinIndex < binIndex - 1) { + // gap detected, insert zero count for boundary introduced by getBoundaries + counts.add(0d); + } + counts.add(bin.getCount()); + lastBinIndex = binIndex; + } + return counts; } - return counts; - } - @Override - public void clear() { - sketch.clear(); - } + @Override + public void clear() { + sketch.clear(); + } - @Override - public final ByteBuffer serialize() { - return sketch.serialize(); - } + @Override + public final ByteBuffer serialize() { + return sketch.serialize(); + } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogramWithSum.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogramWithSum.java index 1837ee4e1b2..804aea157c4 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogramWithSum.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistogramWithSum.java @@ -6,80 +6,80 @@ /** Adds exact summary statistics to the DDSketch wrapper */ public final class DDSketchHistogramWithSum extends DDSketchHistogram implements HistogramWithSum { - private double sum; - // We use a compensated sum to avoid accumulating rounding errors. - // See https://en.wikipedia.org/wiki/Kahan_summation_algorithm. - private double sumCompensation; // Low order bits of sum - private double simpleSum; // Used to compute right sum for non-finite inputs - private double min = Double.POSITIVE_INFINITY; - private double max = Double.NEGATIVE_INFINITY; + private double sum; + // We use a compensated sum to avoid accumulating rounding errors. + // See https://en.wikipedia.org/wiki/Kahan_summation_algorithm. + private double sumCompensation; // Low order bits of sum + private double simpleSum; // Used to compute right sum for non-finite inputs + private double min = Double.POSITIVE_INFINITY; + private double max = Double.NEGATIVE_INFINITY; - public DDSketchHistogramWithSum(DDSketch sketch) { - super(sketch); - } + public DDSketchHistogramWithSum(DDSketch sketch) { + super(sketch); + } - @Override - public double getSum() { - // Better error bounds to add both terms as the final sum - final double tmp = sum + sumCompensation; - if (Double.isNaN(tmp) && Double.isInfinite(simpleSum)) { - // If the compensated sum is spuriously NaN from accumulating one or more same-signed infinite - // values, return the correctly-signed infinity stored in simpleSum. - return simpleSum; - } else { - return tmp; + @Override + public double getSum() { + // Better error bounds to add both terms as the final sum + final double tmp = sum + sumCompensation; + if (Double.isNaN(tmp) && Double.isInfinite(simpleSum)) { + // If the compensated sum is spuriously NaN from accumulating one or more same-signed infinite + // values, return the correctly-signed infinity stored in simpleSum. + return simpleSum; + } else { + return tmp; + } } - } - @Override - public double getMinValue() { - if (isEmpty()) { - throw new NoSuchElementException(); + @Override + public double getMinValue() { + if (isEmpty()) { + throw new NoSuchElementException(); + } + return min; } - return min; - } - @Override - public double getMaxValue() { - if (isEmpty()) { - throw new NoSuchElementException(); + @Override + public double getMaxValue() { + if (isEmpty()) { + throw new NoSuchElementException(); + } + return max; } - return max; - } - @Override - public void accept(double value) { - super.accept(value); - updateSummaryStats(value); - } + @Override + public void accept(double value) { + super.accept(value); + updateSummaryStats(value); + } - @Override - public void accept(double value, double count) { - super.accept(value, count); - updateSummaryStats(value * count); - } + @Override + public void accept(double value, double count) { + super.accept(value, count); + updateSummaryStats(value * count); + } - @Override - public void clear() { - super.clear(); - sum = 0; - simpleSum = 0; - sumCompensation = 0; - min = Double.POSITIVE_INFINITY; - max = Double.NEGATIVE_INFINITY; - } + @Override + public void clear() { + super.clear(); + sum = 0; + simpleSum = 0; + sumCompensation = 0; + min = Double.POSITIVE_INFINITY; + max = Double.NEGATIVE_INFINITY; + } - private void updateSummaryStats(double value) { - simpleSum += value; - sumWithCompensation(value); - min = Math.min(min, value); - max = Math.max(max, value); - } + private void updateSummaryStats(double value) { + simpleSum += value; + sumWithCompensation(value); + min = Math.min(min, value); + max = Math.max(max, value); + } - private void sumWithCompensation(double value) { - final double tmp = value - sumCompensation; - final double velvel = sum + tmp; // Little wolf of rounding error - sumCompensation = (velvel - sum) - tmp; - sum = velvel; - } + private void sumWithCompensation(double value) { + final double tmp = value - sumCompensation; + final double velvel = sum + tmp; // Little wolf of rounding error + sumCompensation = (velvel - sum) - tmp; + sum = velvel; + } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistograms.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistograms.java index 8c68013474f..e8bd19a2e21 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistograms.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/DDSketchHistograms.java @@ -16,146 +16,141 @@ import java.util.List; public final class DDSketchHistograms implements Histograms.Factory { - private static final BitwiseLinearlyInterpolatedMapping INDEX_MAPPING = - new BitwiseLinearlyInterpolatedMapping(1.0 / 128.0); - // use the same gamma and index offset as the Datadog backend, to avoid doing - // any conversions in the backend that would lead to a loss of precision - private static final LogarithmicMapping LOG_INDEX_MAPPING = - new LogarithmicMapping(1.015625, 1.8761281912861705); - public static final Histograms.Factory FACTORY = new DDSketchHistograms(); - - private DDSketchHistograms() {} - - @Override - public Histogram newHistogram() { - DDSketch sketch = new DDSketch(INDEX_MAPPING, () -> new CollapsingLowestDenseStore(1024)); - return new DDSketchHistogram(sketch); - } - - @Override - public Histogram newLogHistogram() { - DDSketch sketch = new DDSketch(LOG_INDEX_MAPPING, () -> new CollapsingLowestDenseStore(1024)); - return new DDSketchHistogram(sketch); - } - - @Override - public Histogram newHistogram(double relativeAccuracy, int maxNumBins) { - DDSketch sketch = DDSketches.logarithmicCollapsingLowestDense(relativeAccuracy, maxNumBins); - return new DDSketchHistogram(sketch); - } - - @Override - public HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins) { - DDSketch sketch = DDSketches.logarithmicCollapsingLowestDense(relativeAccuracy, maxNumBins); - return new DDSketchHistogramWithSum(sketch); - } - - @Override - public HistogramWithSum newHistogramWithSum(List binBoundaries) { - validateBoundaries(binBoundaries); - DDSketch sketch = - new DDSketch( - new ExplicitBoundaries(binBoundaries), - () -> new CollapsingLowestDenseStore(0), // negative store not used - () -> new CollapsingLowestDenseStore(binBoundaries.size() + 1), - Double.NEGATIVE_INFINITY); // assign all negative/zero values to first bin - return new DDSketchHistogramWithSum(sketch); - } - - static void validateBoundaries(List boundaries) { - if (boundaries.isEmpty()) { - return; - } - Double previousBoundary = null; - for (Double boundary : boundaries) { - if (boundary == null) { - throw new IllegalArgumentException("invalid bucket boundary: null"); - } - if (boundary.isNaN()) { - throw new IllegalArgumentException("invalid bucket boundary: NaN"); - } - if (previousBoundary != null && previousBoundary >= boundary) { - throw new IllegalArgumentException( - "Bucket boundaries must be in increasing order: " - + previousBoundary - + " >= " - + boundary); - } - previousBoundary = boundary; - } - if (boundaries.get(0) < 0) { - throw new IllegalArgumentException("invalid bucket boundary: " + boundaries.get(0)); - } - if (boundaries.get(boundaries.size() - 1) == Double.POSITIVE_INFINITY) { - throw new IllegalArgumentException("invalid bucket boundary: +Infinity"); - } - } + private static final BitwiseLinearlyInterpolatedMapping INDEX_MAPPING = + new BitwiseLinearlyInterpolatedMapping(1.0 / 128.0); + // use the same gamma and index offset as the Datadog backend, to avoid doing + // any conversions in the backend that would lead to a loss of precision + private static final LogarithmicMapping LOG_INDEX_MAPPING = new LogarithmicMapping(1.015625, 1.8761281912861705); + public static final Histograms.Factory FACTORY = new DDSketchHistograms(); - static final class ExplicitBoundaries implements IndexMapping { - private final List binBoundaries; - - ExplicitBoundaries(List binBoundaries) { - this.binBoundaries = binBoundaries; - } + private DDSketchHistograms() {} @Override - public int index(double value) { - // negative index means no exact match, use '~' to find the closest index - int index = binarySearch(binBoundaries, value); - return index >= 0 ? index : ~index; + public Histogram newHistogram() { + DDSketch sketch = new DDSketch(INDEX_MAPPING, () -> new CollapsingLowestDenseStore(1024)); + return new DDSketchHistogram(sketch); } @Override - public double lowerBound(int index) { - if (index == 0) { - return Double.NEGATIVE_INFINITY; - } else { - return binBoundaries.get(index - 1); - } + public Histogram newLogHistogram() { + DDSketch sketch = new DDSketch(LOG_INDEX_MAPPING, () -> new CollapsingLowestDenseStore(1024)); + return new DDSketchHistogram(sketch); } @Override - public double upperBound(int index) { - if (index < binBoundaries.size()) { - return binBoundaries.get(index); - } else { - return Double.POSITIVE_INFINITY; - } + public Histogram newHistogram(double relativeAccuracy, int maxNumBins) { + DDSketch sketch = DDSketches.logarithmicCollapsingLowestDense(relativeAccuracy, maxNumBins); + return new DDSketchHistogram(sketch); } @Override - public double minIndexableValue() { - return Double.NEGATIVE_INFINITY; + public HistogramWithSum newHistogramWithSum(double relativeAccuracy, int maxNumBins) { + DDSketch sketch = DDSketches.logarithmicCollapsingLowestDense(relativeAccuracy, maxNumBins); + return new DDSketchHistogramWithSum(sketch); } @Override - public double maxIndexableValue() { - return Double.POSITIVE_INFINITY; + public HistogramWithSum newHistogramWithSum(List binBoundaries) { + validateBoundaries(binBoundaries); + DDSketch sketch = new DDSketch( + new ExplicitBoundaries(binBoundaries), + () -> new CollapsingLowestDenseStore(0), // negative store not used + () -> new CollapsingLowestDenseStore(binBoundaries.size() + 1), + Double.NEGATIVE_INFINITY); // assign all negative/zero values to first bin + return new DDSketchHistogramWithSum(sketch); } - @Override - public double value(int index) { - throw new UnsupportedOperationException(); + static void validateBoundaries(List boundaries) { + if (boundaries.isEmpty()) { + return; + } + Double previousBoundary = null; + for (Double boundary : boundaries) { + if (boundary == null) { + throw new IllegalArgumentException("invalid bucket boundary: null"); + } + if (boundary.isNaN()) { + throw new IllegalArgumentException("invalid bucket boundary: NaN"); + } + if (previousBoundary != null && previousBoundary >= boundary) { + throw new IllegalArgumentException( + "Bucket boundaries must be in increasing order: " + previousBoundary + " >= " + boundary); + } + previousBoundary = boundary; + } + if (boundaries.get(0) < 0) { + throw new IllegalArgumentException("invalid bucket boundary: " + boundaries.get(0)); + } + if (boundaries.get(boundaries.size() - 1) == Double.POSITIVE_INFINITY) { + throw new IllegalArgumentException("invalid bucket boundary: +Infinity"); + } } - @Override - public double relativeAccuracy() { - throw new UnsupportedOperationException(); - } - - @Override - public void encode(Output output) { - throw new UnsupportedOperationException(); - } - - @Override - public int serializedSize() { - throw new UnsupportedOperationException(); - } - - @Override - public void serialize(Serializer serializer) { - throw new UnsupportedOperationException(); + static final class ExplicitBoundaries implements IndexMapping { + private final List binBoundaries; + + ExplicitBoundaries(List binBoundaries) { + this.binBoundaries = binBoundaries; + } + + @Override + public int index(double value) { + // negative index means no exact match, use '~' to find the closest index + int index = binarySearch(binBoundaries, value); + return index >= 0 ? index : ~index; + } + + @Override + public double lowerBound(int index) { + if (index == 0) { + return Double.NEGATIVE_INFINITY; + } else { + return binBoundaries.get(index - 1); + } + } + + @Override + public double upperBound(int index) { + if (index < binBoundaries.size()) { + return binBoundaries.get(index); + } else { + return Double.POSITIVE_INFINITY; + } + } + + @Override + public double minIndexableValue() { + return Double.NEGATIVE_INFINITY; + } + + @Override + public double maxIndexableValue() { + return Double.POSITIVE_INFINITY; + } + + @Override + public double value(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public double relativeAccuracy() { + throw new UnsupportedOperationException(); + } + + @Override + public void encode(Output output) { + throw new UnsupportedOperationException(); + } + + @Override + public int serializedSize() { + throw new UnsupportedOperationException(); + } + + @Override + public void serialize(Serializer serializer) { + throw new UnsupportedOperationException(); + } } - } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/MonitoringImpl.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/MonitoringImpl.java index 95dc6dee6e7..45f4230276d 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/MonitoringImpl.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/MonitoringImpl.java @@ -7,33 +7,32 @@ import java.util.concurrent.TimeUnit; public final class MonitoringImpl implements Monitoring { - private final StatsDClient statsd; - private final long flushAfterNanos; + private final StatsDClient statsd; + private final long flushAfterNanos; - public MonitoringImpl(final StatsDClient statsd, long flushInterval, TimeUnit flushUnit) { - this.statsd = statsd; - this.flushAfterNanos = flushUnit.toNanos(flushInterval); - } + public MonitoringImpl(final StatsDClient statsd, long flushInterval, TimeUnit flushUnit) { + this.statsd = statsd; + this.flushAfterNanos = flushUnit.toNanos(flushInterval); + } - @Override - public Recording newTimer(final String name) { - return new Timer(name, statsd, flushAfterNanos); - } + @Override + public Recording newTimer(final String name) { + return new Timer(name, statsd, flushAfterNanos); + } - @Override - public Recording newTimer(final String name, final String... tags) { - return new Timer(name, tags, statsd, flushAfterNanos); - } + @Override + public Recording newTimer(final String name, final String... tags) { + return new Timer(name, tags, statsd, flushAfterNanos); + } - @Override - public Recording newThreadLocalTimer(final String name) { - return new ThreadLocalRecording( - ThreadLocal.withInitial( - () -> newTimer(name, "thread:" + Thread.currentThread().getName()))); - } + @Override + public Recording newThreadLocalTimer(final String name) { + return new ThreadLocalRecording(ThreadLocal.withInitial( + () -> newTimer(name, "thread:" + Thread.currentThread().getName()))); + } - @Override - public Counter newCounter(final String name) { - return new StatsDCounter(name, statsd); - } + @Override + public Counter newCounter(final String name) { + return new StatsDCounter(name, statsd); + } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/StatsDCounter.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/StatsDCounter.java index de597a3a50d..d16a37b7c16 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/StatsDCounter.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/StatsDCounter.java @@ -7,21 +7,21 @@ public final class StatsDCounter implements Counter { - private final String name; - private final String[] tags; - private final StatsDClient statsd; + private final String name; + private final String[] tags; + private final StatsDClient statsd; - StatsDCounter(String name, StatsDClient statsd) { - this.name = name; - this.tags = new String[0]; - this.statsd = statsd; - } + StatsDCounter(String name, StatsDClient statsd) { + this.name = name; + this.tags = new String[0]; + this.statsd = statsd; + } - public void increment(int delta) { - statsd.count(name, delta, tags); - } + public void increment(int delta) { + statsd.count(name, delta, tags); + } - public void incrementErrorCount(String cause, int delta) { - statsd.count(name, delta, mergeTags(tags, new String[] {"cause:" + cause.replace(' ', '_')})); - } + public void incrementErrorCount(String cause, int delta) { + statsd.count(name, delta, mergeTags(tags, new String[] {"cause:" + cause.replace(' ', '_')})); + } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/ThreadLocalRecording.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/ThreadLocalRecording.java index 0785bcbe7dd..7c37491e308 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/ThreadLocalRecording.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/ThreadLocalRecording.java @@ -4,29 +4,29 @@ public class ThreadLocalRecording extends Recording { - private final ThreadLocal tls; - - public ThreadLocalRecording(ThreadLocal tls) { - this.tls = tls; - } - - @Override - public Recording start() { - return tls.get().start(); - } - - @Override - public void reset() { - tls.get().reset(); - } - - @Override - public void stop() { - tls.get().stop(); - } - - @Override - public void flush() { - tls.get().flush(); - } + private final ThreadLocal tls; + + public ThreadLocalRecording(ThreadLocal tls) { + this.tls = tls; + } + + @Override + public Recording start() { + return tls.get().start(); + } + + @Override + public void reset() { + tls.get().reset(); + } + + @Override + public void stop() { + tls.get().stop(); + } + + @Override + public void flush() { + tls.get().flush(); + } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Timer.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Timer.java index 7d6e0e86351..c6aa96e6265 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Timer.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Timer.java @@ -14,76 +14,76 @@ */ public class Timer extends Recording { - private static final long THIRTY_SECONDS_AS_MICROS = SECONDS.toMicros(30); + private static final long THIRTY_SECONDS_AS_MICROS = SECONDS.toMicros(30); - private static final String[] P_50 = new String[] {"stat:p50"}; - private static final String[] P_99 = new String[] {"stat:p99"}; - private static final String[] MAX = new String[] {"stat:max"}; + private static final String[] P_50 = new String[] {"stat:p50"}; + private static final String[] P_99 = new String[] {"stat:p99"}; + private static final String[] MAX = new String[] {"stat:max"}; - private final String name; - private final StatsDClient statsd; - private final Histogram histogram; - private final long flushAfterNanos; + private final String name; + private final StatsDClient statsd; + private final Histogram histogram; + private final long flushAfterNanos; - private final String[] p50Tags; - private final String[] p99Tags; - private final String[] maxTags; + private final String[] p50Tags; + private final String[] p99Tags; + private final String[] maxTags; - private long start; - private long lastFlush = 0; + private long start; + private long lastFlush = 0; - Timer(final String name, final String[] tags, final StatsDClient statsd, long flushAfterNanos) { - this.name = name; - this.statsd = statsd; - this.flushAfterNanos = flushAfterNanos; - this.histogram = Histogram.newHistogram(); - this.p50Tags = mergeTags(P_50, tags); - this.p99Tags = mergeTags(P_99, tags); - this.maxTags = mergeTags(MAX, tags); - } + Timer(final String name, final String[] tags, final StatsDClient statsd, long flushAfterNanos) { + this.name = name; + this.statsd = statsd; + this.flushAfterNanos = flushAfterNanos; + this.histogram = Histogram.newHistogram(); + this.p50Tags = mergeTags(P_50, tags); + this.p99Tags = mergeTags(P_99, tags); + this.maxTags = mergeTags(MAX, tags); + } - Timer(final String name, final StatsDClient statsd, long flushAfterNanos) { - this(name, null, statsd, flushAfterNanos); - } + Timer(final String name, final StatsDClient statsd, long flushAfterNanos) { + this(name, null, statsd, flushAfterNanos); + } - @Override - public Recording start() { - start = System.nanoTime(); - return this; - } + @Override + public Recording start() { + start = System.nanoTime(); + return this; + } - @Override - public void reset() { - long now = System.nanoTime(); - record(now); - start = now; - } + @Override + public void reset() { + long now = System.nanoTime(); + record(now); + start = now; + } - @Override - public void stop() { - record(System.nanoTime()); - } + @Override + public void stop() { + record(System.nanoTime()); + } - private void record(long now) { - // if it's longer than 30s, we have bigger problems - histogram.accept(Math.min(NANOSECONDS.toMicros(now - start), THIRTY_SECONDS_AS_MICROS)); - if (now - lastFlush > flushAfterNanos) { - lastFlush = now; - flush(); + private void record(long now) { + // if it's longer than 30s, we have bigger problems + histogram.accept(Math.min(NANOSECONDS.toMicros(now - start), THIRTY_SECONDS_AS_MICROS)); + if (now - lastFlush > flushAfterNanos) { + lastFlush = now; + flush(); + } } - } - @Override - public void flush() { - if (histogram.isEmpty()) { - statsd.gauge(name, 0D, p50Tags); - statsd.gauge(name, 0D, p99Tags); - statsd.gauge(name, 0D, maxTags); - } else { - statsd.gauge(name, histogram.getValueAtQuantile(0.50), p50Tags); - statsd.gauge(name, histogram.getValueAtQuantile(0.99), p99Tags); - statsd.gauge(name, histogram.getMaxValue(), maxTags); + @Override + public void flush() { + if (histogram.isEmpty()) { + statsd.gauge(name, 0D, p50Tags); + statsd.gauge(name, 0D, p99Tags); + statsd.gauge(name, 0D, maxTags); + } else { + statsd.gauge(name, histogram.getValueAtQuantile(0.50), p50Tags); + statsd.gauge(name, histogram.getValueAtQuantile(0.99), p99Tags); + statsd.gauge(name, histogram.getMaxValue(), maxTags); + } + histogram.clear(); } - histogram.clear(); - } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Utils.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Utils.java index 7a8bb098d34..86b3a240725 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Utils.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/Utils.java @@ -3,14 +3,14 @@ import java.util.Arrays; public final class Utils { - private Utils() {} + private Utils() {} - static String[] mergeTags(String[] left, String[] right) { - if (null == right) { - return left; + static String[] mergeTags(String[] left, String[] right) { + if (null == right) { + return left; + } + String[] merged = Arrays.copyOf(left, left.length + right.length); + System.arraycopy(right, 0, merged, left.length, right.length); + return merged; } - String[] merged = Arrays.copyOf(left, left.length + right.length); - System.arraycopy(right, 0, merged, left.length, right.length); - return merged; - } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClient.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClient.java index 8f319cdef91..94631d75a42 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClient.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClient.java @@ -8,127 +8,115 @@ import org.slf4j.LoggerFactory; public final class DDAgentStatsDClient implements StatsDClient { - private static final Logger log = LoggerFactory.getLogger(DDAgentStatsDClient.class); - private final DDAgentStatsDConnection connection; - private final Function nameMapping; - private final Function tagMapping; - - DDAgentStatsDClient( - final DDAgentStatsDConnection connection, - final Function nameMapping, - final Function tagMapping) { - this.connection = connection; - this.nameMapping = nameMapping; - this.tagMapping = tagMapping; - - connection.acquire(); - } - - @Override - public void incrementCounter(final String metricName, final String... tags) { - connection.statsd.count(nameMapping.apply(metricName), 1L, tagMapping.apply(tags)); - } - - @Override - public void count(final String metricName, final long value, final String... tags) { - connection.statsd.count(nameMapping.apply(metricName), value, tagMapping.apply(tags)); - } - - @Override - public void gauge(final String metricName, final long value, final String... tags) { - connection.statsd.recordGaugeValue( - nameMapping.apply(metricName), value, tagMapping.apply(tags)); - } - - @Override - public void gauge(final String metricName, final double value, final String... tags) { - connection.statsd.recordGaugeValue( - nameMapping.apply(metricName), value, tagMapping.apply(tags)); - } - - @Override - public void histogram(final String metricName, final long value, final String... tags) { - connection.statsd.recordHistogramValue( - nameMapping.apply(metricName), value, tagMapping.apply(tags)); - } - - @Override - public void histogram(final String metricName, final double value, final String... tags) { - connection.statsd.recordHistogramValue( - nameMapping.apply(metricName), value, tagMapping.apply(tags)); - } - - @Override - public void distribution(String metricName, long value, String... tags) { - connection.statsd.recordDistributionValue( - nameMapping.apply(metricName), value, tagMapping.apply(tags)); - } - - @Override - public void distribution(String metricName, double value, String... tags) { - connection.statsd.recordDistributionValue( - nameMapping.apply(metricName), value, tagMapping.apply(tags)); - } - - @Override - public void serviceCheck( - final String serviceCheckName, - final String status, - final String message, - final String... tags) { - - ServiceCheck serviceCheck = - ServiceCheck.builder() - .withName(nameMapping.apply(serviceCheckName)) - .withStatus(serviceCheckStatus(status)) - .withMessage(message) - .withTags(tagMapping.apply(tags)) - .build(); - - connection.statsd.recordServiceCheckRun(serviceCheck); - } - - @Override - public void recordEvent( - String type, String source, String eventName, String message, String... tags) { - Event.AlertType alertType = Event.AlertType.valueOf(type.toUpperCase()); - Event.Builder eventBuilder = - Event.builder() - .withTitle(eventName) - .withText(message) - .withSourceTypeName(source) - .withDate(System.currentTimeMillis()) - .withAlertType(alertType); - connection.statsd.recordEvent(eventBuilder.build(), tagMapping.apply(tags)); - } - - static ServiceCheck.Status serviceCheckStatus(final String status) { - switch (status) { - case "OK": - return ServiceCheck.Status.OK; - case "WARN": - case "WARNING": - return ServiceCheck.Status.WARNING; - case "CRITICAL": - case "ERROR": - return ServiceCheck.Status.CRITICAL; - default: - return ServiceCheck.Status.UNKNOWN; + private static final Logger log = LoggerFactory.getLogger(DDAgentStatsDClient.class); + private final DDAgentStatsDConnection connection; + private final Function nameMapping; + private final Function tagMapping; + + DDAgentStatsDClient( + final DDAgentStatsDConnection connection, + final Function nameMapping, + final Function tagMapping) { + this.connection = connection; + this.nameMapping = nameMapping; + this.tagMapping = tagMapping; + + connection.acquire(); + } + + @Override + public void incrementCounter(final String metricName, final String... tags) { + connection.statsd.count(nameMapping.apply(metricName), 1L, tagMapping.apply(tags)); + } + + @Override + public void count(final String metricName, final long value, final String... tags) { + connection.statsd.count(nameMapping.apply(metricName), value, tagMapping.apply(tags)); + } + + @Override + public void gauge(final String metricName, final long value, final String... tags) { + connection.statsd.recordGaugeValue(nameMapping.apply(metricName), value, tagMapping.apply(tags)); + } + + @Override + public void gauge(final String metricName, final double value, final String... tags) { + connection.statsd.recordGaugeValue(nameMapping.apply(metricName), value, tagMapping.apply(tags)); + } + + @Override + public void histogram(final String metricName, final long value, final String... tags) { + connection.statsd.recordHistogramValue(nameMapping.apply(metricName), value, tagMapping.apply(tags)); + } + + @Override + public void histogram(final String metricName, final double value, final String... tags) { + connection.statsd.recordHistogramValue(nameMapping.apply(metricName), value, tagMapping.apply(tags)); + } + + @Override + public void distribution(String metricName, long value, String... tags) { + connection.statsd.recordDistributionValue(nameMapping.apply(metricName), value, tagMapping.apply(tags)); + } + + @Override + public void distribution(String metricName, double value, String... tags) { + connection.statsd.recordDistributionValue(nameMapping.apply(metricName), value, tagMapping.apply(tags)); + } + + @Override + public void serviceCheck( + final String serviceCheckName, final String status, final String message, final String... tags) { + + ServiceCheck serviceCheck = ServiceCheck.builder() + .withName(nameMapping.apply(serviceCheckName)) + .withStatus(serviceCheckStatus(status)) + .withMessage(message) + .withTags(tagMapping.apply(tags)) + .build(); + + connection.statsd.recordServiceCheckRun(serviceCheck); + } + + @Override + public void recordEvent(String type, String source, String eventName, String message, String... tags) { + Event.AlertType alertType = Event.AlertType.valueOf(type.toUpperCase()); + Event.Builder eventBuilder = Event.builder() + .withTitle(eventName) + .withText(message) + .withSourceTypeName(source) + .withDate(System.currentTimeMillis()) + .withAlertType(alertType); + connection.statsd.recordEvent(eventBuilder.build(), tagMapping.apply(tags)); + } + + static ServiceCheck.Status serviceCheckStatus(final String status) { + switch (status) { + case "OK": + return ServiceCheck.Status.OK; + case "WARN": + case "WARNING": + return ServiceCheck.Status.WARNING; + case "CRITICAL": + case "ERROR": + return ServiceCheck.Status.CRITICAL; + default: + return ServiceCheck.Status.UNKNOWN; + } + } + + @Override + public void error(final Exception error) { + connection.handle(error); + } + + @Override + public int getErrorCount() { + return connection.getErrorCount(); + } + + @Override + public void close() { + connection.release(); } - } - - @Override - public void error(final Exception error) { - connection.handle(error); - } - - @Override - public int getErrorCount() { - return connection.getErrorCount(); - } - - @Override - public void close() { - connection.release(); - } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClientManager.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClientManager.java index 91407f1b7b8..40ddfeb9f92 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClientManager.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDClientManager.java @@ -14,174 +14,170 @@ import java.util.function.Function; public final class DDAgentStatsDClientManager implements StatsDClientManager { - private static final DDAgentStatsDClientManager INSTANCE = new DDAgentStatsDClientManager(); + private static final DDAgentStatsDClientManager INSTANCE = new DDAgentStatsDClientManager(); - private DDAgentStatsDClientManager() {} + private DDAgentStatsDClientManager() {} - private static final boolean USE_LOGGING_CLIENT = - LOGGING_WRITER_TYPE.equals(Config.get().getWriterType()); + private static final boolean USE_LOGGING_CLIENT = + LOGGING_WRITER_TYPE.equals(Config.get().getWriterType()); - public static StatsDClientManager statsDClientManager() { - return INSTANCE; - } + public static StatsDClientManager statsDClientManager() { + return INSTANCE; + } - private static final AtomicInteger defaultStatsDPort = - new AtomicInteger(Config.get().getDogsStatsDPort()); + private static final AtomicInteger defaultStatsDPort = + new AtomicInteger(Config.get().getDogsStatsDPort()); - public static void setDefaultStatsDPort(final int newPort) { - if (newPort > 0 && defaultStatsDPort.getAndSet(newPort) != newPort) { - INSTANCE.handleDefaultPortChange(newPort); + public static void setDefaultStatsDPort(final int newPort) { + if (newPort > 0 && defaultStatsDPort.getAndSet(newPort) != newPort) { + INSTANCE.handleDefaultPortChange(newPort); + } } - } - - public static int getDefaultStatsDPort() { - return defaultStatsDPort.get(); - } - - private final ConcurrentHashMap connectionPool = - new ConcurrentHashMap<>(4); - - @Override - public StatsDClient statsDClient( - final String host, - final Integer port, - final String namedPipe, - final String namespace, - final String[] constantTags, - final boolean useAggregation) { - Function nameMapping = Function.identity(); - Function tagMapping = Function.identity(); - - if (null != namespace) { - nameMapping = new NameResolver(namespace); + + public static int getDefaultStatsDPort() { + return defaultStatsDPort.get(); } - final List processTags = ProcessTags.getTagsAsStringList(); - final int processTagSize = processTags != null ? processTags.size() : 0; - String[] finalConstantTags = constantTags; - if (processTagSize > 0) { - final int constantTagSize = constantTags != null ? constantTags.length : 0; - if (constantTagSize == 0) { - finalConstantTags = processTags.toArray(new String[0]); - } else { - final int tagSizeSum = processTagSize + constantTagSize; - finalConstantTags = new String[tagSizeSum]; - int pos = 0; - // copy constant tags - for (; pos < constantTagSize; pos++) { - finalConstantTags[pos] = constantTags[pos]; + private final ConcurrentHashMap connectionPool = new ConcurrentHashMap<>(4); + + @Override + public StatsDClient statsDClient( + final String host, + final Integer port, + final String namedPipe, + final String namespace, + final String[] constantTags, + final boolean useAggregation) { + Function nameMapping = Function.identity(); + Function tagMapping = Function.identity(); + + if (null != namespace) { + nameMapping = new NameResolver(namespace); } - // copy process tags - for (int i = 0; i < processTagSize; i++) { - finalConstantTags[pos + i] = processTags.get(i); + + final List processTags = ProcessTags.getTagsAsStringList(); + final int processTagSize = processTags != null ? processTags.size() : 0; + String[] finalConstantTags = constantTags; + if (processTagSize > 0) { + final int constantTagSize = constantTags != null ? constantTags.length : 0; + if (constantTagSize == 0) { + finalConstantTags = processTags.toArray(new String[0]); + } else { + final int tagSizeSum = processTagSize + constantTagSize; + finalConstantTags = new String[tagSizeSum]; + int pos = 0; + // copy constant tags + for (; pos < constantTagSize; pos++) { + finalConstantTags[pos] = constantTags[pos]; + } + // copy process tags + for (int i = 0; i < processTagSize; i++) { + finalConstantTags[pos + i] = processTags.get(i); + } + } } - } - } - if (null != finalConstantTags && finalConstantTags.length > 0) { - tagMapping = new TagCombiner(finalConstantTags); - } + if (null != finalConstantTags && finalConstantTags.length > 0) { + tagMapping = new TagCombiner(finalConstantTags); + } - if (USE_LOGGING_CLIENT) { - return new LoggingStatsDClient(nameMapping, tagMapping); - } else { - return new DDAgentStatsDClient( - getConnection(host, port, namedPipe, useAggregation), nameMapping, tagMapping); + if (USE_LOGGING_CLIENT) { + return new LoggingStatsDClient(nameMapping, tagMapping); + } else { + return new DDAgentStatsDClient( + getConnection(host, port, namedPipe, useAggregation), nameMapping, tagMapping); + } } - } - - private DDAgentStatsDConnection getConnection( - final String host, final Integer port, final String namedPipe, boolean useAggregation) { - String connectionKey = getConnectionKey(host, port, namedPipe, useAggregation); - DDAgentStatsDConnection connection = connectionPool.get(connectionKey); - if (null == connection) { - DDAgentStatsDConnection newConnection = - new DDAgentStatsDConnection(host, port, namedPipe, useAggregation); - connection = connectionPool.putIfAbsent(connectionKey, newConnection); - if (null == connection) { - connection = newConnection; - } + + private DDAgentStatsDConnection getConnection( + final String host, final Integer port, final String namedPipe, boolean useAggregation) { + String connectionKey = getConnectionKey(host, port, namedPipe, useAggregation); + DDAgentStatsDConnection connection = connectionPool.get(connectionKey); + if (null == connection) { + DDAgentStatsDConnection newConnection = new DDAgentStatsDConnection(host, port, namedPipe, useAggregation); + connection = connectionPool.putIfAbsent(connectionKey, newConnection); + if (null == connection) { + connection = newConnection; + } + } + return connection; } - return connection; - } - private static String getConnectionKey( - final String host, final Integer port, final String namedPipe, boolean useAggregation) { - if (namedPipe != null) { - return namedPipe + (useAggregation ? "" : ":no_aggregation"); + private static String getConnectionKey( + final String host, final Integer port, final String namedPipe, boolean useAggregation) { + if (namedPipe != null) { + return namedPipe + (useAggregation ? "" : ":no_aggregation"); + } + return (null != host ? host : "?") + + ":" + + (null != port ? port : "?") + + (useAggregation ? "" : ":no_aggregation"); } - return (null != host ? host : "?") - + ":" - + (null != port ? port : "?") - + (useAggregation ? "" : ":no_aggregation"); - } - - private void handleDefaultPortChange(final int newPort) { - for (DDAgentStatsDConnection connection : connectionPool.values()) { - connection.handleDefaultPortChange(newPort); + + private void handleDefaultPortChange(final int newPort) { + for (DDAgentStatsDConnection connection : connectionPool.values()) { + connection.handleDefaultPortChange(newPort); + } } - } - /** Resolves metrics names by prepending a namespace prefix. */ - static final class NameResolver implements Function { - private final DDCache resolvedNames = DDCaches.newFixedSizeCache(32); - private final Function namePrefixer; + /** Resolves metrics names by prepending a namespace prefix. */ + static final class NameResolver implements Function { + private final DDCache resolvedNames = DDCaches.newFixedSizeCache(32); + private final Function namePrefixer; - NameResolver(final String namespace) { - this.namePrefixer = - new Function() { - private final String prefix = namespace + '.'; + NameResolver(final String namespace) { + this.namePrefixer = new Function() { + private final String prefix = namespace + '.'; - @Override - public String apply(final String metricName) { - return prefix + metricName; - } - }; - } + @Override + public String apply(final String metricName) { + return prefix + metricName; + } + }; + } - @Override - public String apply(final String metricName) { - return resolvedNames.computeIfAbsent(metricName, namePrefixer); - } - } - - /** Combines per-call metrics tags with pre-packed constant tags. */ - static final class TagCombiner implements Function { - private final DDCache combinedTags = DDCaches.newFixedSizeArrayKeyCache(64); - // single-element array containing the pre-packed constant tags - private final String[] packedTags; - private final Function tagsInserter; - - public TagCombiner(final String[] constantTags) { - this.packedTags = pack(constantTags); - this.tagsInserter = - tags -> { - // extend per-call array by one to add the pre-packed constant tags - String[] result = new String[tags.length + 1]; - System.arraycopy(tags, 0, result, 1, tags.length); - result[0] = packedTags[0]; - return result; - }; + @Override + public String apply(final String metricName) { + return resolvedNames.computeIfAbsent(metricName, namePrefixer); + } } - @Override - public String[] apply(final String[] tags) { - if (null == tags || tags.length == 0) { - return packedTags; // no per-call tags so we can use the pre-packed array - } else { - return combinedTags.computeIfAbsent(tags, tagsInserter); - } - } + /** Combines per-call metrics tags with pre-packed constant tags. */ + static final class TagCombiner implements Function { + private final DDCache combinedTags = DDCaches.newFixedSizeArrayKeyCache(64); + // single-element array containing the pre-packed constant tags + private final String[] packedTags; + private final Function tagsInserter; + + public TagCombiner(final String[] constantTags) { + this.packedTags = pack(constantTags); + this.tagsInserter = tags -> { + // extend per-call array by one to add the pre-packed constant tags + String[] result = new String[tags.length + 1]; + System.arraycopy(tags, 0, result, 1, tags.length); + result[0] = packedTags[0]; + return result; + }; + } - /** - * Packs the constant tags into a single element array using the same separator as DogStatsD. - */ - private static String[] pack(final String[] tags) { - StringBuilder buf = new StringBuilder(tags[0]); - for (int i = 1; i < tags.length; i++) { - buf.append(',').append(tags[i]); - } - return new String[] {buf.toString()}; + @Override + public String[] apply(final String[] tags) { + if (null == tags || tags.length == 0) { + return packedTags; // no per-call tags so we can use the pre-packed array + } else { + return combinedTags.computeIfAbsent(tags, tagsInserter); + } + } + + /** + * Packs the constant tags into a single element array using the same separator as DogStatsD. + */ + private static String[] pack(final String[] tags) { + StringBuilder buf = new StringBuilder(tags[0]); + for (int i = 1; i < tags.length; i++) { + buf.append(',').append(tags[i]); + } + return new String[] {buf.toString()}; + } } - } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDConnection.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDConnection.java index f64fdbd285c..31018821bf3 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDConnection.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/DDAgentStatsDConnection.java @@ -21,255 +21,246 @@ import org.slf4j.LoggerFactory; final class DDAgentStatsDConnection implements StatsDClientErrorHandler { - private static final Logger log = LoggerFactory.getLogger(DDAgentStatsDConnection.class); - private static final IOLogger ioLogger = new IOLogger(log); + private static final Logger log = LoggerFactory.getLogger(DDAgentStatsDConnection.class); + private static final IOLogger ioLogger = new IOLogger(log); - private static final com.timgroup.statsd.StatsDClient NO_OP = new NoOpDirectStatsDClient(); + private static final com.timgroup.statsd.StatsDClient NO_OP = new NoOpDirectStatsDClient(); - private static final String UNIX_DOMAIN_SOCKET_PREFIX = "unix://"; + private static final String UNIX_DOMAIN_SOCKET_PREFIX = "unix://"; - private static final AgentThreadFactory STATSD_CLIENT_THREAD_FACTORY = - new AgentThreadFactory(STATSD_CLIENT); + private static final AgentThreadFactory STATSD_CLIENT_THREAD_FACTORY = new AgentThreadFactory(STATSD_CLIENT); - private static final int RETRY_DELAY = 10; - private static final int MAX_RETRIES = 20; + private static final int RETRY_DELAY = 10; + private static final int MAX_RETRIES = 20; - private boolean usingDefaultPort; - private volatile String host; - private volatile Integer port; - private final String namedPipe; - private final boolean useAggregation; + private boolean usingDefaultPort; + private volatile String host; + private volatile Integer port; + private final String namedPipe; + private final boolean useAggregation; - private final AtomicInteger clientCount = new AtomicInteger(0); - private final AtomicInteger errorCount = new AtomicInteger(0); - private final AtomicInteger retries = new AtomicInteger(0); + private final AtomicInteger clientCount = new AtomicInteger(0); + private final AtomicInteger errorCount = new AtomicInteger(0); + private final AtomicInteger retries = new AtomicInteger(0); - volatile com.timgroup.statsd.StatsDClient statsd = NO_OP; + volatile com.timgroup.statsd.StatsDClient statsd = NO_OP; - DDAgentStatsDConnection( - final String host, final Integer port, final String namedPipe, boolean useAggregation) { - this.host = host; - this.port = port; - this.namedPipe = namedPipe; - this.useAggregation = useAggregation; - } - - @Override - public void handle(final Exception e) { - errorCount.incrementAndGet(); - String message = e.getClass().getSimpleName() + " in StatsD client - " + statsDAddress(); - ioLogger.error(message, e); - } - - public void acquire() { - if (clientCount.getAndIncrement() == 0) { - scheduleConnect(); + DDAgentStatsDConnection(final String host, final Integer port, final String namedPipe, boolean useAggregation) { + this.host = host; + this.port = port; + this.namedPipe = namedPipe; + this.useAggregation = useAggregation; } - } - public void release() { - if (clientCount.decrementAndGet() == 0) { - doClose(); - } - } - - public int getErrorCount() { - return errorCount.get(); - } - - private void scheduleConnect() { - long remainingDelay = - Config.get().getDogStatsDStartDelay() - - MILLISECONDS.toSeconds( - System.currentTimeMillis() - Config.get().getStartTimeMillis()); - - if (remainingDelay > 0) { - if (log.isDebugEnabled()) { - log.debug( - "Scheduling StatsD connection in {} seconds - {}", remainingDelay, statsDAddress()); - } - AgentTaskScheduler.get() - .scheduleWithJitter(ConnectTask.INSTANCE, this, remainingDelay, SECONDS); - } else { - doConnect(); + @Override + public void handle(final Exception e) { + errorCount.incrementAndGet(); + String message = e.getClass().getSimpleName() + " in StatsD client - " + statsDAddress(); + ioLogger.error(message, e); } - } - - private void doConnect() { - synchronized (this) { - if (NO_OP == statsd && clientCount.get() > 0) { - discoverConnectionSettings(); - if (log.isDebugEnabled()) { - log.debug("Creating StatsD client - {}", statsDAddress()); - } - NonBlockingStatsDClientBuilder clientBuilder = - new NonBlockingStatsDClientBuilder() - .threadFactory(STATSD_CLIENT_THREAD_FACTORY) - .enableTelemetry(false) - .enableAggregation(useAggregation) - .hostname(host) - .port(port) - .namedPipe(namedPipe) - .errorHandler(this); - - // when using UDS, set "entity-id" to "none" to avoid having the DogStatsD - // server add origin tags (see https://github.com/DataDog/jmxfetch/pull/264) - if (this.port == 0) { - clientBuilder.entityID("none"); - } else { - clientBuilder.entityID(null); + public void acquire() { + if (clientCount.getAndIncrement() == 0) { + scheduleConnect(); } + } - Integer queueSize = Config.get().getStatsDClientQueueSize(); - if (queueSize != null) { - clientBuilder.queueSize(queueSize); + public void release() { + if (clientCount.decrementAndGet() == 0) { + doClose(); } + } - // when using UDS set the datagram size to 8k (2k on Mac due to lower OS default) - // but also make sure packet size isn't larger than the configured socket buffer - if (this.port == 0) { - Integer timeout = Config.get().getStatsDClientSocketTimeout(); - if (timeout != null) { - clientBuilder.timeout(timeout); - } - Integer bufferSize = Config.get().getStatsDClientSocketBuffer(); - if (bufferSize != null) { - clientBuilder.socketBufferSize(bufferSize); - } - int packetSize = OperatingSystem.isMacOs() ? 2048 : 8192; - if (bufferSize != null && bufferSize < packetSize) { - packetSize = bufferSize; - } - clientBuilder.maxPacketSizeBytes(packetSize); - } + public int getErrorCount() { + return errorCount.get(); + } - if (log.isDebugEnabled()) { - if (this.port == 0) { - log.debug( - "Configured StatsD client - queueSize={}, maxPacketSize={}, socketBuffer={}, socketTimeout={}", - clientBuilder.queueSize, - clientBuilder.maxPacketSizeBytes, - clientBuilder.socketBufferSize, - clientBuilder.timeout); - } else { - log.debug("Configured StatsD client - queueSize={}", clientBuilder.queueSize); - } - } + private void scheduleConnect() { + long remainingDelay = Config.get().getDogStatsDStartDelay() + - MILLISECONDS.toSeconds( + System.currentTimeMillis() - Config.get().getStartTimeMillis()); - try { - statsd = clientBuilder.build(); - if (log.isDebugEnabled()) { - log.debug("StatsD connected to {}", statsDAddress()); - } - } catch (final Exception e) { - if (retries.getAndIncrement() < MAX_RETRIES) { + if (remainingDelay > 0) { if (log.isDebugEnabled()) { - log.debug( - "Scheduling StatsD connection in {} seconds - {}", RETRY_DELAY, statsDAddress()); + log.debug("Scheduling StatsD connection in {} seconds - {}", remainingDelay, statsDAddress()); } - AgentTaskScheduler.get() - .scheduleWithJitter(ConnectTask.INSTANCE, this, RETRY_DELAY, SECONDS); - } else { - log.debug("Max retries have been reached. Will not attempt again."); - } - } catch (Throwable t) { - if (log.isDebugEnabled()) { - // Display full stack traces on debug logs - log.warn("Unable to create StatsD client - {} - Will not retry", statsDAddress(), t); - } else { - Throwable rootCause = t; - int i = 100; // arbitrary limit to avoid infinite loops with cycling causes - do { - rootCause = rootCause.getCause(); - i--; - } while (rootCause.getCause() != null && i > 0); - log.warn( - "Unable to create StatsD client - {} - Will not retry: {}, {}", - statsDAddress(), - t.getMessage(), - rootCause.getMessage()); - } + AgentTaskScheduler.get().scheduleWithJitter(ConnectTask.INSTANCE, this, remainingDelay, SECONDS); + } else { + doConnect(); } - } } - } - @SuppressFBWarnings("DMI_HARDCODED_ABSOLUTE_FILENAME") - private void discoverConnectionSettings() { - if (namedPipe != null) { - return; + private void doConnect() { + synchronized (this) { + if (NO_OP == statsd && clientCount.get() > 0) { + discoverConnectionSettings(); + if (log.isDebugEnabled()) { + log.debug("Creating StatsD client - {}", statsDAddress()); + } + + NonBlockingStatsDClientBuilder clientBuilder = new NonBlockingStatsDClientBuilder() + .threadFactory(STATSD_CLIENT_THREAD_FACTORY) + .enableTelemetry(false) + .enableAggregation(useAggregation) + .hostname(host) + .port(port) + .namedPipe(namedPipe) + .errorHandler(this); + + // when using UDS, set "entity-id" to "none" to avoid having the DogStatsD + // server add origin tags (see https://github.com/DataDog/jmxfetch/pull/264) + if (this.port == 0) { + clientBuilder.entityID("none"); + } else { + clientBuilder.entityID(null); + } + + Integer queueSize = Config.get().getStatsDClientQueueSize(); + if (queueSize != null) { + clientBuilder.queueSize(queueSize); + } + + // when using UDS set the datagram size to 8k (2k on Mac due to lower OS default) + // but also make sure packet size isn't larger than the configured socket buffer + if (this.port == 0) { + Integer timeout = Config.get().getStatsDClientSocketTimeout(); + if (timeout != null) { + clientBuilder.timeout(timeout); + } + Integer bufferSize = Config.get().getStatsDClientSocketBuffer(); + if (bufferSize != null) { + clientBuilder.socketBufferSize(bufferSize); + } + int packetSize = OperatingSystem.isMacOs() ? 2048 : 8192; + if (bufferSize != null && bufferSize < packetSize) { + packetSize = bufferSize; + } + clientBuilder.maxPacketSizeBytes(packetSize); + } + + if (log.isDebugEnabled()) { + if (this.port == 0) { + log.debug( + "Configured StatsD client - queueSize={}, maxPacketSize={}, socketBuffer={}, socketTimeout={}", + clientBuilder.queueSize, + clientBuilder.maxPacketSizeBytes, + clientBuilder.socketBufferSize, + clientBuilder.timeout); + } else { + log.debug("Configured StatsD client - queueSize={}", clientBuilder.queueSize); + } + } + + try { + statsd = clientBuilder.build(); + if (log.isDebugEnabled()) { + log.debug("StatsD connected to {}", statsDAddress()); + } + } catch (final Exception e) { + if (retries.getAndIncrement() < MAX_RETRIES) { + if (log.isDebugEnabled()) { + log.debug("Scheduling StatsD connection in {} seconds - {}", RETRY_DELAY, statsDAddress()); + } + AgentTaskScheduler.get().scheduleWithJitter(ConnectTask.INSTANCE, this, RETRY_DELAY, SECONDS); + } else { + log.debug("Max retries have been reached. Will not attempt again."); + } + } catch (Throwable t) { + if (log.isDebugEnabled()) { + // Display full stack traces on debug logs + log.warn("Unable to create StatsD client - {} - Will not retry", statsDAddress(), t); + } else { + Throwable rootCause = t; + int i = 100; // arbitrary limit to avoid infinite loops with cycling causes + do { + rootCause = rootCause.getCause(); + i--; + } while (rootCause.getCause() != null && i > 0); + log.warn( + "Unable to create StatsD client - {} - Will not retry: {}, {}", + statsDAddress(), + t.getMessage(), + rootCause.getMessage()); + } + } + } + } } - if (null == host) { - if (!OperatingSystem.isWindows() && Files.exists(new File(DEFAULT_DOGSTATSD_SOCKET_PATH))) { - log.info("Detected {}. Using it to send StatsD data.", DEFAULT_DOGSTATSD_SOCKET_PATH); - host = DEFAULT_DOGSTATSD_SOCKET_PATH; - port = 0; // tells dogstatsd client to treat host as a socket path - } else { - host = Config.get().getAgentHost(); - } - } + @SuppressFBWarnings("DMI_HARDCODED_ABSOLUTE_FILENAME") + private void discoverConnectionSettings() { + if (namedPipe != null) { + return; + } - if (host.startsWith(UNIX_DOMAIN_SOCKET_PREFIX)) { - host = host.substring(UNIX_DOMAIN_SOCKET_PREFIX.length()); - port = 0; // tells dogstatsd client to treat host as a socket path - } - if (null == port) { - port = DDAgentStatsDClientManager.getDefaultStatsDPort(); - usingDefaultPort = true; - } - } + if (null == host) { + if (!OperatingSystem.isWindows() && Files.exists(new File(DEFAULT_DOGSTATSD_SOCKET_PATH))) { + log.info("Detected {}. Using it to send StatsD data.", DEFAULT_DOGSTATSD_SOCKET_PATH); + host = DEFAULT_DOGSTATSD_SOCKET_PATH; + port = 0; // tells dogstatsd client to treat host as a socket path + } else { + host = Config.get().getAgentHost(); + } + } - void handleDefaultPortChange(final int newPort) { - synchronized (this) { - if (NO_OP != statsd && usingDefaultPort && newPort != port) { - if (log.isDebugEnabled()) { - log.debug("Closing StatsD client - {}", statsDAddress()); + if (host.startsWith(UNIX_DOMAIN_SOCKET_PREFIX)) { + host = host.substring(UNIX_DOMAIN_SOCKET_PREFIX.length()); + port = 0; // tells dogstatsd client to treat host as a socket path } - try { - statsd.close(); - } finally { - statsd = NO_OP; - port = null; // clear so it will pickup latest default - doConnect(); + if (null == port) { + port = DDAgentStatsDClientManager.getDefaultStatsDPort(); + usingDefaultPort = true; } - } } - } - private void doClose() { - synchronized (this) { - if (NO_OP != statsd && 0 == clientCount.get()) { - if (log.isDebugEnabled()) { - log.debug("Closing StatsD client - {}", statsDAddress()); - } - try { - statsd.close(); - } catch (final Exception e) { - log.debug("Problem closing StatsD client - {}", statsDAddress(), e); - } finally { - statsd = NO_OP; + void handleDefaultPortChange(final int newPort) { + synchronized (this) { + if (NO_OP != statsd && usingDefaultPort && newPort != port) { + if (log.isDebugEnabled()) { + log.debug("Closing StatsD client - {}", statsDAddress()); + } + try { + statsd.close(); + } finally { + statsd = NO_OP; + port = null; // clear so it will pickup latest default + doConnect(); + } + } } - } } - } - private String statsDAddress() { - if (namedPipe != null) { - return namedPipe; + private void doClose() { + synchronized (this) { + if (NO_OP != statsd && 0 == clientCount.get()) { + if (log.isDebugEnabled()) { + log.debug("Closing StatsD client - {}", statsDAddress()); + } + try { + statsd.close(); + } catch (final Exception e) { + log.debug("Problem closing StatsD client - {}", statsDAddress(), e); + } finally { + statsd = NO_OP; + } + } + } } - return (null != host ? host : "") + (null != port && port > 0 ? ":" + port : ""); - } + private String statsDAddress() { + if (namedPipe != null) { + return namedPipe; + } - private static final class ConnectTask - implements AgentTaskScheduler.Task { - public static final ConnectTask INSTANCE = new ConnectTask(); + return (null != host ? host : "") + (null != port && port > 0 ? ":" + port : ""); + } - @Override - public void run(final DDAgentStatsDConnection target) { - target.doConnect(); + private static final class ConnectTask implements AgentTaskScheduler.Task { + public static final ConnectTask INSTANCE = new ConnectTask(); + + @Override + public void run(final DDAgentStatsDConnection target) { + target.doConnect(); + } } - } } diff --git a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/LoggingStatsDClient.java b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/LoggingStatsDClient.java index 547f5fad0b4..b7786f553df 100644 --- a/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/LoggingStatsDClient.java +++ b/products/metrics/metrics-lib/src/main/java/datadog/metrics/impl/statsd/LoggingStatsDClient.java @@ -9,124 +9,119 @@ import org.slf4j.LoggerFactory; public final class LoggingStatsDClient implements StatsDClient { - private static final Logger log = LoggerFactory.getLogger(LoggingStatsDClient.class); - - // logging format is based on the StatsD datagram format - private static final String COUNT_FORMAT = "{}:{}|c{}"; - private static final String GAUGE_FORMAT = "{}:{}|g{}"; - private static final String HISTOGRAM_FORMAT = "{}:{}|h{}"; - private static final String DISTRIBUTION_FORMAT = "{}:{}|d{}"; - private static final String SERVICE_CHECK_FORMAT = "_sc|{}|{}{}{}"; - private static final String EVENT_FORMAT = "_e|{}|{}|{}|{}|{}"; - - private static final DecimalFormat DECIMAL_FORMAT; - - static { - DECIMAL_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); - DECIMAL_FORMAT.setMaximumFractionDigits(6); - } - - private final Function nameMapping; - private final Function tagMapping; - - public LoggingStatsDClient( - final Function nameMapping, final Function tagMapping) { - this.nameMapping = nameMapping; - this.tagMapping = tagMapping; - } - - @Override - public void incrementCounter(final String metricName, final String... tags) { - log.info(COUNT_FORMAT, nameMapping.apply(metricName), 1, join(tagMapping.apply(tags))); - } - - @Override - public void count(final String metricName, final long delta, final String... tags) { - log.info(COUNT_FORMAT, nameMapping.apply(metricName), delta, join(tagMapping.apply(tags))); - } - - @Override - public void gauge(final String metricName, final long value, final String... tags) { - log.info(GAUGE_FORMAT, nameMapping.apply(metricName), value, join(tagMapping.apply(tags))); - } - - @Override - public void gauge(final String metricName, final double value, final String... tags) { - log.info( - GAUGE_FORMAT, - nameMapping.apply(metricName), - DECIMAL_FORMAT.format(value), - join(tagMapping.apply(tags))); - } - - @Override - public void histogram(final String metricName, final long value, final String... tags) { - log.info(HISTOGRAM_FORMAT, nameMapping.apply(metricName), value, join(tagMapping.apply(tags))); - } - - @Override - public void histogram(final String metricName, final double value, final String... tags) { - log.info( - HISTOGRAM_FORMAT, - nameMapping.apply(metricName), - DECIMAL_FORMAT.format(value), - join(tagMapping.apply(tags))); - } - - @Override - public void distribution(String metricName, long value, String... tags) { - log.info( - DISTRIBUTION_FORMAT, nameMapping.apply(metricName), value, join(tagMapping.apply(tags))); - } - - @Override - public void distribution(String metricName, double value, String... tags) { - log.info( - DISTRIBUTION_FORMAT, - nameMapping.apply(metricName), - DECIMAL_FORMAT.format(value), - join(tagMapping.apply(tags))); - } - - @Override - public void serviceCheck( - final String serviceCheckName, - final String status, - final String message, - final String... tags) { - log.info( - SERVICE_CHECK_FORMAT, - nameMapping.apply(serviceCheckName), - DDAgentStatsDClient.serviceCheckStatus(status).ordinal(), - join(tagMapping.apply(tags)), - null != message ? "|m:" + message : ""); - } - - @Override - public void error(final Exception error) {} - - @Override - public int getErrorCount() { - return 0; - } - - @Override - public void recordEvent( - String type, String source, String eventName, String message, String... tags) { - log.info(EVENT_FORMAT, type, source, eventName, message, join(tagMapping.apply(tags))); - } - - @Override - public void close() {} - - private static String join(final String... tags) { - if (null == tags || tags.length == 0) { - return ""; + private static final Logger log = LoggerFactory.getLogger(LoggingStatsDClient.class); + + // logging format is based on the StatsD datagram format + private static final String COUNT_FORMAT = "{}:{}|c{}"; + private static final String GAUGE_FORMAT = "{}:{}|g{}"; + private static final String HISTOGRAM_FORMAT = "{}:{}|h{}"; + private static final String DISTRIBUTION_FORMAT = "{}:{}|d{}"; + private static final String SERVICE_CHECK_FORMAT = "_sc|{}|{}{}{}"; + private static final String EVENT_FORMAT = "_e|{}|{}|{}|{}|{}"; + + private static final DecimalFormat DECIMAL_FORMAT; + + static { + DECIMAL_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); + DECIMAL_FORMAT.setMaximumFractionDigits(6); + } + + private final Function nameMapping; + private final Function tagMapping; + + public LoggingStatsDClient( + final Function nameMapping, final Function tagMapping) { + this.nameMapping = nameMapping; + this.tagMapping = tagMapping; + } + + @Override + public void incrementCounter(final String metricName, final String... tags) { + log.info(COUNT_FORMAT, nameMapping.apply(metricName), 1, join(tagMapping.apply(tags))); + } + + @Override + public void count(final String metricName, final long delta, final String... tags) { + log.info(COUNT_FORMAT, nameMapping.apply(metricName), delta, join(tagMapping.apply(tags))); + } + + @Override + public void gauge(final String metricName, final long value, final String... tags) { + log.info(GAUGE_FORMAT, nameMapping.apply(metricName), value, join(tagMapping.apply(tags))); + } + + @Override + public void gauge(final String metricName, final double value, final String... tags) { + log.info( + GAUGE_FORMAT, + nameMapping.apply(metricName), + DECIMAL_FORMAT.format(value), + join(tagMapping.apply(tags))); + } + + @Override + public void histogram(final String metricName, final long value, final String... tags) { + log.info(HISTOGRAM_FORMAT, nameMapping.apply(metricName), value, join(tagMapping.apply(tags))); + } + + @Override + public void histogram(final String metricName, final double value, final String... tags) { + log.info( + HISTOGRAM_FORMAT, + nameMapping.apply(metricName), + DECIMAL_FORMAT.format(value), + join(tagMapping.apply(tags))); + } + + @Override + public void distribution(String metricName, long value, String... tags) { + log.info(DISTRIBUTION_FORMAT, nameMapping.apply(metricName), value, join(tagMapping.apply(tags))); + } + + @Override + public void distribution(String metricName, double value, String... tags) { + log.info( + DISTRIBUTION_FORMAT, + nameMapping.apply(metricName), + DECIMAL_FORMAT.format(value), + join(tagMapping.apply(tags))); } - StringBuilder buf = new StringBuilder("|#").append(tags[0]); - for (int i = 1; i < tags.length; i++) { - buf.append(',').append(tags[i]); + + @Override + public void serviceCheck( + final String serviceCheckName, final String status, final String message, final String... tags) { + log.info( + SERVICE_CHECK_FORMAT, + nameMapping.apply(serviceCheckName), + DDAgentStatsDClient.serviceCheckStatus(status).ordinal(), + join(tagMapping.apply(tags)), + null != message ? "|m:" + message : ""); + } + + @Override + public void error(final Exception error) {} + + @Override + public int getErrorCount() { + return 0; + } + + @Override + public void recordEvent(String type, String source, String eventName, String message, String... tags) { + log.info(EVENT_FORMAT, type, source, eventName, message, join(tagMapping.apply(tags))); + } + + @Override + public void close() {} + + private static String join(final String... tags) { + if (null == tags || tags.length == 0) { + return ""; + } + StringBuilder buf = new StringBuilder("|#").append(tags[0]); + for (int i = 1; i < tags.length; i++) { + buf.append(',').append(tags[i]); + } + return buf.toString(); } - return buf.toString(); - } } diff --git a/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/DDSketchHistogramsTest.java b/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/DDSketchHistogramsTest.java index d5e87846495..dccefdd1bda 100644 --- a/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/DDSketchHistogramsTest.java +++ b/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/DDSketchHistogramsTest.java @@ -10,82 +10,82 @@ class DDSketchHistogramsTest { - // boundaries define 4 bins: - // bin 0: (-inf, 10] - // bin 1: (10, 20] - // bin 2: (20, 30] - // bin 3: (30, +inf) - private static final List BOUNDARIES = asList(10.0, 20.0, 30.0); + // boundaries define 4 bins: + // bin 0: (-inf, 10] + // bin 1: (10, 20] + // bin 2: (20, 30] + // bin 3: (30, +inf) + private static final List BOUNDARIES = asList(10.0, 20.0, 30.0); - @Test - void indexBelowFirstBoundary() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(0, eb.index(5.0)); - } + @Test + void indexBelowFirstBoundary() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(0, eb.index(5.0)); + } - @Test - void indexOnBoundary() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(0, eb.index(10.0)); - assertEquals(1, eb.index(20.0)); - assertEquals(2, eb.index(30.0)); - } + @Test + void indexOnBoundary() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(0, eb.index(10.0)); + assertEquals(1, eb.index(20.0)); + assertEquals(2, eb.index(30.0)); + } - @Test - void indexBetweenBoundaries() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(1, eb.index(15.0)); - assertEquals(2, eb.index(25.0)); - } + @Test + void indexBetweenBoundaries() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(1, eb.index(15.0)); + assertEquals(2, eb.index(25.0)); + } - @Test - void indexAboveLastBoundary() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(3, eb.index(35.0)); - } + @Test + void indexAboveLastBoundary() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(3, eb.index(35.0)); + } - @Test - void lowerBoundOfFirstBinIsNegativeInfinity() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(Double.NEGATIVE_INFINITY, eb.lowerBound(0)); - } + @Test + void lowerBoundOfFirstBinIsNegativeInfinity() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(Double.NEGATIVE_INFINITY, eb.lowerBound(0)); + } - @Test - void lowerBoundMapsToPreceeedingBoundary() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(10.0, eb.lowerBound(1)); - assertEquals(20.0, eb.lowerBound(2)); - assertEquals(30.0, eb.lowerBound(3)); - } + @Test + void lowerBoundMapsToPreceeedingBoundary() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(10.0, eb.lowerBound(1)); + assertEquals(20.0, eb.lowerBound(2)); + assertEquals(30.0, eb.lowerBound(3)); + } - @Test - void upperBoundMapsToCorrespondingBoundary() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(10.0, eb.upperBound(0)); - assertEquals(20.0, eb.upperBound(1)); - assertEquals(30.0, eb.upperBound(2)); - } + @Test + void upperBoundMapsToCorrespondingBoundary() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(10.0, eb.upperBound(0)); + assertEquals(20.0, eb.upperBound(1)); + assertEquals(30.0, eb.upperBound(2)); + } - @Test - void upperBoundOfLastBinIsPositiveInfinity() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(Double.POSITIVE_INFINITY, eb.upperBound(3)); - } + @Test + void upperBoundOfLastBinIsPositiveInfinity() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(Double.POSITIVE_INFINITY, eb.upperBound(3)); + } - @Test - void extremeIndexableValues() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertEquals(Double.NEGATIVE_INFINITY, eb.minIndexableValue()); - assertEquals(Double.POSITIVE_INFINITY, eb.maxIndexableValue()); - } + @Test + void extremeIndexableValues() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertEquals(Double.NEGATIVE_INFINITY, eb.minIndexableValue()); + assertEquals(Double.POSITIVE_INFINITY, eb.maxIndexableValue()); + } - @Test - void unsupportedOperationsThrow() { - ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); - assertThrows(UnsupportedOperationException.class, () -> eb.value(0)); - assertThrows(UnsupportedOperationException.class, eb::relativeAccuracy); - assertThrows(UnsupportedOperationException.class, () -> eb.encode(null)); - assertThrows(UnsupportedOperationException.class, eb::serializedSize); - assertThrows(UnsupportedOperationException.class, () -> eb.serialize(null)); - } + @Test + void unsupportedOperationsThrow() { + ExplicitBoundaries eb = new ExplicitBoundaries(BOUNDARIES); + assertThrows(UnsupportedOperationException.class, () -> eb.value(0)); + assertThrows(UnsupportedOperationException.class, eb::relativeAccuracy); + assertThrows(UnsupportedOperationException.class, () -> eb.encode(null)); + assertThrows(UnsupportedOperationException.class, eb::serializedSize); + assertThrows(UnsupportedOperationException.class, () -> eb.serialize(null)); + } } diff --git a/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/ThreadLocalRecordingTest.java b/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/ThreadLocalRecordingTest.java index f533355eb89..7ffc409bc6f 100644 --- a/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/ThreadLocalRecordingTest.java +++ b/products/metrics/metrics-lib/src/test/java/datadog/metrics/impl/ThreadLocalRecordingTest.java @@ -13,74 +13,70 @@ class ThreadLocalRecordingTest { - @Test - void delegatesPerThread() throws Exception { - Map> callsByThread = new ConcurrentHashMap<>(); + @Test + void delegatesPerThread() throws Exception { + Map> callsByThread = new ConcurrentHashMap<>(); - ThreadLocal sink = - ThreadLocal.withInitial( - () -> { - List calls = new ArrayList<>(); - callsByThread.put(Thread.currentThread(), calls); - return recordCalls(calls); - }); + ThreadLocal sink = ThreadLocal.withInitial(() -> { + List calls = new ArrayList<>(); + callsByThread.put(Thread.currentThread(), calls); + return recordCalls(calls); + }); - Recording recording = new ThreadLocalRecording(sink); + Recording recording = new ThreadLocalRecording(sink); - int threadCount = 4; - CountDownLatch ready = new CountDownLatch(threadCount); - CountDownLatch done = new CountDownLatch(threadCount); - Thread[] threads = new Thread[threadCount]; + int threadCount = 4; + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch done = new CountDownLatch(threadCount); + Thread[] threads = new Thread[threadCount]; - for (int i = 0; i < threadCount; i++) { - threads[i] = - new Thread( - () -> { + for (int i = 0; i < threadCount; i++) { + threads[i] = new Thread(() -> { ready.countDown(); try { - ready.await(); + ready.await(); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + Thread.currentThread().interrupt(); + return; } recording.start(); recording.reset(); recording.stop(); recording.flush(); done.countDown(); - }); - threads[i].start(); - } - done.await(); + }); + threads[i].start(); + } + done.await(); - assertEquals(threadCount, callsByThread.size(), "each thread should have its own Recording"); - for (List calls : callsByThread.values()) { - assertEquals(asList("start", "reset", "stop", "flush"), calls); + assertEquals(threadCount, callsByThread.size(), "each thread should have its own Recording"); + for (List calls : callsByThread.values()) { + assertEquals(asList("start", "reset", "stop", "flush"), calls); + } } - } - private static Recording recordCalls(List calls) { - return new Recording() { - @Override - public Recording start() { - calls.add("start"); - return this; - } + private static Recording recordCalls(List calls) { + return new Recording() { + @Override + public Recording start() { + calls.add("start"); + return this; + } - @Override - public void reset() { - calls.add("reset"); - } + @Override + public void reset() { + calls.add("reset"); + } - @Override - public void stop() { - calls.add("stop"); - } + @Override + public void stop() { + calls.add("stop"); + } - @Override - public void flush() { - calls.add("flush"); - } - }; - } + @Override + public void flush() { + calls.add("flush"); + } + }; + } } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java index ce76e59000a..b1652d8f129 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java @@ -1,50 +1,50 @@ package datadog.remoteconfig; public interface Capabilities { - // This bitset is reserved according to the Remote Config spec. - long CAPABILITY_ASM_ACTIVATION = 1 << 1; - long CAPABILITY_ASM_IP_BLOCKING = 1 << 2; - long CAPABILITY_ASM_DD_RULES = 1 << 3; - long CAPABILITY_ASM_EXCLUSIONS = 1 << 4; - long CAPABILITY_ASM_REQUEST_BLOCKING = 1 << 5; - long CAPABILITY_ASM_RESPONSE_BLOCKING = 1 << 6; - long CAPABILITY_ASM_USER_BLOCKING = 1 << 7; - long CAPABILITY_ASM_CUSTOM_RULES = 1 << 8; - long CAPABILITY_ASM_CUSTOM_BLOCKING_RESPONSE = 1 << 9; - long CAPABILITY_ASM_TRUSTED_IPS = 1 << 10; - long CAPABILITY_ASM_API_SECURITY_SAMPLE_RATE = 1 << 11; - long CAPABILITY_APM_TRACING_SAMPLE_RATE = 1 << 12; - long CAPABILITY_APM_LOGS_INJECTION = 1 << 13; - long CAPABILITY_APM_HTTP_HEADER_TAGS = 1 << 14; - long CAPABILITY_APM_CUSTOM_TAGS = 1 << 15; - long CAPABILITY_ASM_PROCESSOR_OVERRIDES = 1 << 16; - long CAPABILITY_ASM_CUSTOM_DATA_SCANNERS = 1 << 17; - long CAPABILITY_ASM_EXCLUSION_DATA = 1 << 18; - long CAPABILITY_APM_TRACING_TRACING_ENABLED = 1 << 19; - long CAPABILITY_APM_TRACING_DATA_STREAMS_ENABLED = 1 << 20; - long CAPABILITY_ASM_RASP_SQLI = 1 << 21; - long CAPABILITY_ASM_RASP_LFI = 1 << 22; - long CAPABILITY_ASM_RASP_SSRF = 1 << 23; - long CAPABILITY_ASM_RASP_SHI = 1 << 24; - long CAPABILITY_ASM_RASP_XXE = 1 << 25; - long CAPABILITY_ASM_RASP_RCE = 1 << 26; - long CAPABILITY_ASM_RASP_NOSQLI = 1 << 27; - long CAPABILITY_ASM_RASP_XSS = 1 << 28; - long CAPABILITY_APM_TRACING_SAMPLE_RULES = 1 << 29; - long CAPABILITY_CSM_ACTIVATION = 1 << 30; - long CAPABILITY_ASM_AUTO_USER_INSTRUM_MODE = 1L << 31; - long CAPABILITY_ENDPOINT_FINGERPRINT = 1L << 32; - long CAPABILITY_ASM_SESSION_FINGERPRINT = 1L << 33; - long CAPABILITY_ASM_NETWORK_FINGERPRINT = 1L << 34; - long CAPABILITY_ASM_HEADER_FINGERPRINT = 1L << 35; - long CAPABILITY_ASM_RASP_CMDI = 1L << 37; - long CAPABILITY_APM_TRACING_ENABLE_DYNAMIC_INSTRUMENTATION = 1L << 38; - long CAPABILITY_APM_TRACING_ENABLE_EXCEPTION_REPLAY = 1L << 39; - long CAPABILITY_APM_TRACING_ENABLE_CODE_ORIGIN = 1L << 40; - long CAPABILITY_APM_TRACING_ENABLE_LIVE_DEBUGGING = 1L << 41; - long CAPABILITY_ASM_DD_MULTICONFIG = 1L << 42; - long CAPABILITY_ASM_TRACE_TAGGING_RULES = 1L << 43; - long CAPABILITY_ASM_EXTENDED_DATA_COLLECTION = 1L << 44; - long CAPABILITY_APM_TRACING_MULTICONFIG = 1L << 45; - long CAPABILITY_FFE_FLAG_CONFIGURATION_RULES = 1L << 46; + // This bitset is reserved according to the Remote Config spec. + long CAPABILITY_ASM_ACTIVATION = 1 << 1; + long CAPABILITY_ASM_IP_BLOCKING = 1 << 2; + long CAPABILITY_ASM_DD_RULES = 1 << 3; + long CAPABILITY_ASM_EXCLUSIONS = 1 << 4; + long CAPABILITY_ASM_REQUEST_BLOCKING = 1 << 5; + long CAPABILITY_ASM_RESPONSE_BLOCKING = 1 << 6; + long CAPABILITY_ASM_USER_BLOCKING = 1 << 7; + long CAPABILITY_ASM_CUSTOM_RULES = 1 << 8; + long CAPABILITY_ASM_CUSTOM_BLOCKING_RESPONSE = 1 << 9; + long CAPABILITY_ASM_TRUSTED_IPS = 1 << 10; + long CAPABILITY_ASM_API_SECURITY_SAMPLE_RATE = 1 << 11; + long CAPABILITY_APM_TRACING_SAMPLE_RATE = 1 << 12; + long CAPABILITY_APM_LOGS_INJECTION = 1 << 13; + long CAPABILITY_APM_HTTP_HEADER_TAGS = 1 << 14; + long CAPABILITY_APM_CUSTOM_TAGS = 1 << 15; + long CAPABILITY_ASM_PROCESSOR_OVERRIDES = 1 << 16; + long CAPABILITY_ASM_CUSTOM_DATA_SCANNERS = 1 << 17; + long CAPABILITY_ASM_EXCLUSION_DATA = 1 << 18; + long CAPABILITY_APM_TRACING_TRACING_ENABLED = 1 << 19; + long CAPABILITY_APM_TRACING_DATA_STREAMS_ENABLED = 1 << 20; + long CAPABILITY_ASM_RASP_SQLI = 1 << 21; + long CAPABILITY_ASM_RASP_LFI = 1 << 22; + long CAPABILITY_ASM_RASP_SSRF = 1 << 23; + long CAPABILITY_ASM_RASP_SHI = 1 << 24; + long CAPABILITY_ASM_RASP_XXE = 1 << 25; + long CAPABILITY_ASM_RASP_RCE = 1 << 26; + long CAPABILITY_ASM_RASP_NOSQLI = 1 << 27; + long CAPABILITY_ASM_RASP_XSS = 1 << 28; + long CAPABILITY_APM_TRACING_SAMPLE_RULES = 1 << 29; + long CAPABILITY_CSM_ACTIVATION = 1 << 30; + long CAPABILITY_ASM_AUTO_USER_INSTRUM_MODE = 1L << 31; + long CAPABILITY_ENDPOINT_FINGERPRINT = 1L << 32; + long CAPABILITY_ASM_SESSION_FINGERPRINT = 1L << 33; + long CAPABILITY_ASM_NETWORK_FINGERPRINT = 1L << 34; + long CAPABILITY_ASM_HEADER_FINGERPRINT = 1L << 35; + long CAPABILITY_ASM_RASP_CMDI = 1L << 37; + long CAPABILITY_APM_TRACING_ENABLE_DYNAMIC_INSTRUMENTATION = 1L << 38; + long CAPABILITY_APM_TRACING_ENABLE_EXCEPTION_REPLAY = 1L << 39; + long CAPABILITY_APM_TRACING_ENABLE_CODE_ORIGIN = 1L << 40; + long CAPABILITY_APM_TRACING_ENABLE_LIVE_DEBUGGING = 1L << 41; + long CAPABILITY_ASM_DD_MULTICONFIG = 1L << 42; + long CAPABILITY_ASM_TRACE_TAGGING_RULES = 1L << 43; + long CAPABILITY_ASM_EXTENDED_DATA_COLLECTION = 1L << 44; + long CAPABILITY_APM_TRACING_MULTICONFIG = 1L << 45; + long CAPABILITY_FFE_FLAG_CONFIGURATION_RULES = 1L << 46; } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesListener.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesListener.java index 60850d5f92e..e0758289699 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesListener.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesListener.java @@ -9,15 +9,14 @@ */ @FunctionalInterface public interface ConfigurationChangesListener { - /** - * Notifies a new configuration value change. - * - * @param configKey The configuration key that changed. - * @param content The new configuration value, might be {@code null} to "unapply" the - * configuration. - * @param pollingRateHinter The callback to hint about the expected polling rate. - * @throws IOException If the configuration could not be deserialized. - */ - void accept(String configKey, @Nullable byte[] content, PollingRateHinter pollingRateHinter) - throws IOException; + /** + * Notifies a new configuration value change. + * + * @param configKey The configuration key that changed. + * @param content The new configuration value, might be {@code null} to "unapply" the + * configuration. + * @param pollingRateHinter The callback to hint about the expected polling rate. + * @throws IOException If the configuration could not be deserialized. + */ + void accept(String configKey, @Nullable byte[] content, PollingRateHinter pollingRateHinter) throws IOException; } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesTypedListener.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesTypedListener.java index 6da20b34e2a..069b8a33487 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesTypedListener.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationChangesTypedListener.java @@ -5,42 +5,42 @@ /** This interface describes a configuration strongly-typed value change. */ @FunctionalInterface public interface ConfigurationChangesTypedListener { - /** - * Notifies a new configuration value change. - * - * @param configKey The configuration key that changed. - * @param configuration The new configuration value, might be {@code null} to "unapply" the - * configuration. - * @param pollingRateHinter The callback to hint about the expected polling rate. - */ - void accept( - String configKey, - @Nullable T configuration, // null to "unapply" the configuration - PollingRateHinter pollingRateHinter); - - class Builder { /** - * Creates a {@link ConfigurationChangesListener} for a strongly-typed configuration. + * Notifies a new configuration value change. * - * @param deserializer The configuration value deserializer. - * @param listener The strongly-typed listener to delegate the notification. - * @return The resulting {@link ConfigurationChangesListener} instance. - * @param The type of the configuration value. + * @param configKey The configuration key that changed. + * @param configuration The new configuration value, might be {@code null} to "unapply" the + * configuration. + * @param pollingRateHinter The callback to hint about the expected polling rate. */ - static ConfigurationChangesListener useDeserializer( - ConfigurationDeserializer deserializer, ConfigurationChangesTypedListener listener) { - return (configKey, content, pollingRateHinter) -> { - K configuration = null; + void accept( + String configKey, + @Nullable T configuration, // null to "unapply" the configuration + PollingRateHinter pollingRateHinter); + + class Builder { + /** + * Creates a {@link ConfigurationChangesListener} for a strongly-typed configuration. + * + * @param deserializer The configuration value deserializer. + * @param listener The strongly-typed listener to delegate the notification. + * @return The resulting {@link ConfigurationChangesListener} instance. + * @param The type of the configuration value. + */ + static ConfigurationChangesListener useDeserializer( + ConfigurationDeserializer deserializer, ConfigurationChangesTypedListener listener) { + return (configKey, content, pollingRateHinter) -> { + K configuration = null; - if (content != null) { - configuration = deserializer.deserialize(content); - // ensure deserializer return a value. - if (configuration == null) { - throw new RuntimeException("Configuration deserializer didn't provide a configuration"); - } + if (content != null) { + configuration = deserializer.deserialize(content); + // ensure deserializer return a value. + if (configuration == null) { + throw new RuntimeException("Configuration deserializer didn't provide a configuration"); + } + } + listener.accept(configKey, configuration, pollingRateHinter); + }; } - listener.accept(configKey, configuration, pollingRateHinter); - }; } - } } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationDeserializer.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationDeserializer.java index a6e136c0616..01638623b7e 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationDeserializer.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationDeserializer.java @@ -9,12 +9,12 @@ */ @FunctionalInterface public interface ConfigurationDeserializer { - /** - * Deserializes a configuration value. - * - * @param content The binary representation of the configuration value. - * @return The deserialized typed configuration value. - * @throws IOException If the configuration value cannot be deserialized. - */ - T deserialize(byte[] content) throws IOException; + /** + * Deserializes a configuration value. + * + * @param content The binary representation of the configuration value. + * @return The deserialized typed configuration value. + * @throws IOException If the configuration value cannot be deserialized. + */ + T deserialize(byte[] content) throws IOException; } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationEndListener.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationEndListener.java index 12f55c01f46..9e653c8071a 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationEndListener.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationEndListener.java @@ -3,6 +3,6 @@ /** This interface defines a listener of the configuration change. */ @FunctionalInterface public interface ConfigurationEndListener { - /** Notifies the listener that new configuration was applied. */ - void onConfigurationEnd(); + /** Notifies the listener that new configuration was applied. */ + void onConfigurationEnd(); } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationPoller.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationPoller.java index f358b0e457e..a35e28e968f 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationPoller.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/ConfigurationPoller.java @@ -3,34 +3,32 @@ import datadog.remoteconfig.state.ProductListener; public interface ConfigurationPoller { - void addListener(Product product, ProductListener listener); + void addListener(Product product, ProductListener listener); - void addListener( - Product product, - ConfigurationDeserializer deserializer, - ConfigurationChangesTypedListener listener); + void addListener( + Product product, ConfigurationDeserializer deserializer, ConfigurationChangesTypedListener listener); - void addListener(Product product, String configKey, ProductListener listener); + void addListener(Product product, String configKey, ProductListener listener); - void addListener( - Product product, - String configKey, - ConfigurationDeserializer deserializer, - ConfigurationChangesTypedListener listener); + void addListener( + Product product, + String configKey, + ConfigurationDeserializer deserializer, + ConfigurationChangesTypedListener listener); - void addListener(Product product, ConfigurationChangesListener configurationChangesListener); + void addListener(Product product, ConfigurationChangesListener configurationChangesListener); - void removeListeners(Product product); + void removeListeners(Product product); - void addConfigurationEndListener(ConfigurationEndListener listener); + void addConfigurationEndListener(ConfigurationEndListener listener); - void removeConfigurationEndListener(ConfigurationEndListener listener); + void removeConfigurationEndListener(ConfigurationEndListener listener); - void addCapabilities(long flags); + void addCapabilities(long flags); - void removeCapabilities(long flags); + void removeCapabilities(long flags); - void start(); + void start(); - void stop(); + void stop(); } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/PollingRateHinter.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/PollingRateHinter.java index 1b86f8ce3ae..8db98dcddc5 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/PollingRateHinter.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/PollingRateHinter.java @@ -4,5 +4,5 @@ @FunctionalInterface public interface PollingRateHinter { - void suggestPollingRate(Duration duration); + void suggestPollingRate(Duration duration); } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java index c018205d958..af03157579e 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java @@ -2,15 +2,15 @@ /* The order of these products is the same as the order in which they're processed */ public enum Product { - AGENT_CONFIG, - AGENT_TASK, - APM_TRACING, - LIVE_DEBUGGING, - LIVE_DEBUGGING_SYMBOL_DB, - ASM_DD, - ASM, - ASM_DATA, - ASM_FEATURES, - FFE_FLAGS, - _UNKNOWN, + AGENT_CONFIG, + AGENT_TASK, + APM_TRACING, + LIVE_DEBUGGING, + LIVE_DEBUGGING_SYMBOL_DB, + ASM_DD, + ASM, + ASM_DATA, + ASM_FEATURES, + FFE_FLAGS, + _UNKNOWN, } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ConfigKey.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ConfigKey.java index 9a76753577c..7f0232e2d20 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ConfigKey.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ConfigKey.java @@ -4,14 +4,14 @@ import javax.annotation.Nullable; public interface ConfigKey { - Product getProduct(); + Product getProduct(); - String getProductName(); + String getProductName(); - String getOrg(); + String getOrg(); - @Nullable - Integer getVersion(); + @Nullable + Integer getVersion(); - String getConfigId(); + String getConfigId(); } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ProductListener.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ProductListener.java index 588ffa300f3..8c35b820598 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ProductListener.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/state/ProductListener.java @@ -4,10 +4,9 @@ import java.io.IOException; public interface ProductListener { - void accept(ConfigKey configKey, byte[] content, PollingRateHinter pollingRateHinter) - throws IOException; + void accept(ConfigKey configKey, byte[] content, PollingRateHinter pollingRateHinter) throws IOException; - void remove(ConfigKey configKey, PollingRateHinter pollingRateHinter) throws IOException; + void remove(ConfigKey configKey, PollingRateHinter pollingRateHinter) throws IOException; - void commit(PollingRateHinter pollingRateHinter); + void commit(PollingRateHinter pollingRateHinter); } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java index 76ef06740b7..4ec02d74dd2 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/DefaultConfigurationPoller.java @@ -55,533 +55,509 @@ /** Handles polling debugger configuration from datadog agent/Remote Configuration */ public class DefaultConfigurationPoller - implements ConfigurationPoller, - PollingRateHinter, - AgentTaskScheduler.Target { - private static final Logger log = LoggerFactory.getLogger(ConfigurationPoller.class); - private static final int MINUTES_BETWEEN_ERROR_LOG = 5; - - private final String keyId; - private final Ed25519PublicKey key; - private final Config config; - private final String tracerVersion; - private final String containerId; - private final String entityId; - private final OkHttpClient httpClient; - private final RatelimitedLogger ratelimitedLogger; - private final Supplier urlSupplier; - private final PollerScheduler scheduler; - private final long maxPayloadSize; - private final boolean integrityChecks; - - private final Map productStates = new EnumMap<>(Product.class); - private final Map fileListeners = new HashMap<>(); - private final List configurationEndListeners = new ArrayList<>(); - - private final ClientState nextClientState = new ClientState(); - private final AtomicInteger startCount = new AtomicInteger(0); - private long capabilities; - private Duration durationHint; - private PollerRequestFactory requestFactory; - private RemoteConfigResponse.Factory responseFactory; - private boolean fatalOnInitialization = false; - - public DefaultConfigurationPoller( - Config config, - String tracerVersion, - String containerId, - String entityId, - Supplier urlSupplier, - OkHttpClient client) { - this( - config, - tracerVersion, - containerId, - entityId, - urlSupplier, - client, - new AgentTaskScheduler(AgentThreadFactory.AgentThread.REMOTE_CONFIG)); - } - - // for testing - public DefaultConfigurationPoller( - Config config, - String tracerVersion, - String containerId, - String entityId, - Supplier urlSupplier, - OkHttpClient httpClient, - AgentTaskScheduler taskScheduler) { - this.config = config; - this.tracerVersion = tracerVersion; - this.containerId = containerId; - this.entityId = entityId; - this.urlSupplier = urlSupplier; - this.keyId = config.getRemoteConfigTargetsKeyId(); - String keyStr = config.getRemoteConfigTargetsKey(); - try { - this.key = Ed25519PublicKey.fromByteArray(HexUtils.fromHexString(keyStr)); - } catch (InvalidEncodingException e) { - throw new IllegalArgumentException("Bad public key: " + keyStr, e); - } - - this.scheduler = new PollerScheduler(config, this, taskScheduler); - log.debug("Started remote config poller every {} ms", scheduler.getInitialPollInterval()); - this.ratelimitedLogger = - new RatelimitedLogger(log, MINUTES_BETWEEN_ERROR_LOG, TimeUnit.MINUTES); - this.maxPayloadSize = config.getRemoteConfigMaxPayloadSizeBytes(); - this.integrityChecks = config.isRemoteConfigIntegrityCheckEnabled(); - this.httpClient = httpClient; - } - - @Override - public synchronized void addListener( - Product product, ConfigurationChangesListener configurationChangesListener) { - this.addListener(product, new SimpleProductListener(configurationChangesListener)); - } - - @Override - public synchronized void addListener(Product product, ProductListener listener) { - ProductState productState = - this.productStates.computeIfAbsent(product, p -> new ProductState(product)); - productState.addProductListener(listener); - } - - @Override - public synchronized void addListener( - Product product, - ConfigurationDeserializer deserializer, - ConfigurationChangesTypedListener listener) { - this.addListener(product, new SimpleProductListener(useDeserializer(deserializer, listener))); - } - - @Override - public synchronized void addListener( - Product product, String configKey, ProductListener listener) { - ProductState productState = - this.productStates.computeIfAbsent(product, p -> new ProductState(product)); - productState.addProductListener(configKey, listener); - } - - @Override - public synchronized void addListener( - Product product, - String configKey, - ConfigurationDeserializer deserializer, - ConfigurationChangesTypedListener listener) { - this.addListener( - product, configKey, new SimpleProductListener(useDeserializer(deserializer, listener))); - } - - @Override - public synchronized void removeListeners(Product product) { - this.productStates.remove(product); - } - - public synchronized void addFileListener( - File file, - ConfigurationDeserializer deserializer, - ConfigurationChangesTypedListener listener) { - this.fileListeners.put(file, useDeserializer(deserializer, listener)); - } - - @Override - public synchronized void addConfigurationEndListener(ConfigurationEndListener listener) { - this.configurationEndListeners.add(listener); - } - - @Override - public synchronized void removeConfigurationEndListener(ConfigurationEndListener listener) { - this.configurationEndListeners.removeIf(l -> l == listener); - } - - @Override - public synchronized void addCapabilities(long flags) { - capabilities |= flags; - } - - @Override - public synchronized void removeCapabilities(long flags) { - capabilities &= ~flags; - } - - @Override - public ConfigurationPoller get() { - return this; - } - - @Override - public void start() { - int prevCount = this.startCount.getAndIncrement(); - if (prevCount == 0) { - scheduler.start(); - } - } - - @Override - public void stop() { - int newCount = this.startCount.decrementAndGet(); - if (newCount == 0) { - scheduler.stop(); - } - } - - synchronized void poll(ConfigurationPoller poller) { - for (Map.Entry e : this.fileListeners.entrySet()) { - loadFromFile(e.getKey(), e.getValue()); - } - - if (!this.productStates.isEmpty()) { - - if (!initialize()) { - // Do not log anything before initialization to avoid excessive verboseness when remote - // config is disabled in the agent. The urlSupplier will log failed attempts whenever it - // actually makes requests to feature discovery (at a higher polling interval). - return; - } - - try { - sendRequest(this::handleAgentResponse); - } catch (InterruptedIOException ignored) { - } catch (IOException | RuntimeException ex) { - ExceptionHelper.rateLimitedLogException( - ratelimitedLogger, - log, - ex, - "Failed to poll remote configuration from {}", - requestFactory.url.toString()); - } - } - } - - /** Tries to initialize remote config, and returns true if it is ready to run. */ - private boolean initialize() { - if (fatalOnInitialization) { - return false; - } - - if (requestFactory != null && responseFactory != null) { - return true; - } - - final String url = urlSupplier.get(); - if (url == null) { - return false; - } - - try { - // Initialization of these is delayed until the remote config URL is available. - // See #initialize(). - Moshi moshi = - new Moshi.Builder() - .add(Instant.class, new InstantJsonAdapter()) - .add(ByteString.class, new RawJsonAdapter()) - .build(); - this.responseFactory = new RemoteConfigResponse.Factory(moshi); - this.requestFactory = - new PollerRequestFactory(config, tracerVersion, containerId, entityId, url, moshi); - } catch (Exception e) { - // We can't recover from this, so we'll not try to initialize again. - fatalOnInitialization = true; - log.error("Remote configuration poller initialization failed", e); - } - return true; - } - - private Response fetchConfiguration() throws IOException { - Request request = - this.requestFactory.newConfigurationRequest( - getSubscribedProductNames(), - this.nextClientState, - getCachedTargetFiles(), - capabilities); - if (request == null) { - throw new IOException("Endpoint has not been discovered yet"); - } - log.debug("Sending Remote configuration request: {}", request); - Call call = this.httpClient.newCall(request); - return call.execute(); - } - - private Collection getSubscribedProductNames() { - return this.productStates.keySet().stream().map(Enum::name).collect(Collectors.toList()); - } - - List getCachedTargetFiles() { - List cachedTargetFiles = new ArrayList<>(); - - for (ProductState state : productStates.values()) { - cachedTargetFiles.addAll(state.getCachedTargetFiles()); - } - return cachedTargetFiles; - } - - List getConfigState() { - List configStates = new ArrayList<>(); - - for (ProductState state : productStates.values()) { - configStates.addAll(state.getConfigStates()); - } - return configStates; - } - - void sendRequest(Consumer responseBodyConsumer) throws IOException { - try (Response response = fetchConfiguration()) { - if (response.code() == 404) { - log.debug("Remote configuration endpoint is disabled"); - return; - } - if (response.code() == 204) { - log.debug("No configuration changes (HTTP 204 No Content)"); - return; - } - ResponseBody body = response.body(); - if (response.isSuccessful()) { - if (body == null) { - ratelimitedLogger.warn("No body content while retrieving remote configuration"); - return; + implements ConfigurationPoller, PollingRateHinter, AgentTaskScheduler.Target { + private static final Logger log = LoggerFactory.getLogger(ConfigurationPoller.class); + private static final int MINUTES_BETWEEN_ERROR_LOG = 5; + + private final String keyId; + private final Ed25519PublicKey key; + private final Config config; + private final String tracerVersion; + private final String containerId; + private final String entityId; + private final OkHttpClient httpClient; + private final RatelimitedLogger ratelimitedLogger; + private final Supplier urlSupplier; + private final PollerScheduler scheduler; + private final long maxPayloadSize; + private final boolean integrityChecks; + + private final Map productStates = new EnumMap<>(Product.class); + private final Map fileListeners = new HashMap<>(); + private final List configurationEndListeners = new ArrayList<>(); + + private final ClientState nextClientState = new ClientState(); + private final AtomicInteger startCount = new AtomicInteger(0); + private long capabilities; + private Duration durationHint; + private PollerRequestFactory requestFactory; + private RemoteConfigResponse.Factory responseFactory; + private boolean fatalOnInitialization = false; + + public DefaultConfigurationPoller( + Config config, + String tracerVersion, + String containerId, + String entityId, + Supplier urlSupplier, + OkHttpClient client) { + this( + config, + tracerVersion, + containerId, + entityId, + urlSupplier, + client, + new AgentTaskScheduler(AgentThreadFactory.AgentThread.REMOTE_CONFIG)); + } + + // for testing + public DefaultConfigurationPoller( + Config config, + String tracerVersion, + String containerId, + String entityId, + Supplier urlSupplier, + OkHttpClient httpClient, + AgentTaskScheduler taskScheduler) { + this.config = config; + this.tracerVersion = tracerVersion; + this.containerId = containerId; + this.entityId = entityId; + this.urlSupplier = urlSupplier; + this.keyId = config.getRemoteConfigTargetsKeyId(); + String keyStr = config.getRemoteConfigTargetsKey(); + try { + this.key = Ed25519PublicKey.fromByteArray(HexUtils.fromHexString(keyStr)); + } catch (InvalidEncodingException e) { + throw new IllegalArgumentException("Bad public key: " + keyStr, e); + } + + this.scheduler = new PollerScheduler(config, this, taskScheduler); + log.debug("Started remote config poller every {} ms", scheduler.getInitialPollInterval()); + this.ratelimitedLogger = new RatelimitedLogger(log, MINUTES_BETWEEN_ERROR_LOG, TimeUnit.MINUTES); + this.maxPayloadSize = config.getRemoteConfigMaxPayloadSizeBytes(); + this.integrityChecks = config.isRemoteConfigIntegrityCheckEnabled(); + this.httpClient = httpClient; + } + + @Override + public synchronized void addListener(Product product, ConfigurationChangesListener configurationChangesListener) { + this.addListener(product, new SimpleProductListener(configurationChangesListener)); + } + + @Override + public synchronized void addListener(Product product, ProductListener listener) { + ProductState productState = this.productStates.computeIfAbsent(product, p -> new ProductState(product)); + productState.addProductListener(listener); + } + + @Override + public synchronized void addListener( + Product product, ConfigurationDeserializer deserializer, ConfigurationChangesTypedListener listener) { + this.addListener(product, new SimpleProductListener(useDeserializer(deserializer, listener))); + } + + @Override + public synchronized void addListener(Product product, String configKey, ProductListener listener) { + ProductState productState = this.productStates.computeIfAbsent(product, p -> new ProductState(product)); + productState.addProductListener(configKey, listener); + } + + @Override + public synchronized void addListener( + Product product, + String configKey, + ConfigurationDeserializer deserializer, + ConfigurationChangesTypedListener listener) { + this.addListener(product, configKey, new SimpleProductListener(useDeserializer(deserializer, listener))); + } + + @Override + public synchronized void removeListeners(Product product) { + this.productStates.remove(product); + } + + public synchronized void addFileListener( + File file, ConfigurationDeserializer deserializer, ConfigurationChangesTypedListener listener) { + this.fileListeners.put(file, useDeserializer(deserializer, listener)); + } + + @Override + public synchronized void addConfigurationEndListener(ConfigurationEndListener listener) { + this.configurationEndListeners.add(listener); + } + + @Override + public synchronized void removeConfigurationEndListener(ConfigurationEndListener listener) { + this.configurationEndListeners.removeIf(l -> l == listener); + } + + @Override + public synchronized void addCapabilities(long flags) { + capabilities |= flags; + } + + @Override + public synchronized void removeCapabilities(long flags) { + capabilities &= ~flags; + } + + @Override + public ConfigurationPoller get() { + return this; + } + + @Override + public void start() { + int prevCount = this.startCount.getAndIncrement(); + if (prevCount == 0) { + scheduler.start(); + } + } + + @Override + public void stop() { + int newCount = this.startCount.decrementAndGet(); + if (newCount == 0) { + scheduler.stop(); + } + } + + synchronized void poll(ConfigurationPoller poller) { + for (Map.Entry e : this.fileListeners.entrySet()) { + loadFromFile(e.getKey(), e.getValue()); + } + + if (!this.productStates.isEmpty()) { + + if (!initialize()) { + // Do not log anything before initialization to avoid excessive verboseness when remote + // config is disabled in the agent. The urlSupplier will log failed attempts whenever it + // actually makes requests to feature discovery (at a higher polling interval). + return; + } + + try { + sendRequest(this::handleAgentResponse); + } catch (InterruptedIOException ignored) { + } catch (IOException | RuntimeException ex) { + ExceptionHelper.rateLimitedLogException( + ratelimitedLogger, + log, + ex, + "Failed to poll remote configuration from {}", + requestFactory.url.toString()); + } + } + } + + /** Tries to initialize remote config, and returns true if it is ready to run. */ + private boolean initialize() { + if (fatalOnInitialization) { + return false; + } + + if (requestFactory != null && responseFactory != null) { + return true; + } + + final String url = urlSupplier.get(); + if (url == null) { + return false; + } + + try { + // Initialization of these is delayed until the remote config URL is available. + // See #initialize(). + Moshi moshi = new Moshi.Builder() + .add(Instant.class, new InstantJsonAdapter()) + .add(ByteString.class, new RawJsonAdapter()) + .build(); + this.responseFactory = new RemoteConfigResponse.Factory(moshi); + this.requestFactory = new PollerRequestFactory(config, tracerVersion, containerId, entityId, url, moshi); + } catch (Exception e) { + // We can't recover from this, so we'll not try to initialize again. + fatalOnInitialization = true; + log.error("Remote configuration poller initialization failed", e); + } + return true; + } + + private Response fetchConfiguration() throws IOException { + Request request = this.requestFactory.newConfigurationRequest( + getSubscribedProductNames(), this.nextClientState, getCachedTargetFiles(), capabilities); + if (request == null) { + throw new IOException("Endpoint has not been discovered yet"); + } + log.debug("Sending Remote configuration request: {}", request); + Call call = this.httpClient.newCall(request); + return call.execute(); + } + + private Collection getSubscribedProductNames() { + return this.productStates.keySet().stream().map(Enum::name).collect(Collectors.toList()); + } + + List getCachedTargetFiles() { + List cachedTargetFiles = new ArrayList<>(); + + for (ProductState state : productStates.values()) { + cachedTargetFiles.addAll(state.getCachedTargetFiles()); + } + return cachedTargetFiles; + } + + List getConfigState() { + List configStates = new ArrayList<>(); + + for (ProductState state : productStates.values()) { + configStates.addAll(state.getConfigStates()); + } + return configStates; + } + + void sendRequest(Consumer responseBodyConsumer) throws IOException { + try (Response response = fetchConfiguration()) { + if (response.code() == 404) { + log.debug("Remote configuration endpoint is disabled"); + return; + } + if (response.code() == 204) { + log.debug("No configuration changes (HTTP 204 No Content)"); + return; + } + ResponseBody body = response.body(); + if (response.isSuccessful()) { + if (body == null) { + ratelimitedLogger.warn("No body content while retrieving remote configuration"); + return; + } + responseBodyConsumer.accept(body); + return; + } + // Retrieve body content for detailed error messages + if (body != null) { + try { + ratelimitedLogger.warn( + "Failed to retrieve remote configuration: unexpected response code {} {} {}", + response.message(), + response.code(), + body.string()); + } catch (IOException ex) { + ExceptionHelper.rateLimitedLogException( + ratelimitedLogger, log, ex, "Error while getting error message body"); + } + } else { + ratelimitedLogger.warn( + "Failed to retrieve remote configuration: unexpected response code {} {}", + response.message(), + response.code()); + } + } + } + + private void handleAgentResponse(ResponseBody body) { + RemoteConfigResponse fleetResponse; + + try (InputStream inputStream = new SizeCheckedInputStream(body.byteStream(), maxPayloadSize)) { + Optional maybeFleetResp; + maybeFleetResp = this.responseFactory.fromInputStream(inputStream); + if (!maybeFleetResp.isPresent()) { + log.debug("No configuration changes"); + return; + } + + fleetResponse = maybeFleetResp.get(); + } catch (Exception e) { + // no error can be reported, as we don't have the data client.state.targets_version avail + if (e instanceof ClosedSelectorException) { + // expected when the selector is closed mid-poll, e.g. during pod shutdown + ratelimitedLogger.warn( + "Error parsing remote config response, which is expected when the selector is closed mid-poll: {}", + e.getMessage()); + } else { + ratelimitedLogger.warn("Error parsing remote config response", e); + } + return; + } + + if (log.isDebugEnabled() && fleetResponse.getTargetsSigned() != null) { + log.debug("Got configuration with targets version {}", fleetResponse.getTargetsSigned().version); } - responseBodyConsumer.accept(body); - return; - } - // Retrieve body content for detailed error messages - if (body != null) { + try { - ratelimitedLogger.warn( - "Failed to retrieve remote configuration: unexpected response code {} {} {}", - response.message(), - response.code(), - body.string()); - } catch (IOException ex) { - ExceptionHelper.rateLimitedLogException( - ratelimitedLogger, log, ex, "Error while getting error message body"); + verifyTargetsSignature(fleetResponse); + verifyTargetsPresence(fleetResponse); + } catch (RuntimeException rte) { + ratelimitedLogger.warn("Error doing initial verifications: {}", rte.getMessage(), rte); + this.nextClientState.hasError = true; + this.nextClientState.error = rte.getMessage(); + return; + } + + List errors = new ArrayList<>(); + + Map> parsedKeysByProduct = new HashMap<>(); + + for (String configKey : fleetResponse.getClientConfigs()) { + try { + ParsedConfigKey parsedConfigKey = ParsedConfigKey.parse(configKey); + Product product = parsedConfigKey.getProduct(); + if (!(productStates.containsKey(product))) { + throw new ReportableException("Told to handle config key " + + configKey + + ", but the product " + + parsedConfigKey.getProductName() + + " is not being handled"); + } + parsedKeysByProduct + .computeIfAbsent(product, k -> new ArrayList<>()) + .add(parsedConfigKey); + } catch (ReportableException e) { + errors.add(e); + } } - } else { - ratelimitedLogger.warn( - "Failed to retrieve remote configuration: unexpected response code {} {}", - response.message(), - response.code()); - } - } - } - - private void handleAgentResponse(ResponseBody body) { - RemoteConfigResponse fleetResponse; - - try (InputStream inputStream = new SizeCheckedInputStream(body.byteStream(), maxPayloadSize)) { - Optional maybeFleetResp; - maybeFleetResp = this.responseFactory.fromInputStream(inputStream); - if (!maybeFleetResp.isPresent()) { - log.debug("No configuration changes"); - return; - } - - fleetResponse = maybeFleetResp.get(); - } catch (Exception e) { - // no error can be reported, as we don't have the data client.state.targets_version avail - if (e instanceof ClosedSelectorException) { - // expected when the selector is closed mid-poll, e.g. during pod shutdown - ratelimitedLogger.warn( - "Error parsing remote config response, which is expected when the selector is closed mid-poll: {}", - e.getMessage()); - } else { - ratelimitedLogger.warn("Error parsing remote config response", e); - } - return; - } - - if (log.isDebugEnabled() && fleetResponse.getTargetsSigned() != null) { - log.debug( - "Got configuration with targets version {}", fleetResponse.getTargetsSigned().version); - } - - try { - verifyTargetsSignature(fleetResponse); - verifyTargetsPresence(fleetResponse); - } catch (RuntimeException rte) { - ratelimitedLogger.warn("Error doing initial verifications: {}", rte.getMessage(), rte); - this.nextClientState.hasError = true; - this.nextClientState.error = rte.getMessage(); - return; - } - - List errors = new ArrayList<>(); - - Map> parsedKeysByProduct = new HashMap<>(); - - for (String configKey : fleetResponse.getClientConfigs()) { - try { - ParsedConfigKey parsedConfigKey = ParsedConfigKey.parse(configKey); - Product product = parsedConfigKey.getProduct(); - if (!(productStates.containsKey(product))) { - throw new ReportableException( - "Told to handle config key " - + configKey - + ", but the product " - + parsedConfigKey.getProductName() - + " is not being handled"); + + boolean appliedAny = false; + for (Map.Entry entry : productStates.entrySet()) { + Product product = entry.getKey(); + ProductState state = entry.getValue(); + List relevantKeys = parsedKeysByProduct.getOrDefault(product, Collections.EMPTY_LIST); + appliedAny = state.apply(fleetResponse, relevantKeys, this) || appliedAny; + if (state.hasError()) { + errors.addAll(state.getErrors()); + } + } + + if (appliedAny) { + for (ConfigurationEndListener listener : this.configurationEndListeners) { + runConfigurationEndListener(listener, errors); + } } - parsedKeysByProduct.computeIfAbsent(product, k -> new ArrayList<>()).add(parsedConfigKey); - } catch (ReportableException e) { - errors.add(e); - } - } - - boolean appliedAny = false; - for (Map.Entry entry : productStates.entrySet()) { - Product product = entry.getKey(); - ProductState state = entry.getValue(); - List relevantKeys = - parsedKeysByProduct.getOrDefault(product, Collections.EMPTY_LIST); - appliedAny = state.apply(fleetResponse, relevantKeys, this) || appliedAny; - if (state.hasError()) { - errors.addAll(state.getErrors()); - } - } - - if (appliedAny) { - for (ConfigurationEndListener listener : this.configurationEndListeners) { - runConfigurationEndListener(listener, errors); - } - } - - updateNextState(fleetResponse, buildErrorMessage(errors)); - - rescheduleBaseOnConfiguration(this.durationHint); - } - - private void runConfigurationEndListener( - ConfigurationEndListener listener, List errors) { - try { - listener.onConfigurationEnd(); - } catch (ReportableException re) { - errors.add(re); - } catch (RuntimeException rte) { - // XXX: we have no way to report this error back - // This is because errors are scoped to a specific config key and this listener - // is about combining configuration from different products - ratelimitedLogger.warn( - "Error running configuration listener {}: {}", listener, rte.getMessage(), rte); - } - } - - private String buildErrorMessage(List errors) { - if (errors.isEmpty()) { - return null; - } - if (errors.size() == 1) { - return errors.get(0).getMessage(); - } - StringBuilder aggregateMessage = new StringBuilder(); - aggregateMessage.append( - String.format("Failed to apply configuration due to %d errors:%n", errors.size())); - for (int i = 0; i < errors.size(); i++) { - aggregateMessage.append(String.format(" (%d) %s%n", i + 1, errors.get(i).getMessage())); - } - return aggregateMessage.toString(); - } - - /** - * @param fleetResponse the response the agent sent - * @param error a procedural error that occurred during the update; it does not include errors - * during registered deserializers and config listeners. If there is an error, the - * targets_version in client.state is not updated (but the rest is). - */ - private void updateNextState(RemoteConfigResponse fleetResponse, String error) { - RemoteConfigResponse.Targets.TargetsSigned targetsSigned = fleetResponse.getTargetsSigned(); - - long newTargetsVersion = targetsSigned.version; - this.nextClientState.setState( - // if there was an error, we did not apply the configurations fully - // the system tests expect here the targets version not to be updated - error == null ? newTargetsVersion : this.nextClientState.targetsVersion, - getConfigState(), - error, - targetsSigned.custom != null ? targetsSigned.custom.opaqueBackendState : null); - } - - private void rescheduleBaseOnConfiguration(Duration hint) { - if (hint == null) { - return; - } - - scheduler.reschedule(hint.toMillis()); - } - - private void loadFromFile(File file, ConfigurationChangesListener listener) { - log.debug("Loading configuration from file {}", file); - - try (InputStream inputStream = - new SizeCheckedInputStream(Files.newInputStream(file.toPath()), maxPayloadSize)) { - byte[] buffer = new byte[4096]; - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(4096); - int bytesRead; - do { - bytesRead = inputStream.read(buffer); - if (bytesRead > -1) { - outputStream.write(buffer, 0, bytesRead); + + updateNextState(fleetResponse, buildErrorMessage(errors)); + + rescheduleBaseOnConfiguration(this.durationHint); + } + + private void runConfigurationEndListener(ConfigurationEndListener listener, List errors) { + try { + listener.onConfigurationEnd(); + } catch (ReportableException re) { + errors.add(re); + } catch (RuntimeException rte) { + // XXX: we have no way to report this error back + // This is because errors are scoped to a specific config key and this listener + // is about combining configuration from different products + ratelimitedLogger.warn("Error running configuration listener {}: {}", listener, rte.getMessage(), rte); } - } while (bytesRead > -1); - - try { - listener.accept(file.getAbsolutePath(), outputStream.toByteArray(), NOOP); - log.debug("Loaded configuration from file {}", file); - } catch (Exception ex) { - ratelimitedLogger.warn( - "Failed reading or applying configuration from {}: {}", file, ex.getMessage()); - } - } catch (IOException | RuntimeException ex) { - ExceptionHelper.rateLimitedLogException( - ratelimitedLogger, log, ex, "Unable to load config file: {}.", file); - } - } - - @Override - public void suggestPollingRate(Duration duration) { - if (durationHint == null) { - durationHint = duration; - } else if (duration.compareTo(durationHint) < 0) { - durationHint = duration; - } - } - - private void verifyTargetsSignature(RemoteConfigResponse resp) { - if (!integrityChecks) { - return; - } - - Ed25519Signature sig; - byte[] canonicalTargetsSigned; - try { - { - String targetsSignatureStr = resp.getTargetsSignature(this.keyId); - sig = Ed25519Signature.fromByteArray(HexUtils.fromHexString(targetsSignatureStr)); - } - { - Map untypedTargetsSigned = resp.getUntypedTargetsSigned(); - canonicalTargetsSigned = JsonCanonicalizer.canonicalize(untypedTargetsSigned); - } - } catch (RuntimeException rte) { - throw new ReportableException( - "Error reading signature or canonicalizing targets.signed: " + rte.getMessage(), rte); - } - boolean valid = this.key.verify(canonicalTargetsSigned, sig); - if (!valid) { - throw new ReportableException( - "Signature verification failed for targets.signed. Key id: " + this.keyId); - } - } - - private void verifyTargetsPresence(RemoteConfigResponse resp) { - if (resp.targetFiles == null) { - return; - } - for (RemoteConfigResponse.TargetFile file : resp.targetFiles) { - RemoteConfigResponse.Targets.ConfigTarget target = resp.getTarget(file.path); - if (target == null) { - throw new ReportableException( - "Path " + file.path + " is in target_files, but not in targets.signed"); - } - } - } + } + + private String buildErrorMessage(List errors) { + if (errors.isEmpty()) { + return null; + } + if (errors.size() == 1) { + return errors.get(0).getMessage(); + } + StringBuilder aggregateMessage = new StringBuilder(); + aggregateMessage.append(String.format("Failed to apply configuration due to %d errors:%n", errors.size())); + for (int i = 0; i < errors.size(); i++) { + aggregateMessage.append( + String.format(" (%d) %s%n", i + 1, errors.get(i).getMessage())); + } + return aggregateMessage.toString(); + } + + /** + * @param fleetResponse the response the agent sent + * @param error a procedural error that occurred during the update; it does not include errors + * during registered deserializers and config listeners. If there is an error, the + * targets_version in client.state is not updated (but the rest is). + */ + private void updateNextState(RemoteConfigResponse fleetResponse, String error) { + RemoteConfigResponse.Targets.TargetsSigned targetsSigned = fleetResponse.getTargetsSigned(); + + long newTargetsVersion = targetsSigned.version; + this.nextClientState.setState( + // if there was an error, we did not apply the configurations fully + // the system tests expect here the targets version not to be updated + error == null ? newTargetsVersion : this.nextClientState.targetsVersion, + getConfigState(), + error, + targetsSigned.custom != null ? targetsSigned.custom.opaqueBackendState : null); + } + + private void rescheduleBaseOnConfiguration(Duration hint) { + if (hint == null) { + return; + } + + scheduler.reschedule(hint.toMillis()); + } + + private void loadFromFile(File file, ConfigurationChangesListener listener) { + log.debug("Loading configuration from file {}", file); + + try (InputStream inputStream = + new SizeCheckedInputStream(Files.newInputStream(file.toPath()), maxPayloadSize)) { + byte[] buffer = new byte[4096]; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(4096); + int bytesRead; + do { + bytesRead = inputStream.read(buffer); + if (bytesRead > -1) { + outputStream.write(buffer, 0, bytesRead); + } + } while (bytesRead > -1); + + try { + listener.accept(file.getAbsolutePath(), outputStream.toByteArray(), NOOP); + log.debug("Loaded configuration from file {}", file); + } catch (Exception ex) { + ratelimitedLogger.warn("Failed reading or applying configuration from {}: {}", file, ex.getMessage()); + } + } catch (IOException | RuntimeException ex) { + ExceptionHelper.rateLimitedLogException( + ratelimitedLogger, log, ex, "Unable to load config file: {}.", file); + } + } + + @Override + public void suggestPollingRate(Duration duration) { + if (durationHint == null) { + durationHint = duration; + } else if (duration.compareTo(durationHint) < 0) { + durationHint = duration; + } + } + + private void verifyTargetsSignature(RemoteConfigResponse resp) { + if (!integrityChecks) { + return; + } + + Ed25519Signature sig; + byte[] canonicalTargetsSigned; + try { + { + String targetsSignatureStr = resp.getTargetsSignature(this.keyId); + sig = Ed25519Signature.fromByteArray(HexUtils.fromHexString(targetsSignatureStr)); + } + { + Map untypedTargetsSigned = resp.getUntypedTargetsSigned(); + canonicalTargetsSigned = JsonCanonicalizer.canonicalize(untypedTargetsSigned); + } + } catch (RuntimeException rte) { + throw new ReportableException( + "Error reading signature or canonicalizing targets.signed: " + rte.getMessage(), rte); + } + boolean valid = this.key.verify(canonicalTargetsSigned, sig); + if (!valid) { + throw new ReportableException("Signature verification failed for targets.signed. Key id: " + this.keyId); + } + } + + private void verifyTargetsPresence(RemoteConfigResponse resp) { + if (resp.targetFiles == null) { + return; + } + for (RemoteConfigResponse.TargetFile file : resp.targetFiles) { + RemoteConfigResponse.Targets.ConfigTarget target = resp.getTarget(file.path); + if (target == null) { + throw new ReportableException("Path " + file.path + " is in target_files, but not in targets.signed"); + } + } + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ExceptionHelper.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ExceptionHelper.java index edb303992f3..7fca2398310 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ExceptionHelper.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ExceptionHelper.java @@ -6,31 +6,27 @@ /** Helper class for rate limiting & logging exceptions */ public class ExceptionHelper { - private static final Object[] EMPTY_ARRAY = new Object[0]; + private static final Object[] EMPTY_ARRAY = new Object[0]; - public static void logException(Logger log, Throwable ex, String message, Object... params) { - if (log.isDebugEnabled()) { - if (params == null) { - params = EMPTY_ARRAY; - } - Object[] fullParams = Arrays.copyOf(params, params.length + 1); - fullParams[params.length] = ex; - log.debug(message, fullParams); - } else { - log.warn(message + " " + ex.toString(), params); + public static void logException(Logger log, Throwable ex, String message, Object... params) { + if (log.isDebugEnabled()) { + if (params == null) { + params = EMPTY_ARRAY; + } + Object[] fullParams = Arrays.copyOf(params, params.length + 1); + fullParams[params.length] = ex; + log.debug(message, fullParams); + } else { + log.warn(message + " " + ex.toString(), params); + } } - } - public static void rateLimitedLogException( - RatelimitedLogger ratelimitedLogger, - Logger log, - Throwable ex, - String message, - Object... params) { - if (log.isDebugEnabled()) { - logException(log, ex, message, params); - } else { - ratelimitedLogger.warn(message + " " + ex.toString(), params); + public static void rateLimitedLogException( + RatelimitedLogger ratelimitedLogger, Logger log, Throwable ex, String message, Object... params) { + if (log.isDebugEnabled()) { + logException(log, ex, message, params); + } else { + ratelimitedLogger.warn(message + " " + ex.toString(), params); + } } - } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/HexUtils.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/HexUtils.java index e21937b07d6..408f8350e0b 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/HexUtils.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/HexUtils.java @@ -3,47 +3,45 @@ import java.util.Arrays; public class HexUtils { - private static final int[] DEC = - new int[] { - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, 10, 11, 12, 13, 14, 15 - }; - - private HexUtils() {} - - // only accepts 128 or 256 bit input - public static byte[] fromHexString(String input) { - if (input == null) { - return null; + private static final int[] DEC = new int[] { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15 + }; + + private HexUtils() {} + + // only accepts 128 or 256 bit input + public static byte[] fromHexString(String input) { + if (input == null) { + return null; + } + + int bufferSize = input.length() / 2 > 32 ? 64 : 32; + int diff = bufferSize * 2 - input.length(); + char[] inputChars; + if (diff < 0) { + throw new IllegalArgumentException("Hex data is too large"); + } else if (diff > 0) { + inputChars = new char[diff + input.length()]; + Arrays.fill(inputChars, 0, diff, '0'); + System.arraycopy(input.toCharArray(), 0, inputChars, diff, input.length()); + } else { + inputChars = input.toCharArray(); + } + byte[] result = new byte[inputChars.length / 2]; + + for (int i = 0; i < result.length; ++i) { + result[i] = (byte) ((getDec(inputChars[2 * i]) << 4) + getDec(inputChars[2 * i + 1])); + } + + return result; } - int bufferSize = input.length() / 2 > 32 ? 64 : 32; - int diff = bufferSize * 2 - input.length(); - char[] inputChars; - if (diff < 0) { - throw new IllegalArgumentException("Hex data is too large"); - } else if (diff > 0) { - inputChars = new char[diff + input.length()]; - Arrays.fill(inputChars, 0, diff, '0'); - System.arraycopy(input.toCharArray(), 0, inputChars, diff, input.length()); - } else { - inputChars = input.toCharArray(); + private static int getDec(int index) { + int lookupIdx = index - 48; + if (lookupIdx < 0 || lookupIdx >= DEC.length) { + return -1; + } + return DEC[lookupIdx]; } - byte[] result = new byte[inputChars.length / 2]; - - for (int i = 0; i < result.length; ++i) { - result[i] = (byte) ((getDec(inputChars[2 * i]) << 4) + getDec(inputChars[2 * i + 1])); - } - - return result; - } - - private static int getDec(int index) { - int lookupIdx = index - 48; - if (lookupIdx < 0 || lookupIdx >= DEC.length) { - return -1; - } - return DEC[lookupIdx]; - } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/JsonCanonicalizer.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/JsonCanonicalizer.java index 081e99eab9b..9274c433daf 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/JsonCanonicalizer.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/JsonCanonicalizer.java @@ -6,102 +6,99 @@ import java.util.Map; public class JsonCanonicalizer { - private static final byte[] TRUE_CONSTANT = {'t', 'r', 'u', 'e'}; - private static final byte[] FALSE_CONSTANT = {'f', 'a', 'l', 's', 'e'}; - private static final byte[] NULL_CONSTANT = {'n', 'u', 'l', 'l'}; + private static final byte[] TRUE_CONSTANT = {'t', 'r', 'u', 'e'}; + private static final byte[] FALSE_CONSTANT = {'f', 'a', 'l', 's', 'e'}; + private static final byte[] NULL_CONSTANT = {'n', 'u', 'l', 'l'}; - public static byte[] canonicalize(Map map) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - serialize(baos, map); - return baos.toByteArray(); - } + public static byte[] canonicalize(Map map) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + serialize(baos, map); + return baos.toByteArray(); + } - private static void serialize(ByteArrayOutputStream os, Object o) { - if (o == null) { - os.write(NULL_CONSTANT, 0, NULL_CONSTANT.length); - } else if (o instanceof String) { - serialize(os, (String) o); - } else if (o instanceof Number) { - serialize(os, ((Number) o).longValue()); - } else if (o instanceof Boolean) { - serialize(os, (Boolean) o); - } else if (o instanceof List) { - serialize(os, (List) o); - } else if (o instanceof Map) { - serialize(os, (Map) o); - } else { - throw new RuntimeException("Unexpected type for: " + o); + private static void serialize(ByteArrayOutputStream os, Object o) { + if (o == null) { + os.write(NULL_CONSTANT, 0, NULL_CONSTANT.length); + } else if (o instanceof String) { + serialize(os, (String) o); + } else if (o instanceof Number) { + serialize(os, ((Number) o).longValue()); + } else if (o instanceof Boolean) { + serialize(os, (Boolean) o); + } else if (o instanceof List) { + serialize(os, (List) o); + } else if (o instanceof Map) { + serialize(os, (Map) o); + } else { + throw new RuntimeException("Unexpected type for: " + o); + } } - } - private static void serialize(ByteArrayOutputStream os, String s) { - os.write('"'); - s.codePoints() - .forEach( - codepoint -> { - if (codepoint == '\\' || codepoint == '"') { + private static void serialize(ByteArrayOutputStream os, String s) { + os.write('"'); + s.codePoints().forEach(codepoint -> { + if (codepoint == '\\' || codepoint == '"') { os.write('\\'); - } - if (codepoint < 0x80) { + } + if (codepoint < 0x80) { os.write(codepoint); - } else if (codepoint < 0x800) { + } else if (codepoint < 0x800) { os.write(0xc0 | (codepoint >> 6)); os.write(0x80 | (codepoint & 0x3f)); - } else if (codepoint < 0x10000) { + } else if (codepoint < 0x10000) { os.write(0xe0 | ((codepoint >> 12))); os.write(0x80 | ((codepoint >> 6) & 0x3f)); os.write(0x80 | (codepoint & 0x3f)); - } else { + } else { os.write(0xf0 | ((codepoint >> 18))); os.write(0x80 | ((codepoint >> 12) & 0x3f)); os.write(0x80 | ((codepoint >> 6) & 0x3f)); os.write(0x80 | (codepoint & 0x3f)); - } - }); - os.write('"'); - } + } + }); + os.write('"'); + } - private static void serialize(ByteArrayOutputStream os, long l) { - byte[] bytes = Long.toString(l).getBytes(StandardCharsets.US_ASCII); - os.write(bytes, 0, bytes.length); - } + private static void serialize(ByteArrayOutputStream os, long l) { + byte[] bytes = Long.toString(l).getBytes(StandardCharsets.US_ASCII); + os.write(bytes, 0, bytes.length); + } - private static void serialize(ByteArrayOutputStream os, Boolean b) { - byte[] arr = b.booleanValue() ? TRUE_CONSTANT : FALSE_CONSTANT; - os.write(arr, 0, arr.length); - } + private static void serialize(ByteArrayOutputStream os, Boolean b) { + byte[] arr = b.booleanValue() ? TRUE_CONSTANT : FALSE_CONSTANT; + os.write(arr, 0, arr.length); + } - private static void serialize(ByteArrayOutputStream os, List list) { - os.write('['); - for (int i = 0; i < list.size(); i++) { - if (i != 0) { - os.write(','); - } - serialize(os, list.get(i)); + private static void serialize(ByteArrayOutputStream os, List list) { + os.write('['); + for (int i = 0; i < list.size(); i++) { + if (i != 0) { + os.write(','); + } + serialize(os, list.get(i)); + } + os.write(']'); } - os.write(']'); - } - private static void serialize(ByteArrayOutputStream os, Map map) { - os.write('{'); + private static void serialize(ByteArrayOutputStream os, Map map) { + os.write('{'); - boolean[] first = new boolean[] {true}; - map.entrySet().stream() - // the canonical json spec only says "keys are lexicographically sorted" - .sorted((e1, e2) -> String.CASE_INSENSITIVE_ORDER.compare(e1.getKey(), e2.getKey())) - .forEach( - e -> { - if (first[0]) { - first[0] = false; - } else { - os.write(','); - } + boolean[] first = new boolean[] {true}; + map.entrySet().stream() + // the canonical json spec only says "keys are lexicographically sorted" + .sorted((e1, e2) -> String.CASE_INSENSITIVE_ORDER.compare(e1.getKey(), e2.getKey())) + .forEach(e -> { + if (first[0]) { + first[0] = false; + } else { + os.write(','); + } - serialize(os, e.getKey()); - os.write(':'); - serialize(os, e.getValue()); - }); + serialize(os, e.getKey()); + os.write(':'); + serialize(os, e.getValue()); + }); - os.write('}'); - } + os.write('}'); + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerRequestFactory.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerRequestFactory.java index f4714afd973..986e130d9b5 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerRequestFactory.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerRequestFactory.java @@ -24,142 +24,132 @@ /** Factory for creating OkHttp requests */ public class PollerRequestFactory { - private static final String HEADER_DD_API_KEY = "DD-API-KEY"; - private static final String HEADER_CONTAINER_ID = "Datadog-Container-ID"; - private static final String HEADER_ENTITY_ID = "Datadog-Entity-ID"; + private static final String HEADER_DD_API_KEY = "DD-API-KEY"; + private static final String HEADER_CONTAINER_ID = "Datadog-Container-ID"; + private static final String HEADER_ENTITY_ID = "Datadog-Entity-ID"; - private static final Logger log = LoggerFactory.getLogger(PollerRequestFactory.class); + private static final Logger log = LoggerFactory.getLogger(PollerRequestFactory.class); - private final String clientId = RandomUtils.randomUUID().toString(); - private final String runtimeId; - private final String serviceName; - private final String apiKey; - private final String env; - private final String ddVersion; - private final String hostName; - private final String tracerVersion; - private final String containerId; - private final String entityId; - private final Moshi moshi; - final HttpUrl url; + private final String clientId = RandomUtils.randomUUID().toString(); + private final String runtimeId; + private final String serviceName; + private final String apiKey; + private final String env; + private final String ddVersion; + private final String hostName; + private final String tracerVersion; + private final String containerId; + private final String entityId; + private final Moshi moshi; + final HttpUrl url; - public PollerRequestFactory( - Config config, - String tracerVersion, - String containerId, - String entityId, - String url, - Moshi moshi) { - this.runtimeId = getRuntimeId(config); - this.serviceName = TagsHelper.sanitize(config.getServiceName()); - this.apiKey = config.getApiKey(); - this.env = TagsHelper.sanitize(config.getEnv()); - this.ddVersion = TagsHelper.sanitize(config.getVersion()); - this.hostName = config.getHostName(); - // Semantic Versioning requires build separated with `+` - this.tracerVersion = tracerVersion.replace('~', '+'); - this.containerId = containerId; - this.entityId = entityId; - this.url = parseUrl(url); - this.moshi = moshi; - } - - private static String getRuntimeId(Config config) { - String runtimeId = config.getRuntimeId(); - if (runtimeId == null || runtimeId.isEmpty()) { - log.debug("runtimeId not configured, generating a new UUID"); - runtimeId = RandomUtils.randomUUID().toString(); + public PollerRequestFactory( + Config config, String tracerVersion, String containerId, String entityId, String url, Moshi moshi) { + this.runtimeId = getRuntimeId(config); + this.serviceName = TagsHelper.sanitize(config.getServiceName()); + this.apiKey = config.getApiKey(); + this.env = TagsHelper.sanitize(config.getEnv()); + this.ddVersion = TagsHelper.sanitize(config.getVersion()); + this.hostName = config.getHostName(); + // Semantic Versioning requires build separated with `+` + this.tracerVersion = tracerVersion.replace('~', '+'); + this.containerId = containerId; + this.entityId = entityId; + this.url = parseUrl(url); + this.moshi = moshi; } - return runtimeId; - } - private static HttpUrl parseUrl(String url) { - HttpUrl httpUrl = HttpUrl.parse(url); - if (httpUrl == null) { - throw new IllegalArgumentException("Cannot parse url"); + private static String getRuntimeId(Config config) { + String runtimeId = config.getRuntimeId(); + if (runtimeId == null || runtimeId.isEmpty()) { + log.debug("runtimeId not configured, generating a new UUID"); + runtimeId = RandomUtils.randomUUID().toString(); + } + return runtimeId; } - return httpUrl; - } - public Request newConfigurationRequest( - Collection productNames, - ClientState clientState, - Collection cachedTargetFiles, - long capabilities) { - Request.Builder requestBuilder = new Request.Builder().url(this.url).get(); - MediaType applicationJson = MediaType.parse("application/json"); - RequestBody requestBody = - RequestBody.create( - applicationJson, - buildRemoteConfigRequestJson( - productNames, clientState, cachedTargetFiles, capabilities)); - requestBuilder.post(requestBody); - if (this.apiKey != null) { - requestBuilder.addHeader(HEADER_DD_API_KEY, this.apiKey); + private static HttpUrl parseUrl(String url) { + HttpUrl httpUrl = HttpUrl.parse(url); + if (httpUrl == null) { + throw new IllegalArgumentException("Cannot parse url"); + } + return httpUrl; } - if (containerId != null) { - requestBuilder.addHeader(HEADER_CONTAINER_ID, containerId); + + public Request newConfigurationRequest( + Collection productNames, + ClientState clientState, + Collection cachedTargetFiles, + long capabilities) { + Request.Builder requestBuilder = new Request.Builder().url(this.url).get(); + MediaType applicationJson = MediaType.parse("application/json"); + RequestBody requestBody = RequestBody.create( + applicationJson, + buildRemoteConfigRequestJson(productNames, clientState, cachedTargetFiles, capabilities)); + requestBuilder.post(requestBody); + if (this.apiKey != null) { + requestBuilder.addHeader(HEADER_DD_API_KEY, this.apiKey); + } + if (containerId != null) { + requestBuilder.addHeader(HEADER_CONTAINER_ID, containerId); + } + if (entityId != null) { + requestBuilder.addHeader(HEADER_ENTITY_ID, entityId); + } + return requestBuilder.build(); } - if (entityId != null) { - requestBuilder.addHeader(HEADER_ENTITY_ID, entityId); + + private String buildRemoteConfigRequestJson( + Collection productNames, + ClientState clientState, + Collection cachedTargetFiles, + long capabilities) { + RemoteConfigRequest rcRequest = buildRemoteConfigRequest( + productNames, clientState, cachedTargetFiles, capabilities, ServiceNameCollector.get()); + return moshi.adapter(RemoteConfigRequest.class).toJson(rcRequest); } - return requestBuilder.build(); - } - private String buildRemoteConfigRequestJson( - Collection productNames, - ClientState clientState, - Collection cachedTargetFiles, - long capabilities) { - RemoteConfigRequest rcRequest = - buildRemoteConfigRequest( - productNames, clientState, cachedTargetFiles, capabilities, ServiceNameCollector.get()); - return moshi.adapter(RemoteConfigRequest.class).toJson(rcRequest); - } + /** For testing purposes only. */ + public RemoteConfigRequest buildRemoteConfigRequest( + Collection productNames, + ClientState clientState, + Collection cachedTargetFiles, + long capabilities, + ServiceNameCollector serviceNameCollector) { + return RemoteConfigRequest.newRequest( + this.clientId, + this.runtimeId, + this.tracerVersion, + productNames, + this.serviceName, + serviceNameCollector.getServices(), + this.env, + this.ddVersion, + buildRequestTags(), + clientState, + cachedTargetFiles, + capabilities); + } - /** For testing purposes only. */ - public RemoteConfigRequest buildRemoteConfigRequest( - Collection productNames, - ClientState clientState, - Collection cachedTargetFiles, - long capabilities, - ServiceNameCollector serviceNameCollector) { - return RemoteConfigRequest.newRequest( - this.clientId, - this.runtimeId, - this.tracerVersion, - productNames, - this.serviceName, - serviceNameCollector.getServices(), - this.env, - this.ddVersion, - buildRequestTags(), - clientState, - cachedTargetFiles, - capabilities); - } + private List buildRequestTags() { + List tags = Config.get().getGlobalTags().entrySet().stream() + .map(entry -> entry.getKey() + ":" + entry.getValue()) + .collect(Collectors.toList()); + GitInfo gitInfo = GitInfoProvider.INSTANCE.getGitInfo(); + String repositoryURL = gitInfo.getRepositoryURL(); + if (repositoryURL != null) { + tags.add(Tags.GIT_REPOSITORY_URL + ":" + repositoryURL); + } + String sha = gitInfo.getCommit().getSha(); + if (sha != null) { + tags.add(Tags.GIT_COMMIT_SHA + ":" + sha); + } + tags.addAll(Arrays.asList( + "env:" + this.env, + "version:" + this.ddVersion, + "tracer_version:" + this.tracerVersion, + "host_name:" + this.hostName)); - private List buildRequestTags() { - List tags = - Config.get().getGlobalTags().entrySet().stream() - .map(entry -> entry.getKey() + ":" + entry.getValue()) - .collect(Collectors.toList()); - GitInfo gitInfo = GitInfoProvider.INSTANCE.getGitInfo(); - String repositoryURL = gitInfo.getRepositoryURL(); - if (repositoryURL != null) { - tags.add(Tags.GIT_REPOSITORY_URL + ":" + repositoryURL); - } - String sha = gitInfo.getCommit().getSha(); - if (sha != null) { - tags.add(Tags.GIT_COMMIT_SHA + ":" + sha); + return tags; } - tags.addAll( - Arrays.asList( - "env:" + this.env, - "version:" + this.ddVersion, - "tracer_version:" + this.tracerVersion, - "host_name:" + this.hostName)); - - return tags; - } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerScheduler.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerScheduler.java index 93a47be51f0..56e675b0d91 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerScheduler.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollerScheduler.java @@ -10,56 +10,54 @@ /** Handles scheduling scheme for polling configuration */ @SuppressFBWarnings("AT_NONATOMIC_64BIT_PRIMITIVE") class PollerScheduler { - private static final Logger LOGGER = LoggerFactory.getLogger(PollerScheduler.class); - - private final long initialPollInterval; - private long currentPollInterval; - private final DefaultConfigurationPoller poller; - private final AgentTaskScheduler taskScheduler; - private volatile AgentTaskScheduler.Scheduled scheduled; - - public PollerScheduler( - Config config, DefaultConfigurationPoller poller, AgentTaskScheduler taskScheduler) { - // TODO add a jitter to avoid herd issue - this.initialPollInterval = (long) (config.getRemoteConfigPollIntervalSeconds() * 1000); - this.poller = poller; - this.taskScheduler = taskScheduler; - } + private static final Logger LOGGER = LoggerFactory.getLogger(PollerScheduler.class); + + private final long initialPollInterval; + private long currentPollInterval; + private final DefaultConfigurationPoller poller; + private final AgentTaskScheduler taskScheduler; + private volatile AgentTaskScheduler.Scheduled scheduled; + + public PollerScheduler(Config config, DefaultConfigurationPoller poller, AgentTaskScheduler taskScheduler) { + // TODO add a jitter to avoid herd issue + this.initialPollInterval = (long) (config.getRemoteConfigPollIntervalSeconds() * 1000); + this.poller = poller; + this.taskScheduler = taskScheduler; + } - void start() { - currentPollInterval = initialPollInterval; - reschedule(); - } + void start() { + currentPollInterval = initialPollInterval; + reschedule(); + } - void stop() { - AgentTaskScheduler.Scheduled localScheduled = this.scheduled; - if (localScheduled != null) { - localScheduled.cancel(); + void stop() { + AgentTaskScheduler.Scheduled localScheduled = this.scheduled; + if (localScheduled != null) { + localScheduled.cancel(); + } } - } - long getInitialPollInterval() { - return initialPollInterval; - } + long getInitialPollInterval() { + return initialPollInterval; + } - void reschedule(long newInterval) { - if (currentPollInterval != newInterval) { - LOGGER.debug("Setting polling interval to {}ms, and rescheduling", newInterval); - currentPollInterval = newInterval; - reschedule(); + void reschedule(long newInterval) { + if (currentPollInterval != newInterval) { + LOGGER.debug("Setting polling interval to {}ms, and rescheduling", newInterval); + currentPollInterval = newInterval; + reschedule(); + } } - } - private void reschedule() { - AgentTaskScheduler.Scheduled localScheduled = this.scheduled; - long initialDelay = currentPollInterval; - if (localScheduled != null) { - localScheduled.cancel(); - } else { - initialDelay = 0; + private void reschedule() { + AgentTaskScheduler.Scheduled localScheduled = this.scheduled; + long initialDelay = currentPollInterval; + if (localScheduled != null) { + localScheduled.cancel(); + } else { + initialDelay = 0; + } + this.scheduled = taskScheduler.scheduleAtFixedRate( + poller::poll, poller, initialDelay, currentPollInterval, TimeUnit.MILLISECONDS); } - this.scheduled = - taskScheduler.scheduleAtFixedRate( - poller::poll, poller, initialDelay, currentPollInterval, TimeUnit.MILLISECONDS); - } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollingHinterNoop.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollingHinterNoop.java index 357357ac136..8aa23e107ff 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollingHinterNoop.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/PollingHinterNoop.java @@ -3,10 +3,10 @@ import java.time.Duration; public final class PollingHinterNoop implements PollingRateHinter { - public static final PollingRateHinter NOOP = new PollingHinterNoop(); + public static final PollingRateHinter NOOP = new PollingHinterNoop(); - private PollingHinterNoop() {} + private PollingHinterNoop() {} - @Override - public void suggestPollingRate(Duration duration) {} + @Override + public void suggestPollingRate(Duration duration) {} } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ReportableException.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ReportableException.java index cc3288fda03..2220bf9d458 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ReportableException.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/ReportableException.java @@ -2,11 +2,11 @@ /* An exception that should be reported in client.state.error */ public class ReportableException extends RuntimeException { - public ReportableException(String message) { - super(message); - } + public ReportableException(String message) { + super(message); + } - public ReportableException(String message, Throwable t) { - super(message, t); - } + public ReportableException(String message, Throwable t) { + super(message, t); + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ParsedConfigKey.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ParsedConfigKey.java index 4554471d357..97f2edaa06c 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ParsedConfigKey.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ParsedConfigKey.java @@ -9,87 +9,85 @@ public class ParsedConfigKey implements ConfigKey { - private static final Pattern EXTRACT_PRODUCT_REGEX = - Pattern.compile("([^/]+)(/\\d+)?/([^/]+)/([^/]+)/[^/]+"); - - private final String originalKey; - private final String org; - private final Integer version; - private final String productName; - private final String configId; - private final Product product; - - ParsedConfigKey( - String originalKey, String org, Integer version, String productName, String configId) { - this.originalKey = originalKey; - this.org = org; - this.version = version; - this.productName = productName; - this.configId = configId; - - Product parsedProduct = Product._UNKNOWN; - try { - parsedProduct = Product.valueOf(productName.toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException iae) { + private static final Pattern EXTRACT_PRODUCT_REGEX = Pattern.compile("([^/]+)(/\\d+)?/([^/]+)/([^/]+)/[^/]+"); + + private final String originalKey; + private final String org; + private final Integer version; + private final String productName; + private final String configId; + private final Product product; + + ParsedConfigKey(String originalKey, String org, Integer version, String productName, String configId) { + this.originalKey = originalKey; + this.org = org; + this.version = version; + this.productName = productName; + this.configId = configId; + + Product parsedProduct = Product._UNKNOWN; + try { + parsedProduct = Product.valueOf(productName.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException iae) { + } + this.product = parsedProduct; } - this.product = parsedProduct; - } - public static ParsedConfigKey parse(String configKey) { - Matcher matcher = EXTRACT_PRODUCT_REGEX.matcher(configKey); - if (!matcher.matches()) { - throw new ReportableException("Not a valid config key: " + configKey); + public static ParsedConfigKey parse(String configKey) { + Matcher matcher = EXTRACT_PRODUCT_REGEX.matcher(configKey); + if (!matcher.matches()) { + throw new ReportableException("Not a valid config key: " + configKey); + } + String org = matcher.group(1); + String version = matcher.group(2); + // version group can be null or "/" + number. so if it is not null, parse int without first char + Integer parsedVersion = version != null ? Integer.parseInt(version.substring(1)) : null; + String product = matcher.group(3); + String configId = matcher.group(4); + + return new ParsedConfigKey(configKey, org, parsedVersion, product, configId); + } + + @Override + public Product getProduct() { + return product; + } + + @Override + public String getProductName() { + return productName; + } + + @Override + public String getOrg() { + return org; + } + + @Override + public Integer getVersion() { + return version; + } + + @Override + public String getConfigId() { + return configId; + } + + @Override + public String toString() { + return originalKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ParsedConfigKey that = (ParsedConfigKey) o; + return Objects.equals(originalKey, that.originalKey); + } + + @Override + public int hashCode() { + return Objects.hash(originalKey); } - String org = matcher.group(1); - String version = matcher.group(2); - // version group can be null or "/" + number. so if it is not null, parse int without first char - Integer parsedVersion = version != null ? Integer.parseInt(version.substring(1)) : null; - String product = matcher.group(3); - String configId = matcher.group(4); - - return new ParsedConfigKey(configKey, org, parsedVersion, product, configId); - } - - @Override - public Product getProduct() { - return product; - } - - @Override - public String getProductName() { - return productName; - } - - @Override - public String getOrg() { - return org; - } - - @Override - public Integer getVersion() { - return version; - } - - @Override - public String getConfigId() { - return configId; - } - - @Override - public String toString() { - return originalKey; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ParsedConfigKey that = (ParsedConfigKey) o; - return Objects.equals(originalKey, that.originalKey); - } - - @Override - public int hashCode() { - return Objects.hash(originalKey); - } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ProductState.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ProductState.java index ccce0dab21c..dc4876f9fed 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ProductState.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/ProductState.java @@ -19,256 +19,241 @@ import org.slf4j.LoggerFactory; public class ProductState { - private static final Logger log = LoggerFactory.getLogger(ProductState.class); - private static final int MINUTES_BETWEEN_ERROR_LOG = 5; - private final RatelimitedLogger ratelimitedLogger; - - final Product product; - - private final Map cachedTargetFiles = - new HashMap<>(); - private final Map - configStates = new HashMap<>(); - private final List productListeners; - private final Map configListeners; - - List errors = null; - - public ProductState(Product product) { - this.product = product; - this.productListeners = new ArrayList<>(); - this.configListeners = new HashMap<>(); - - this.ratelimitedLogger = - new RatelimitedLogger(log, MINUTES_BETWEEN_ERROR_LOG, TimeUnit.MINUTES); - } - - public void addProductListener(ProductListener listener) { - productListeners.add(listener); - } - - public void addProductListener(String configId, ProductListener listener) { - configListeners.put(configId, listener); - } - - public boolean apply( - RemoteConfigResponse fleetResponse, - List relevantKeys, - PollingRateHinter hinter) { - errors = null; - - List configBeenUsedByProduct = new ArrayList<>(); - List changedKeys = new ArrayList<>(); - boolean changesDetected = false; - - // Step 1: Detect all changes - for (ParsedConfigKey configKey : relevantKeys) { - try { - RemoteConfigResponse.Targets.ConfigTarget target = - getTargetOrThrow(fleetResponse, configKey); - configBeenUsedByProduct.add(configKey); - - if (isTargetChanged(configKey, target)) { - changesDetected = true; - changedKeys.add(configKey); + private static final Logger log = LoggerFactory.getLogger(ProductState.class); + private static final int MINUTES_BETWEEN_ERROR_LOG = 5; + private final RatelimitedLogger ratelimitedLogger; + + final Product product; + + private final Map cachedTargetFiles = new HashMap<>(); + private final Map configStates = + new HashMap<>(); + private final List productListeners; + private final Map configListeners; + + List errors = null; + + public ProductState(Product product) { + this.product = product; + this.productListeners = new ArrayList<>(); + this.configListeners = new HashMap<>(); + + this.ratelimitedLogger = new RatelimitedLogger(log, MINUTES_BETWEEN_ERROR_LOG, TimeUnit.MINUTES); + } + + public void addProductListener(ProductListener listener) { + productListeners.add(listener); + } + + public void addProductListener(String configId, ProductListener listener) { + configListeners.put(configId, listener); + } + + public boolean apply( + RemoteConfigResponse fleetResponse, List relevantKeys, PollingRateHinter hinter) { + errors = null; + + List configBeenUsedByProduct = new ArrayList<>(); + List changedKeys = new ArrayList<>(); + boolean changesDetected = false; + + // Step 1: Detect all changes + for (ParsedConfigKey configKey : relevantKeys) { + try { + RemoteConfigResponse.Targets.ConfigTarget target = getTargetOrThrow(fleetResponse, configKey); + configBeenUsedByProduct.add(configKey); + + if (isTargetChanged(configKey, target)) { + changesDetected = true; + changedKeys.add(configKey); + } + } catch (ReportableException e) { + recordError(e); + } + } + + // Step 2: For products other than ASM_DD, apply changes immediately + if (product != Product.ASM_DD) { + for (ParsedConfigKey configKey : changedKeys) { + try { + byte[] content = getTargetFileContent(fleetResponse, configKey); + callListenerApplyTarget(fleetResponse, hinter, configKey, content); + } catch (ReportableException e) { + recordError(e); + } + } + } + + // Step 3: Remove obsolete configurations (for all products) + // For ASM_DD, this is critical: removes MUST happen before applies to prevent + // duplicate rule warnings from the ddwaf rule parser and causing memory spikes. + List keysToRemove = cachedTargetFiles.keySet().stream() + .filter(configKey -> !configBeenUsedByProduct.contains(configKey)) + .collect(Collectors.toList()); + + for (ParsedConfigKey configKey : keysToRemove) { + changesDetected = true; + callListenerRemoveTarget(hinter, configKey); + } + + // Step 4: For ASM_DD, apply changes AFTER removes + // TODO: This is a temporary solution. The proper fix requires better synchronization + // between remove and add/update operations. This should be discussed + // with the guild to determine the best long-term design approach. + if (product == Product.ASM_DD) { + for (ParsedConfigKey configKey : changedKeys) { + try { + byte[] content = getTargetFileContent(fleetResponse, configKey); + callListenerApplyTarget(fleetResponse, hinter, configKey, content); + } catch (ReportableException e) { + recordError(e); + } + } + } + + // Step 5: Commit if there were changes + if (changesDetected) { + try { + callListenerCommit(hinter); + } catch (Exception ex) { + log.error("Error committing changes for product {}", product, ex); + } } - } catch (ReportableException e) { - recordError(e); - } + + return changesDetected; } - // Step 2: For products other than ASM_DD, apply changes immediately - if (product != Product.ASM_DD) { - for (ParsedConfigKey configKey : changedKeys) { + private void callListenerApplyTarget( + RemoteConfigResponse fleetResponse, PollingRateHinter hinter, ParsedConfigKey configKey, byte[] content) { + try { - byte[] content = getTargetFileContent(fleetResponse, configKey); - callListenerApplyTarget(fleetResponse, hinter, configKey, content); + for (ProductListener listener : productListeners) { + listener.accept(configKey, content, hinter); + } + ProductListener listener = configListeners.get(configKey.getConfigId()); + if (listener != null) { + listener.accept(configKey, content, hinter); + } + + updateConfigState(fleetResponse, configKey, null); } catch (ReportableException e) { - recordError(e); + recordError(e); + } catch (Exception ex) { + updateConfigState(fleetResponse, configKey, ex); + if (!(ex instanceof InterruptedIOException)) { + ratelimitedLogger.warn("Error processing config key {}: {}", configKey, ex.getMessage(), ex); + } } - } } - // Step 3: Remove obsolete configurations (for all products) - // For ASM_DD, this is critical: removes MUST happen before applies to prevent - // duplicate rule warnings from the ddwaf rule parser and causing memory spikes. - List keysToRemove = - cachedTargetFiles.keySet().stream() - .filter(configKey -> !configBeenUsedByProduct.contains(configKey)) - .collect(Collectors.toList()); - - for (ParsedConfigKey configKey : keysToRemove) { - changesDetected = true; - callListenerRemoveTarget(hinter, configKey); + private void callListenerRemoveTarget(PollingRateHinter hinter, ParsedConfigKey configKey) { + try { + for (ProductListener listener : productListeners) { + listener.remove(configKey, hinter); + } + ProductListener listener = configListeners.get(configKey.getConfigId()); + if (listener != null) { + listener.remove(configKey, hinter); + } + } catch (Exception ex) { + ratelimitedLogger.warn("Error handling configuration removal for {}", configKey, ex); + } + cachedTargetFiles.remove(configKey); + configStates.remove(configKey); } - // Step 4: For ASM_DD, apply changes AFTER removes - // TODO: This is a temporary solution. The proper fix requires better synchronization - // between remove and add/update operations. This should be discussed - // with the guild to determine the best long-term design approach. - if (product == Product.ASM_DD) { - for (ParsedConfigKey configKey : changedKeys) { + private void callListenerCommit(PollingRateHinter hinter) { try { - byte[] content = getTargetFileContent(fleetResponse, configKey); - callListenerApplyTarget(fleetResponse, hinter, configKey, content); + for (ProductListener listener : productListeners) { + listener.commit(hinter); + } + for (ProductListener listener : configListeners.values()) { + listener.commit(hinter); + } } catch (ReportableException e) { - recordError(e); + recordError(e); + } catch (Exception ex) { + ratelimitedLogger.warn("Error committing changes for product {}: {}", product, ex.getMessage(), ex); } - } } - // Step 5: Commit if there were changes - if (changesDetected) { - try { - callListenerCommit(hinter); - } catch (Exception ex) { - log.error("Error committing changes for product {}", product, ex); - } + RemoteConfigResponse.Targets.ConfigTarget getTargetOrThrow( + RemoteConfigResponse fleetResponse, ParsedConfigKey configKey) { + RemoteConfigResponse.Targets.ConfigTarget target = fleetResponse.getTarget(configKey.toString()); + if (target == null) { + throw new ReportableException("Told to apply config for " + + configKey + + " but no corresponding entry exists in targets.targets_signed.targets"); + } + return target; } - return changesDetected; - } - - private void callListenerApplyTarget( - RemoteConfigResponse fleetResponse, - PollingRateHinter hinter, - ParsedConfigKey configKey, - byte[] content) { - - try { - for (ProductListener listener : productListeners) { - listener.accept(configKey, content, hinter); - } - ProductListener listener = configListeners.get(configKey.getConfigId()); - if (listener != null) { - listener.accept(configKey, content, hinter); - } - - updateConfigState(fleetResponse, configKey, null); - } catch (ReportableException e) { - recordError(e); - } catch (Exception ex) { - updateConfigState(fleetResponse, configKey, ex); - if (!(ex instanceof InterruptedIOException)) { - ratelimitedLogger.warn( - "Error processing config key {}: {}", configKey, ex.getMessage(), ex); - } - } - } - - private void callListenerRemoveTarget(PollingRateHinter hinter, ParsedConfigKey configKey) { - try { - for (ProductListener listener : productListeners) { - listener.remove(configKey, hinter); - } - ProductListener listener = configListeners.get(configKey.getConfigId()); - if (listener != null) { - listener.remove(configKey, hinter); - } - } catch (Exception ex) { - ratelimitedLogger.warn("Error handling configuration removal for {}", configKey, ex); - } - cachedTargetFiles.remove(configKey); - configStates.remove(configKey); - } - - private void callListenerCommit(PollingRateHinter hinter) { - try { - for (ProductListener listener : productListeners) { - listener.commit(hinter); - } - for (ProductListener listener : configListeners.values()) { - listener.commit(hinter); - } - } catch (ReportableException e) { - recordError(e); - } catch (Exception ex) { - ratelimitedLogger.warn( - "Error committing changes for product {}: {}", product, ex.getMessage(), ex); + boolean isTargetChanged(ParsedConfigKey parsedConfigKey, RemoteConfigResponse.Targets.ConfigTarget target) { + RemoteConfigRequest.CachedTargetFile cachedTargetFile = cachedTargetFiles.get(parsedConfigKey); + if (cachedTargetFile != null && cachedTargetFile.hashesMatch(target.hashes)) { + log.debug("No change in configuration for key {}", parsedConfigKey); + return false; + } + return true; } - } - - RemoteConfigResponse.Targets.ConfigTarget getTargetOrThrow( - RemoteConfigResponse fleetResponse, ParsedConfigKey configKey) { - RemoteConfigResponse.Targets.ConfigTarget target = - fleetResponse.getTarget(configKey.toString()); - if (target == null) { - throw new ReportableException( - "Told to apply config for " - + configKey - + " but no corresponding entry exists in targets.targets_signed.targets"); + + byte[] getTargetFileContent(RemoteConfigResponse fleetResponse, ParsedConfigKey configKey) { + // fetch the content + byte[] maybeFileContent; + try { + maybeFileContent = fleetResponse.getFileContents(configKey.toString()); + } catch (MissingContentException e) { + if (cachedTargetFiles.containsKey(configKey)) { + throw new ReportableException("Told to apply config " + + configKey + + " but content not present even though hash differs from that of 'cached file'"); + } + throw new ReportableException(e.getMessage()); + } catch (Exception e) { + throw new ReportableException(e.getMessage()); + } + + return maybeFileContent; } - return target; - } - - boolean isTargetChanged( - ParsedConfigKey parsedConfigKey, RemoteConfigResponse.Targets.ConfigTarget target) { - RemoteConfigRequest.CachedTargetFile cachedTargetFile = cachedTargetFiles.get(parsedConfigKey); - if (cachedTargetFile != null && cachedTargetFile.hashesMatch(target.hashes)) { - log.debug("No change in configuration for key {}", parsedConfigKey); - return false; + + private void updateConfigState( + RemoteConfigResponse fleetResponse, ParsedConfigKey parsedConfigKey, Exception error) { + String configKey = parsedConfigKey.toString(); + RemoteConfigResponse.Targets.ConfigTarget target = fleetResponse.getTarget(configKey); + RemoteConfigRequest.ClientInfo.ClientState.ConfigState newState = + new RemoteConfigRequest.ClientInfo.ClientState.ConfigState(); + newState.setState( + parsedConfigKey.getConfigId(), + target.custom.version, + product.name(), + error != null ? error.getMessage() : null); + configStates.put(parsedConfigKey, newState); + + RemoteConfigRequest.CachedTargetFile newCTF = + new RemoteConfigRequest.CachedTargetFile(configKey, target.length, target.hashes); + cachedTargetFiles.put(parsedConfigKey, newCTF); } - return true; - } - - byte[] getTargetFileContent(RemoteConfigResponse fleetResponse, ParsedConfigKey configKey) { - // fetch the content - byte[] maybeFileContent; - try { - maybeFileContent = fleetResponse.getFileContents(configKey.toString()); - } catch (MissingContentException e) { - if (cachedTargetFiles.containsKey(configKey)) { - throw new ReportableException( - "Told to apply config " - + configKey - + " but content not present even though hash differs from that of 'cached file'"); - } - throw new ReportableException(e.getMessage()); - } catch (Exception e) { - throw new ReportableException(e.getMessage()); + + public boolean hasError() { + return errors != null; } - return maybeFileContent; - } - - private void updateConfigState( - RemoteConfigResponse fleetResponse, ParsedConfigKey parsedConfigKey, Exception error) { - String configKey = parsedConfigKey.toString(); - RemoteConfigResponse.Targets.ConfigTarget target = fleetResponse.getTarget(configKey); - RemoteConfigRequest.ClientInfo.ClientState.ConfigState newState = - new RemoteConfigRequest.ClientInfo.ClientState.ConfigState(); - newState.setState( - parsedConfigKey.getConfigId(), - target.custom.version, - product.name(), - error != null ? error.getMessage() : null); - configStates.put(parsedConfigKey, newState); - - RemoteConfigRequest.CachedTargetFile newCTF = - new RemoteConfigRequest.CachedTargetFile(configKey, target.length, target.hashes); - cachedTargetFiles.put(parsedConfigKey, newCTF); - } - - public boolean hasError() { - return errors != null; - } - - public void recordError(ReportableException error) { - if (errors == null) { - errors = new ArrayList<>(); + public void recordError(ReportableException error) { + if (errors == null) { + errors = new ArrayList<>(); + } + errors.add(error); } - errors.add(error); - } - public Collection getErrors() { - return errors; - } + public Collection getErrors() { + return errors; + } - public Collection getCachedTargetFiles() { - return cachedTargetFiles.values(); - } + public Collection getCachedTargetFiles() { + return cachedTargetFiles.values(); + } - public Collection getConfigStates() { - return configStates.values(); - } + public Collection getConfigStates() { + return configStates.values(); + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/SimpleProductListener.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/SimpleProductListener.java index 58fc697949a..e431a7f82bb 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/SimpleProductListener.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/state/SimpleProductListener.java @@ -5,23 +5,22 @@ import java.io.IOException; public class SimpleProductListener implements ProductListener { - private final ConfigurationChangesListener listener; + private final ConfigurationChangesListener listener; - public SimpleProductListener(ConfigurationChangesListener listener) { - this.listener = listener; - } + public SimpleProductListener(ConfigurationChangesListener listener) { + this.listener = listener; + } - @Override - public void accept(ConfigKey configKey, byte[] content, PollingRateHinter pollingRateHinter) - throws IOException { - listener.accept(configKey.toString(), content, pollingRateHinter); - } + @Override + public void accept(ConfigKey configKey, byte[] content, PollingRateHinter pollingRateHinter) throws IOException { + listener.accept(configKey.toString(), content, pollingRateHinter); + } - @Override - public void remove(ConfigKey configKey, PollingRateHinter pollingRateHinter) throws IOException { - listener.accept(configKey.toString(), null, pollingRateHinter); - } + @Override + public void remove(ConfigKey configKey, PollingRateHinter pollingRateHinter) throws IOException { + listener.accept(configKey.toString(), null, pollingRateHinter); + } - @Override - public void commit(PollingRateHinter pollingRateHinter) {} + @Override + public void commit(PollingRateHinter pollingRateHinter) {} } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/InstantJsonAdapter.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/InstantJsonAdapter.java index 886a8dd6816..1b22394e5fa 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/InstantJsonAdapter.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/InstantJsonAdapter.java @@ -9,15 +9,15 @@ import javax.annotation.Nullable; public class InstantJsonAdapter extends JsonAdapter { - @Nullable - @Override - public Instant fromJson(JsonReader reader) throws IOException { - String s = reader.nextString(); - return OffsetDateTime.parse(s).toInstant(); - } + @Nullable + @Override + public Instant fromJson(JsonReader reader) throws IOException { + String s = reader.nextString(); + return OffsetDateTime.parse(s).toInstant(); + } - @Override - public void toJson(JsonWriter writer, @Nullable Instant value) throws IOException { - throw new UnsupportedOperationException(); - } + @Override + public void toJson(JsonWriter writer, @Nullable Instant value) throws IOException { + throw new UnsupportedOperationException(); + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/IntegrityCheckException.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/IntegrityCheckException.java index 3a45ec8cd1f..45e7e25057d 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/IntegrityCheckException.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/IntegrityCheckException.java @@ -2,11 +2,11 @@ /** Exception when checking configuration integrity */ public class IntegrityCheckException extends RuntimeException { - public IntegrityCheckException(String message) { - super(message); - } + public IntegrityCheckException(String message) { + super(message); + } - public IntegrityCheckException(String message, Throwable cause) { - super(message, cause); - } + public IntegrityCheckException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/MissingContentException.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/MissingContentException.java index acb5ccb2faf..d8247cd6872 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/MissingContentException.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/MissingContentException.java @@ -1,7 +1,7 @@ package datadog.remoteconfig.tuf; public class MissingContentException extends RuntimeException { - public MissingContentException(String message) { - super(message); - } + public MissingContentException(String message) { + super(message); + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RawJsonAdapter.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RawJsonAdapter.java index fb7d5f420b2..fbd79791bfd 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RawJsonAdapter.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RawJsonAdapter.java @@ -9,15 +9,15 @@ import okio.Okio; public class RawJsonAdapter extends JsonAdapter { - public void toJson(JsonWriter writer, ByteString value) throws IOException { - if (value == null) { - writer.nullValue(); - return; + public void toJson(JsonWriter writer, ByteString value) throws IOException { + if (value == null) { + writer.nullValue(); + return; + } + writer.value(Okio.buffer(Okio.source(new ByteArrayInputStream(value.toByteArray())))); } - writer.value(Okio.buffer(Okio.source(new ByteArrayInputStream(value.toByteArray())))); - } - public ByteString fromJson(JsonReader reader) throws IOException { - return reader.nextSource().readByteString(); - } + public ByteString fromJson(JsonReader reader) throws IOException { + return reader.nextSource().readByteString(); + } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigRequest.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigRequest.java index c8b6c388d41..9023c788664 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigRequest.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigRequest.java @@ -11,278 +11,270 @@ /** Handles requests to Remote Configuration */ public class RemoteConfigRequest { - public static RemoteConfigRequest newRequest( - String clientId, - String runtimeId, - String tracerVersion, - Collection productNames, - String serviceName, - List extraServices, - String serviceEnv, - String serviceVersion, - List tags, - ClientInfo.ClientState clientState, - Collection cachedTargetFiles, - long capabilities) { - - ClientInfo.TracerInfo tracerInfo = - new RemoteConfigRequest.ClientInfo.TracerInfo( - runtimeId, - tracerVersion, - serviceName, - extraServices, - serviceEnv, - serviceVersion, - tags, - ProcessTags.getTagsAsStringList()); - - ClientInfo clientInfo = - new RemoteConfigRequest.ClientInfo( - clientState, clientId, productNames, tracerInfo, capabilities); - - return new RemoteConfigRequest(clientInfo, cachedTargetFiles); - } - - private final ClientInfo client; - - @Json(name = "cached_target_files") - private final Collection cachedTargetFiles; - - public RemoteConfigRequest(ClientInfo client, Collection cachedTargetFiles) { - this.client = client; - // system tests expect this to be null if we're not holding anything cached - this.cachedTargetFiles = - (cachedTargetFiles != null && cachedTargetFiles.isEmpty()) ? null : cachedTargetFiles; - } - - public ClientInfo getClient() { - return this.client; - } - - /** Stores client information for Remote Configuration */ - public static class ClientInfo { - @Json(name = "state") - private final ClientState clientState; - - private final String id; - private final Collection products; - - @Json(name = "client_tracer") - private final TracerInfo tracerInfo; - - @Json(name = "client_agent") - private final AgentInfo agentInfo = null; // MUST NOT be set - - @Json(name = "is_tracer") - private final boolean isTracer = true; - - @Json(name = "is_agent") - private final Boolean isAgent = null; // MUST NOT be set; - - private final byte[] capabilities; - - public ClientInfo( - ClientState clientState, - String id, - Collection productNames, - TracerInfo tracerInfo, - final long capabilities) { - this.clientState = clientState; - this.id = id; - this.products = productNames; - this.tracerInfo = tracerInfo; - - // Big-endian encoding of the `long` capabilities, stripping any trailing zero bytes - // (except the first one) - final int size = Math.max(1, Long.BYTES - Long.numberOfLeadingZeros(capabilities) / 8); - this.capabilities = new byte[size]; - for (int i = size - 1; i >= 0; i--) { - this.capabilities[size - i - 1] = (byte) (capabilities >>> (i * 8)); - } + public static RemoteConfigRequest newRequest( + String clientId, + String runtimeId, + String tracerVersion, + Collection productNames, + String serviceName, + List extraServices, + String serviceEnv, + String serviceVersion, + List tags, + ClientInfo.ClientState clientState, + Collection cachedTargetFiles, + long capabilities) { + + ClientInfo.TracerInfo tracerInfo = new RemoteConfigRequest.ClientInfo.TracerInfo( + runtimeId, + tracerVersion, + serviceName, + extraServices, + serviceEnv, + serviceVersion, + tags, + ProcessTags.getTagsAsStringList()); + + ClientInfo clientInfo = + new RemoteConfigRequest.ClientInfo(clientState, clientId, productNames, tracerInfo, capabilities); + + return new RemoteConfigRequest(clientInfo, cachedTargetFiles); } - public TracerInfo getTracerInfo() { - return this.tracerInfo; + private final ClientInfo client; + + @Json(name = "cached_target_files") + private final Collection cachedTargetFiles; + + public RemoteConfigRequest(ClientInfo client, Collection cachedTargetFiles) { + this.client = client; + // system tests expect this to be null if we're not holding anything cached + this.cachedTargetFiles = (cachedTargetFiles != null && cachedTargetFiles.isEmpty()) ? null : cachedTargetFiles; } - public static class ClientState { - @Json(name = "root_version") - public long rootVersion = 1L; + public ClientInfo getClient() { + return this.client; + } + + /** Stores client information for Remote Configuration */ + public static class ClientInfo { + @Json(name = "state") + private final ClientState clientState; + + private final String id; + private final Collection products; + + @Json(name = "client_tracer") + private final TracerInfo tracerInfo; + + @Json(name = "client_agent") + private final AgentInfo agentInfo = null; // MUST NOT be set + + @Json(name = "is_tracer") + private final boolean isTracer = true; + + @Json(name = "is_agent") + private final Boolean isAgent = null; // MUST NOT be set; + + private final byte[] capabilities; + + public ClientInfo( + ClientState clientState, + String id, + Collection productNames, + TracerInfo tracerInfo, + final long capabilities) { + this.clientState = clientState; + this.id = id; + this.products = productNames; + this.tracerInfo = tracerInfo; + + // Big-endian encoding of the `long` capabilities, stripping any trailing zero bytes + // (except the first one) + final int size = Math.max(1, Long.BYTES - Long.numberOfLeadingZeros(capabilities) / 8); + this.capabilities = new byte[size]; + for (int i = size - 1; i >= 0; i--) { + this.capabilities[size - i - 1] = (byte) (capabilities >>> (i * 8)); + } + } + + public TracerInfo getTracerInfo() { + return this.tracerInfo; + } + + public static class ClientState { + @Json(name = "root_version") + public long rootVersion = 1L; - @Json(name = "targets_version") - public long targetsVersion; + @Json(name = "targets_version") + public long targetsVersion; - @Json(name = "config_states") - public List configStates = new ArrayList<>(); + @Json(name = "config_states") + public List configStates = new ArrayList<>(); - @Json(name = "has_error") - public boolean hasError; + @Json(name = "has_error") + public boolean hasError; - public String error; + public String error; - @Json(name = "backend_client_state") - public String backendClientState; + @Json(name = "backend_client_state") + public String backendClientState; - public void setState( - long targetsVersion, - List configStates, - String error, - String backendClientState) { - this.targetsVersion = targetsVersion; - this.configStates = configStates; - this.error = error; - this.hasError = error != null && !error.isEmpty(); - this.backendClientState = backendClientState; - } + public void setState( + long targetsVersion, List configStates, String error, String backendClientState) { + this.targetsVersion = targetsVersion; + this.configStates = configStates; + this.error = error; + this.hasError = error != null && !error.isEmpty(); + this.backendClientState = backendClientState; + } - public static class ConfigState { - public static final int APPLY_STATE_ACKNOWLEDGED = 2; - public static final int APPLY_STATE_ERROR = 3; + public static class ConfigState { + public static final int APPLY_STATE_ACKNOWLEDGED = 2; + public static final int APPLY_STATE_ERROR = 3; - private String id; - private long version; - public String product; + private String id; + private long version; + public String product; - @Json(name = "apply_state") - public int applyState; + @Json(name = "apply_state") + public int applyState; - @Json(name = "apply_error") - public String applyError; + @Json(name = "apply_error") + public String applyError; - public void setState(String id, long version, String product, String error) { - this.id = id; - this.version = version; - this.product = product; - this.applyState = error == null ? APPLY_STATE_ACKNOWLEDGED : APPLY_STATE_ERROR; - this.applyError = error; + public void setState(String id, long version, String product, String error) { + this.id = id; + this.version = version; + this.product = product; + this.applyState = error == null ? APPLY_STATE_ACKNOWLEDGED : APPLY_STATE_ERROR; + this.applyError = error; + } + } } - } - } - public static class TracerInfo { - @Json(name = "runtime_id") - private final String runtimeId; - - private final String language = "java"; - - private final List tags; - - @Json(name = "tracer_version") - private final String tracerVersion; - - @Json(name = "service") - private final String serviceName; - - @Json(name = "extra_services") - private final List extraServices; - - @Json(name = "env") - private final String serviceEnv; - - @Json(name = "app_version") - private final String serviceVersion; - - @Json(name = "process_tags") - private final List processTags; - - public TracerInfo( - String runtimeId, - String tracerVersion, - String serviceName, - List extraServices, - String serviceEnv, - String serviceVersion, - List tags, - List processTags) { - this.runtimeId = runtimeId; - this.tracerVersion = tracerVersion; - this.serviceName = serviceName; - this.extraServices = extraServices; - this.serviceEnv = serviceEnv; - this.serviceVersion = serviceVersion; - this.tags = tags; - this.processTags = processTags; - } - - public String getServiceName() { - return this.serviceName; - } - - public List getExtraServices() { - return extraServices; - } - - public String getServiceEnv() { - return this.serviceEnv; - } - - public String getServiceVersion() { - return this.serviceVersion; - } - - public List getTags() { - return tags; - } - - public List getProcessTags() { - return processTags; - } - } + public static class TracerInfo { + @Json(name = "runtime_id") + private final String runtimeId; + + private final String language = "java"; + + private final List tags; + + @Json(name = "tracer_version") + private final String tracerVersion; + + @Json(name = "service") + private final String serviceName; + + @Json(name = "extra_services") + private final List extraServices; + + @Json(name = "env") + private final String serviceEnv; + + @Json(name = "app_version") + private final String serviceVersion; + + @Json(name = "process_tags") + private final List processTags; + + public TracerInfo( + String runtimeId, + String tracerVersion, + String serviceName, + List extraServices, + String serviceEnv, + String serviceVersion, + List tags, + List processTags) { + this.runtimeId = runtimeId; + this.tracerVersion = tracerVersion; + this.serviceName = serviceName; + this.extraServices = extraServices; + this.serviceEnv = serviceEnv; + this.serviceVersion = serviceVersion; + this.tags = tags; + this.processTags = processTags; + } + + public String getServiceName() { + return this.serviceName; + } + + public List getExtraServices() { + return extraServices; + } + + public String getServiceEnv() { + return this.serviceEnv; + } + + public String getServiceVersion() { + return this.serviceVersion; + } + + public List getTags() { + return tags; + } + + public List getProcessTags() { + return processTags; + } + } - private static class AgentInfo { - private final String name; - private final String version; + private static class AgentInfo { + private final String name; + private final String version; - private AgentInfo(String name, String version) { - this.name = name; - this.version = version; - } - } - } - - public static class CachedTargetFile { - public final String path; - public final long length; - public final List hashes; - - public CachedTargetFile( - String path, long length, Map hashes) { - this.path = path; - this.length = length; - List hashesList = - hashes.entrySet().stream() - .map(e -> new TargetFileHash(e.getKey(), e.getValue())) - .collect(Collectors.toList()); - this.hashes = hashesList; + private AgentInfo(String name, String version) { + this.name = name; + this.version = version; + } + } } - public boolean hashesMatch(Map hashesMap) { - if (this.hashes == null) { - return false; - } + public static class CachedTargetFile { + public final String path; + public final long length; + public final List hashes; + + public CachedTargetFile(String path, long length, Map hashes) { + this.path = path; + this.length = length; + List hashesList = hashes.entrySet().stream() + .map(e -> new TargetFileHash(e.getKey(), e.getValue())) + .collect(Collectors.toList()); + this.hashes = hashesList; + } - if (hashesMap.size() != this.hashes.size()) { - return false; - } + public boolean hashesMatch(Map hashesMap) { + if (this.hashes == null) { + return false; + } - for (TargetFileHash tfh : hashes) { - String digest = hashesMap.get(tfh.algorithm); - if (!digest.equals(tfh.hash)) { - return false; - } - } + if (hashesMap.size() != this.hashes.size()) { + return false; + } - return true; - } + for (TargetFileHash tfh : hashes) { + String digest = hashesMap.get(tfh.algorithm); + if (!digest.equals(tfh.hash)) { + return false; + } + } + + return true; + } - public static class TargetFileHash { - public final String algorithm; - public final String hash; + public static class TargetFileHash { + public final String algorithm; + public final String hash; - public TargetFileHash(String algorithm, String hash) { - this.algorithm = algorithm; - this.hash = hash; - } + public TargetFileHash(String algorithm, String hash) { + this.algorithm = algorithm; + this.hash = hash; + } + } } - } } diff --git a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigResponse.java b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigResponse.java index 0fcb8482ab8..2cc3a737706 100644 --- a/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigResponse.java +++ b/remote-config/remote-config-core/src/main/java/datadog/remoteconfig/tuf/RemoteConfigResponse.java @@ -25,211 +25,206 @@ /** Handles responses from Remote Configuration */ public class RemoteConfigResponse { - private static final Logger log = LoggerFactory.getLogger(RemoteConfigResponse.class); + private static final Logger log = LoggerFactory.getLogger(RemoteConfigResponse.class); - @Json(name = "client_configs") - public List clientConfigs; + @Json(name = "client_configs") + public List clientConfigs; - @Json(name = "targets") - private String targetsJson; + @Json(name = "targets") + private String targetsJson; - private transient Targets targets; + private transient Targets targets; - @Json(name = "target_files") - public List targetFiles; + @Json(name = "target_files") + public List targetFiles; - public static class Factory { - private final JsonAdapter adapterRC; - private final JsonAdapter adapterTargets; + public static class Factory { + private final JsonAdapter adapterRC; + private final JsonAdapter adapterTargets; - public Factory(Moshi moshi) { - this.adapterRC = moshi.adapter(RemoteConfigResponse.class); - this.adapterTargets = moshi.adapter(Targets.class); - } - - public Optional fromInputStream(InputStream inputStream) { - try { - RemoteConfigResponse response = adapterRC.fromJson(Okio.buffer(Okio.source(inputStream))); - String targetsJsonBase64 = response.targetsJson; - if (targetsJsonBase64 == null) { - return Optional.empty(); // empty response -- no change + public Factory(Moshi moshi) { + this.adapterRC = moshi.adapter(RemoteConfigResponse.class); + this.adapterTargets = moshi.adapter(Targets.class); } - byte[] targetsJsonDecoded = - Base64.getDecoder().decode(targetsJsonBase64.getBytes(StandardCharsets.ISO_8859_1)); - if (targetsJsonDecoded.length > 0) { - response.targets = - adapterTargets.fromJson( - Okio.buffer(Okio.source(new ByteArrayInputStream(targetsJsonDecoded)))); - response.targets.targetsSignedUntyped = extractUntypedSignedField(targetsJsonDecoded); + + public Optional fromInputStream(InputStream inputStream) { + try { + RemoteConfigResponse response = adapterRC.fromJson(Okio.buffer(Okio.source(inputStream))); + String targetsJsonBase64 = response.targetsJson; + if (targetsJsonBase64 == null) { + return Optional.empty(); // empty response -- no change + } + byte[] targetsJsonDecoded = + Base64.getDecoder().decode(targetsJsonBase64.getBytes(StandardCharsets.ISO_8859_1)); + if (targetsJsonDecoded.length > 0) { + response.targets = adapterTargets.fromJson( + Okio.buffer(Okio.source(new ByteArrayInputStream(targetsJsonDecoded)))); + response.targets.targetsSignedUntyped = extractUntypedSignedField(targetsJsonDecoded); + } + response.targetsJson = null; + return Optional.of(response); + } catch (InterruptedIOException ignored) { + return Optional.empty(); + } catch (IOException | RuntimeException e) { + throw new RuntimeException("Failed to parse fleet response: " + e.getMessage(), e); + } } - response.targetsJson = null; - return Optional.of(response); - } catch (InterruptedIOException ignored) { - return Optional.empty(); - } catch (IOException | RuntimeException e) { - throw new RuntimeException("Failed to parse fleet response: " + e.getMessage(), e); - } - } - private Map extractUntypedSignedField(byte[] targetsJsonDecoded) - throws IOException { - JsonReader reader = - JsonReader.of(Okio.buffer(Okio.source(new ByteArrayInputStream(targetsJsonDecoded)))); - reader.beginObject(); - while (reader.peek() == JsonReader.Token.NAME) { - String curName = reader.nextName(); - if (curName.equals("signed")) { - return (Map) reader.readJsonValue(); - } else { - reader.skipValue(); + private Map extractUntypedSignedField(byte[] targetsJsonDecoded) throws IOException { + JsonReader reader = JsonReader.of(Okio.buffer(Okio.source(new ByteArrayInputStream(targetsJsonDecoded)))); + reader.beginObject(); + while (reader.peek() == JsonReader.Token.NAME) { + String curName = reader.nextName(); + if (curName.equals("signed")) { + return (Map) reader.readJsonValue(); + } else { + reader.skipValue(); + } + } + return null; } - } - return null; } - } - - public Targets.ConfigTarget getTarget(String configKey) { - return this.targets.targetsSigned.targets.get(configKey); - } - public String getTargetsSignature(String keyId) { - for (Targets.Signature signature : this.targets.signatures) { - if (keyId.equals(signature.keyId)) { - return signature.signature; - } + public Targets.ConfigTarget getTarget(String configKey) { + return this.targets.targetsSigned.targets.get(configKey); } - throw new IntegrityCheckException("Missing signature for key " + keyId); - } - - public Targets.TargetsSigned getTargetsSigned() { - return this.targets.targetsSigned; - } + public String getTargetsSignature(String keyId) { + for (Targets.Signature signature : this.targets.signatures) { + if (keyId.equals(signature.keyId)) { + return signature.signature; + } + } - public Map getUntypedTargetsSigned() { - return this.targets.targetsSignedUntyped; - } + throw new IntegrityCheckException("Missing signature for key " + keyId); + } - public byte[] getFileContents(String configKey) { + public Targets.TargetsSigned getTargetsSigned() { + return this.targets.targetsSigned; + } - if (targetFiles == null) { - throw new MissingContentException("No content for " + configKey); + public Map getUntypedTargetsSigned() { + return this.targets.targetsSignedUntyped; } - try { - for (TargetFile targetFile : this.targetFiles) { - if (!configKey.equals(targetFile.path)) { - continue; - } + public byte[] getFileContents(String configKey) { - Targets.ConfigTarget configTarget = getTarget(configKey); - String hashStr; - if (configTarget == null - || configTarget.hashes == null - || (hashStr = configTarget.hashes.get("sha256")) == null) { - throw new IntegrityCheckException("No sha256 hash present for " + configKey); - } - BigInteger expectedHash = new BigInteger(hashStr, 16); - - String raw = targetFile.raw; - byte[] decode = Base64.getDecoder().decode(raw); - BigInteger gottenHash = sha256(decode); - if (!expectedHash.equals(gottenHash)) { - throw new IntegrityCheckException( - "File " - + configKey - + " does not " - + "have the expected sha256 hash: Expected " - + expectedHash.toString(16) - + ", but got " - + gottenHash.toString(16)); + if (targetFiles == null) { + throw new MissingContentException("No content for " + configKey); } - if (decode.length != configTarget.length) { - throw new IntegrityCheckException( - "File " - + configKey - + " does not " - + "have the expected length: Expected " - + configTarget.length - + ", but got " - + decode.length); + + try { + for (TargetFile targetFile : this.targetFiles) { + if (!configKey.equals(targetFile.path)) { + continue; + } + + Targets.ConfigTarget configTarget = getTarget(configKey); + String hashStr; + if (configTarget == null + || configTarget.hashes == null + || (hashStr = configTarget.hashes.get("sha256")) == null) { + throw new IntegrityCheckException("No sha256 hash present for " + configKey); + } + BigInteger expectedHash = new BigInteger(hashStr, 16); + + String raw = targetFile.raw; + byte[] decode = Base64.getDecoder().decode(raw); + BigInteger gottenHash = sha256(decode); + if (!expectedHash.equals(gottenHash)) { + throw new IntegrityCheckException("File " + + configKey + + " does not " + + "have the expected sha256 hash: Expected " + + expectedHash.toString(16) + + ", but got " + + gottenHash.toString(16)); + } + if (decode.length != configTarget.length) { + throw new IntegrityCheckException("File " + + configKey + + " does not " + + "have the expected length: Expected " + + configTarget.length + + ", but got " + + decode.length); + } + + return decode; + } + } catch (IntegrityCheckException e) { + throw e; + } catch (Exception exception) { + throw new IntegrityCheckException( + "Could not get file contents from remote config, file " + configKey, exception); } - return decode; - } - } catch (IntegrityCheckException e) { - throw e; - } catch (Exception exception) { - throw new IntegrityCheckException( - "Could not get file contents from remote config, file " + configKey, exception); + throw new MissingContentException("No content for " + configKey); } - throw new MissingContentException("No content for " + configKey); - } - - private static BigInteger sha256(byte[] bytes) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(bytes); - return new BigInteger(1, hash); - } catch (NoSuchAlgorithmException e) { - throw new UndeclaredThrowableException(e); + private static BigInteger sha256(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(bytes); + return new BigInteger(1, hash); + } catch (NoSuchAlgorithmException e) { + throw new UndeclaredThrowableException(e); + } } - } - public List getClientConfigs() { - return this.clientConfigs != null ? this.clientConfigs : Collections.emptyList(); - } + public List getClientConfigs() { + return this.clientConfigs != null ? this.clientConfigs : Collections.emptyList(); + } - public static class Targets { - public List signatures; + public static class Targets { + public List signatures; - @Json(name = "signed") - public TargetsSigned targetsSigned; + @Json(name = "signed") + public TargetsSigned targetsSigned; - public transient Map targetsSignedUntyped; + public transient Map targetsSignedUntyped; - public static class Signature { - @Json(name = "keyid") - public String keyId; + public static class Signature { + @Json(name = "keyid") + public String keyId; - @Json(name = "sig") - public String signature; - } + @Json(name = "sig") + public String signature; + } - public static class TargetsSigned { - @Json(name = "_type") - public String type; + public static class TargetsSigned { + @Json(name = "_type") + public String type; - public TargetsCustom custom; - public Instant expires; + public TargetsCustom custom; + public Instant expires; - @Json(name = "spec_version") - public String specVersion; + @Json(name = "spec_version") + public String specVersion; - public long version; - public Map targets; + public long version; + public Map targets; - public static class TargetsCustom { - @Json(name = "opaque_backend_state") - public String opaqueBackendState; - } - } + public static class TargetsCustom { + @Json(name = "opaque_backend_state") + public String opaqueBackendState; + } + } - public static class ConfigTarget { - public ConfigTargetCustom custom; - public Map hashes; - public long length; + public static class ConfigTarget { + public ConfigTargetCustom custom; + public Map hashes; + public long length; - public static class ConfigTargetCustom { - @Json(name = "v") - public long version; - } + public static class ConfigTargetCustom { + @Json(name = "v") + public long version; + } + } } - } - public static class TargetFile { - public String path; - public String raw; - } + public static class TargetFile { + public String path; + public String raw; + } } diff --git a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java index 51922d9cbfa..de8cfb10301 100644 --- a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java +++ b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java @@ -72,1417 +72,1350 @@ @WithConfig(key = "env", value = "my_env") @WithConfig(key = "remote_config.integrity_check.enabled", value = "true") class DefaultConfigurationPollerSpecification extends DDJavaSpecification { - private static final HttpUrl URL = HttpUrl.get("https://example.com/v0.7/config"); - private static final Request REQUEST = new Request.Builder().url("https://example.com").build(); - private static final int DEFAULT_POLL_PERIOD = 5000; - private static final String KEY_ID = "TEST_KEY_ID"; - private static final Ed25519PrivateKey PRIVATE_KEY = - Ed25519PrivateKey.generate(new SecureRandom()); - private static final Ed25519PublicKey PUBLIC_KEY = PRIVATE_KEY.derivePublic(); - - private static final Moshi MOSHI = new Moshi.Builder().build(); - - private final OkHttpClient okHttpClient = mock(OkHttpClient.class); - private final AgentTaskScheduler scheduler = mock(AgentTaskScheduler.class); - - @SuppressWarnings("unchecked") - private final AgentTaskScheduler.Scheduled scheduled = - mock(AgentTaskScheduler.Scheduled.class); - - private final Call call = mock(Call.class); - - private DefaultConfigurationPoller poller; - private AgentTaskScheduler.Task task; - private Request request; - private Supplier configUrlSupplier = URL::toString; - - private Response buildOKResponse(String bodyStr) { - ResponseBody body = ResponseBody.create(MediaType.get("application/json"), bodyStr); - return new Response.Builder() - .request(REQUEST) - .protocol(Protocol.HTTP_1_1) - .message("OK") - .body(body) - .code(200) - .build(); - } - - @BeforeEach - void setup() { - // value derived from the randomly generated keypair, so it cannot be a @WithConfig literal - injectSysConfig("dd.rc.targets.key", new BigInteger(1, PUBLIC_KEY.toByteArray()).toString(16)); - poller = - new DefaultConfigurationPoller( - Config.get(), "0.0.0", "", "", () -> configUrlSupplier.get(), okHttpClient, scheduler); - } - - // ----- Tests ----- - - @Test - void issuesNoRequestIfThereAreNoSubscriptions() { - start(); - - task.run(poller); - verify(okHttpClient, never()).newCall(any()); - - poller.addListener( - Product.ASM_DD, - deserializerThrowing("should not be called"), - (configKey, config, hinter) -> {}); - poller.removeListeners(Product.ASM_DD); - task.run(poller); - verify(okHttpClient, never()).newCall(any()); - - poller.stop(); - verify(scheduled).cancel(); - } - - @Test - void issuesRequestIfThereIsASubscription() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener(Product.ASM_DD, parseDeserializer(), listener); - start(); - - stubHttp(buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - - verify(listener).accept(any(), any(), any()); - - String json = bodyJson(); - assertThatJson(json).node("cached_target_files").isAbsent(); - assertThatJson(json).node("client.client_tracer.app_version").isEqualTo(""); - assertThatJson(json).node("client.client_tracer.env").isEqualTo("my_env"); - assertThatJson(json).node("client.client_tracer.language").isEqualTo("java"); - assertThatJson(json).node("client.client_tracer.runtime_id").isString().isNotEmpty(); - assertThatJson(json).node("client.client_tracer.service").isEqualTo("my_service"); - assertThatJson(json).node("client.client_tracer.tracer_version").isEqualTo("0.0.0"); - assertThatJson(json).node("client.id").isString().hasSize(36); - assertThatJson(json).node("client.is_tracer").isEqualTo(true); - assertThatJson(json).node("client.products").isArray().containsExactly("ASM_DD"); - assertThatJson(json).node("client.state.config_states").isArray().isEmpty(); - assertThatJson(json).node("client.state.has_error").isEqualTo(false); - assertThatJson(json).node("client.state.root_version").isEqualTo(1); - assertThatJson(json).node("client.state.targets_version").isEqualTo(0); - } - - @Test - void issuesNoRequestIfTheConfigUrlSupplierReturnsNull() { - ConfigurationDeserializer deserializer = deserializerMock(); - ConfigurationChangesTypedListener listener = typedListener(); - configUrlSupplier = () -> null; - - poller.addListener(Product.ASM_DD, deserializer, listener); - start(); - - task.run(poller); - - verify(okHttpClient, never()).newCall(any()); - } - - @Test - @SuppressWarnings("unchecked") - void onceTheSupplierProvidesAUrlItIsNotCalledAnymore() throws IOException { - ConfigurationDeserializer deserializer = deserializerMock(); - ConfigurationChangesTypedListener listener = typedListener(); - configUrlSupplier = mock(Supplier.class); - when(configUrlSupplier.get()).thenReturn(URL.toString()); - - poller.addListener(Product.ASM_DD, deserializer, listener); - start(); - - when(deserializer.deserialize(any())).thenReturn(true); - stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - verify(configUrlSupplier, times(1)).get(); - verify(okHttpClient, times(2)).newCall(any()); - verify(deserializer, times(1)).deserialize(any()); - verify(listener, times(1)).accept(any(), any(), any()); - } - - @Test - void handleProductListenersPerConfig() throws IOException { - ConfigurationDeserializer deserializer = deserializerMock(); - ConfigurationChangesTypedListener activationListener = typedListener(); - ConfigurationChangesTypedListener sampleRateListener = typedListener(); - - String activationKey = "datadog/2/ASM_FEATURES/asm_features_activation/config"; - String sampleRateKey = "datadog/2/ASM_FEATURES/api_security/sample_rate"; - - String respBody = - toJson( - map( + private static final HttpUrl URL = HttpUrl.get("https://example.com/v0.7/config"); + private static final Request REQUEST = + new Request.Builder().url("https://example.com").build(); + private static final int DEFAULT_POLL_PERIOD = 5000; + private static final String KEY_ID = "TEST_KEY_ID"; + private static final Ed25519PrivateKey PRIVATE_KEY = Ed25519PrivateKey.generate(new SecureRandom()); + private static final Ed25519PublicKey PUBLIC_KEY = PRIVATE_KEY.derivePublic(); + + private static final Moshi MOSHI = new Moshi.Builder().build(); + + private final OkHttpClient okHttpClient = mock(OkHttpClient.class); + private final AgentTaskScheduler scheduler = mock(AgentTaskScheduler.class); + + @SuppressWarnings("unchecked") + private final AgentTaskScheduler.Scheduled scheduled = + mock(AgentTaskScheduler.Scheduled.class); + + private final Call call = mock(Call.class); + + private DefaultConfigurationPoller poller; + private AgentTaskScheduler.Task task; + private Request request; + private Supplier configUrlSupplier = URL::toString; + + private Response buildOKResponse(String bodyStr) { + ResponseBody body = ResponseBody.create(MediaType.get("application/json"), bodyStr); + return new Response.Builder() + .request(REQUEST) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .body(body) + .code(200) + .build(); + } + + @BeforeEach + void setup() { + // value derived from the randomly generated keypair, so it cannot be a @WithConfig literal + injectSysConfig("dd.rc.targets.key", new BigInteger(1, PUBLIC_KEY.toByteArray()).toString(16)); + poller = new DefaultConfigurationPoller( + Config.get(), "0.0.0", "", "", () -> configUrlSupplier.get(), okHttpClient, scheduler); + } + + // ----- Tests ----- + + @Test + void issuesNoRequestIfThereAreNoSubscriptions() { + start(); + + task.run(poller); + verify(okHttpClient, never()).newCall(any()); + + poller.addListener( + Product.ASM_DD, deserializerThrowing("should not be called"), (configKey, config, hinter) -> {}); + poller.removeListeners(Product.ASM_DD); + task.run(poller); + verify(okHttpClient, never()).newCall(any()); + + poller.stop(); + verify(scheduled).cancel(); + } + + @Test + void issuesRequestIfThereIsASubscription() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener(Product.ASM_DD, parseDeserializer(), listener); + start(); + + stubHttp(buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + + verify(listener).accept(any(), any(), any()); + + String json = bodyJson(); + assertThatJson(json).node("cached_target_files").isAbsent(); + assertThatJson(json).node("client.client_tracer.app_version").isEqualTo(""); + assertThatJson(json).node("client.client_tracer.env").isEqualTo("my_env"); + assertThatJson(json).node("client.client_tracer.language").isEqualTo("java"); + assertThatJson(json).node("client.client_tracer.runtime_id").isString().isNotEmpty(); + assertThatJson(json).node("client.client_tracer.service").isEqualTo("my_service"); + assertThatJson(json).node("client.client_tracer.tracer_version").isEqualTo("0.0.0"); + assertThatJson(json).node("client.id").isString().hasSize(36); + assertThatJson(json).node("client.is_tracer").isEqualTo(true); + assertThatJson(json).node("client.products").isArray().containsExactly("ASM_DD"); + assertThatJson(json).node("client.state.config_states").isArray().isEmpty(); + assertThatJson(json).node("client.state.has_error").isEqualTo(false); + assertThatJson(json).node("client.state.root_version").isEqualTo(1); + assertThatJson(json).node("client.state.targets_version").isEqualTo(0); + } + + @Test + void issuesNoRequestIfTheConfigUrlSupplierReturnsNull() { + ConfigurationDeserializer deserializer = deserializerMock(); + ConfigurationChangesTypedListener listener = typedListener(); + configUrlSupplier = () -> null; + + poller.addListener(Product.ASM_DD, deserializer, listener); + start(); + + task.run(poller); + + verify(okHttpClient, never()).newCall(any()); + } + + @Test + @SuppressWarnings("unchecked") + void onceTheSupplierProvidesAUrlItIsNotCalledAnymore() throws IOException { + ConfigurationDeserializer deserializer = deserializerMock(); + ConfigurationChangesTypedListener listener = typedListener(); + configUrlSupplier = mock(Supplier.class); + when(configUrlSupplier.get()).thenReturn(URL.toString()); + + poller.addListener(Product.ASM_DD, deserializer, listener); + start(); + + when(deserializer.deserialize(any())).thenReturn(true); + stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + verify(configUrlSupplier, times(1)).get(); + verify(okHttpClient, times(2)).newCall(any()); + verify(deserializer, times(1)).deserialize(any()); + verify(listener, times(1)).accept(any(), any(), any()); + } + + @Test + void handleProductListenersPerConfig() throws IOException { + ConfigurationDeserializer deserializer = deserializerMock(); + ConfigurationChangesTypedListener activationListener = typedListener(); + ConfigurationChangesTypedListener sampleRateListener = typedListener(); + + String activationKey = "datadog/2/ASM_FEATURES/asm_features_activation/config"; + String sampleRateKey = "datadog/2/ASM_FEATURES/api_security/sample_rate"; + + String respBody = toJson(map( "client_configs", list(activationKey, sampleRateKey), "roots", list(), "target_files", - list( - map("path", activationKey, "raw", b64("{\"asm\":{\"enabled\":true}}")), - map( - "path", - sampleRateKey, - "raw", - b64("{\"api_security\": {\"request_sample_rate\": 0.1}"))), + list( + map("path", activationKey, "raw", b64("{\"asm\":{\"enabled\":true}}")), + map( + "path", + sampleRateKey, + "raw", + b64("{\"api_security\": {\"request_sample_rate\": 0.1}"))), "targets", - signAndBase64EncodeTargets( - map( - "signed", - map( - "expires", - "2022-09-17T12:49:15Z", - "spec_version", - "1.0.0", - "targets", + signAndBase64EncodeTargets(map( + "signed", map( - activationKey, + "expires", + "2022-09-17T12:49:15Z", + "spec_version", + "1.0.0", + "targets", map( - "custom", map("v", 1), - "hashes", - map( - "sha256", - "159658ab85be7207761a4111172b01558394bfc74a1fe1d314f2023f7c656db"), - "length", 24), - sampleRateKey, - map( - "custom", map("v", 1), - "hashes", - map( - "sha256", - "bc898b7eb75d9fd0ddee1c1a556bc3c528dd41382950aa86e48816f792d01494"), - "length", 45)), - "version", - 1))))); - - String noConfigs = withClientConfigs(SAMPLE_RESP_BODY, list()); - - poller.addListener( - Product.ASM_FEATURES, "asm_features_activation", deserializer, activationListener); - poller.addListener(Product.ASM_FEATURES, "api_security", deserializer, sampleRateListener); - start(); - - when(deserializer.deserialize(any())).thenReturn(true); - stubHttp(buildOKResponse(respBody), buildOKResponse(noConfigs)); - - task.run(poller); // apply - task.run(poller); // remove all configurations - - // 2 deserializations on apply; accept called once per run (apply + remove) on each listener - verify(deserializer, times(2)).deserialize(any()); - verify(activationListener, times(2)).accept(eq(activationKey), any(), any()); - verify(sampleRateListener, times(2)).accept(eq(sampleRateKey), any(), any()); - } - - @Test - void processingHappensForAllListeners() throws IOException { - ConfigurationDeserializer deserializer = deserializerMock(); - List> listeners = new ArrayList<>(); - for (int i = 0; i < 5; i++) { - listeners.add(typedListener()); - } - - String respBody = - toJson( - map( + activationKey, + map( + "custom", map("v", 1), + "hashes", + map( + "sha256", + "159658ab85be7207761a4111172b01558394bfc74a1fe1d314f2023f7c656db"), + "length", 24), + sampleRateKey, + map( + "custom", map("v", 1), + "hashes", + map( + "sha256", + "bc898b7eb75d9fd0ddee1c1a556bc3c528dd41382950aa86e48816f792d01494"), + "length", 45)), + "version", + 1))))); + + String noConfigs = withClientConfigs(SAMPLE_RESP_BODY, list()); + + poller.addListener(Product.ASM_FEATURES, "asm_features_activation", deserializer, activationListener); + poller.addListener(Product.ASM_FEATURES, "api_security", deserializer, sampleRateListener); + start(); + + when(deserializer.deserialize(any())).thenReturn(true); + stubHttp(buildOKResponse(respBody), buildOKResponse(noConfigs)); + + task.run(poller); // apply + task.run(poller); // remove all configurations + + // 2 deserializations on apply; accept called once per run (apply + remove) on each listener + verify(deserializer, times(2)).deserialize(any()); + verify(activationListener, times(2)).accept(eq(activationKey), any(), any()); + verify(sampleRateListener, times(2)).accept(eq(sampleRateKey), any(), any()); + } + + @Test + void processingHappensForAllListeners() throws IOException { + ConfigurationDeserializer deserializer = deserializerMock(); + List> listeners = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + listeners.add(typedListener()); + } + + String respBody = toJson(map( "client_configs", - list( - "datadog/2/ASM_FEATURES/asm_features_activation/config", - "foo/ASM_DD/bar/config", - "foo/ASM/bar/config", - "foo/ASM_DATA/bar/config", - "foo/LIVE_DEBUGGING/bar/config"), + list( + "datadog/2/ASM_FEATURES/asm_features_activation/config", + "foo/ASM_DD/bar/config", + "foo/ASM/bar/config", + "foo/ASM_DATA/bar/config", + "foo/LIVE_DEBUGGING/bar/config"), "roots", list(), "target_files", - list( - map( - "path", - "datadog/2/ASM_FEATURES/asm_features_activation/config", - "raw", - b64("{\"asm\":{\"enabled\":true}}")), - map("path", "foo/ASM_DD/bar/config", "raw", ""), - map("path", "foo/ASM/bar/config", "raw", ""), - map("path", "foo/ASM_DATA/bar/config", "raw", ""), - map("path", "foo/LIVE_DEBUGGING/bar/config", "raw", "")), + list( + map( + "path", + "datadog/2/ASM_FEATURES/asm_features_activation/config", + "raw", + b64("{\"asm\":{\"enabled\":true}}")), + map("path", "foo/ASM_DD/bar/config", "raw", ""), + map("path", "foo/ASM/bar/config", "raw", ""), + map("path", "foo/ASM_DATA/bar/config", "raw", ""), + map("path", "foo/LIVE_DEBUGGING/bar/config", "raw", "")), "targets", - signAndBase64EncodeTargets( - map( - "signed", - map( - "expires", - "2022-09-17T12:49:15Z", - "spec_version", - "1.0.0", - "targets", + signAndBase64EncodeTargets(map( + "signed", map( - "datadog/2/ASM_FEATURES/asm_features_activation/config", + "expires", + "2022-09-17T12:49:15Z", + "spec_version", + "1.0.0", + "targets", map( - "custom", map("v", 1), - "hashes", - map( - "sha256", - "159658ab85be7207761a4111172b01558394bfc74a1fe1d314f2023f7c656db"), - "length", 24), - "foo/ASM_DD/bar/config", emptyTarget(), - "foo/ASM/bar/config", emptyTarget(), - "foo/ASM_DATA/bar/config", emptyTarget(), - "foo/LIVE_DEBUGGING/bar/config", emptyTarget()), - "version", - 1))))); - - poller.addListener(Product.ASM_DD, deserializer, listeners.get(1)); - poller.addListener(Product.ASM, deserializer, listeners.get(2)); - poller.addListener(Product.ASM_DATA, deserializer, listeners.get(3)); - poller.addListener(Product.LIVE_DEBUGGING, deserializer, listeners.get(0)); - poller.addListener(Product.ASM_FEATURES, deserializer, listeners.get(4)); - start(); - - when(deserializer.deserialize(any())).thenReturn(true); - stubHttp(buildOKResponse(respBody)); - task.run(poller); - - verify(deserializer, times(5)).deserialize(any()); - for (ConfigurationChangesTypedListener listener : listeners) { - verify(listener).accept(any(), any(), any()); - } - } - - @Test - void reschedulesIfInstructedToDoSo() throws IOException { - poller.addListener( - Product.ASM_DD, - parseDeserializer(), - (configKey, config, hinter) -> { - hinter.suggestPollingRate(Duration.ofMillis(124)); - hinter.suggestPollingRate(Duration.ofMillis(123)); - hinter.suggestPollingRate(Duration.ofMillis(1230)); // higher is ignored - }); - start(); - - when(scheduler.scheduleAtFixedRate(any(), eq(poller), eq(123L), eq(123L), eq(MILLISECONDS))) - .thenAnswer(invocation -> scheduled); - stubHttp(buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - - verify(scheduler).scheduleAtFixedRate(any(), eq(poller), eq(123L), eq(123L), eq(MILLISECONDS)); - verify(scheduled).cancel(); - } - - @Test - void setsCachedFilesAndConfigStateOnSecondRequest() throws IOException { - poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> {}); - start(); - - stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - verify(okHttpClient, times(2)).newCall(any()); - - Map body = parseBody(); - List cachedTargetFiles = asList(body.get("cached_target_files")); - assertEquals(1, cachedTargetFiles.size()); - Map cached = asMap(cachedTargetFiles.get(0)); - assertEquals( - list( - map( - "algorithm", - "sha256", - "hash", - "6302258236e6051216b950583ec7136d946b463c17cbe64384ba5d566324819")), - cached.get("hashes")); - assertEquals(919L, asLong(cached.get("length"))); - assertEquals("employee/ASM_DD/1.recommended.json/config", cached.get("path")); - - Map state = clientState(body); - assertEquals("foobar", state.get("backend_client_state")); - List configStates = asList(state.get("config_states")); - assertEquals(1, configStates.size()); - Map configState = asMap(configStates.get(0)); - assertEquals("1.recommended.json", configState.get("id")); - assertEquals("ASM_DD", configState.get("product")); - assertEquals(1L, asLong(configState.get("version"))); - } - - @Test - void removesCachedFileIfConfigurationIsPulled() throws IOException { - poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> {}); - start(); - - String noConfigs = withClientConfigs(SAMPLE_RESP_BODY, list()); - stubHttp( - buildOKResponse(SAMPLE_RESP_BODY), - buildOKResponse(noConfigs), - buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - task.run(poller); - - Map body = parseBody(); - assertNull(body.get("cached_target_files")); - } - - @Test - void doesNotUpdateTargetsVersionNumberIfThereIsAnError() throws IOException { - poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> {}); - start(); - - Map errored = parseMap(SAMPLE_RESP_BODY); - errored.put("target_files", list()); - Map targets = decodeTargets(errored.get("targets")); - asMap(asMap(targets.get("signed")).get("targets")) - .remove("employee/ASM_DD/1.recommended.json/config"); - asMap(targets.get("signed")).put("version", 42); - errored.put("targets", signAndBase64EncodeTargets(targets)); - - stubHttp( - buildOKResponse(SAMPLE_RESP_BODY), - buildOKResponse(toJson(errored)), - buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - task.run(poller); - - Map body = parseBody(); - assertNull(body.get("cached_target_files")); // previous hash should be cleared too - Map state = clientState(body); - assertEquals("foobar", state.get("backend_client_state")); - assertTrue(asList(state.get("config_states")).isEmpty()); - assertEquals(Boolean.TRUE, state.get("has_error")); - assertEquals( - "Told to apply config for employee/ASM_DD/1.recommended.json/config but no corresponding entry " - + "exists in targets.targets_signed.targets", - state.get("error")); - assertEquals(1L, asLong(state.get("root_version"))); - assertEquals(23337393L, asLong(state.get("targets_version"))); - } - - @Test - void appliesConfigurationOnlyIfTheHashHasChanged() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - String configKey = "employee/ASM_DD/1.recommended.json/config"; - - poller.addListener(Product.ASM_DD, parseDeserializer(), listener); - start(); - - Map changed = parseMap(SAMPLE_RESP_BODY); - List targetFiles = asList(changed.get("target_files")); - byte[] fileDecoded = Base64.getDecoder().decode((String) asMap(targetFiles.get(0)).get("raw")); - byte[] newFile = Arrays.copyOf(fileDecoded, fileDecoded.length + 1); - newFile[fileDecoded.length] = '\n'; - asMap(targetFiles.get(0)).put("raw", Base64.getEncoder().encodeToString(newFile)); - Map targets = decodeTargets(changed.get("targets")); - Map targetEntry = - asMap(asMap(asMap(targets.get("signed")).get("targets")).get(configKey)); - asMap(targetEntry.get("hashes")).put("sha256", sha256(newFile).toString(16)); - targetEntry.put("length", asLong(targetEntry.get("length")) + 1); - changed.put("targets", signAndBase64EncodeTargets(targets)); - - stubHttp( - buildOKResponse(SAMPLE_RESP_BODY), - buildOKResponse(SAMPLE_RESP_BODY), - buildOKResponse(toJson(changed))); - task.run(poller); - task.run(poller); - task.run(poller); - - // applied once on the first run (unchanged hash on the second), once again when the hash - // changed - verify(listener, times(2)).accept(eq(configKey), notNull(), any()); - } - - @Test - void configurationCannotBeAppliedWithoutHashes() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - String configKey = "employee/ASM_DD/1.recommended.json/config"; - - poller.addListener(Product.ASM_DD, parseDeserializer(), listener); - start(); - - Map noHashes = parseMap(SAMPLE_RESP_BODY); - Map targets = decodeTargets(noHashes.get("targets")); - asMap(asMap(asMap(asMap(targets.get("signed")).get("targets")).get(configKey)).get("hashes")) - .remove("sha256"); - noHashes.put("targets", signAndBase64EncodeTargets(targets)); - - stubHttp(buildOKResponse(toJson(noHashes)), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - verify(listener).accept(eq(configKey), any(), any()); - - Map state = clientState(parseBody()); - assertEquals(0, asList(state.get("config_states")).size()); - assertEquals("No sha256 hash present for " + configKey, state.get("error")); - assertEquals(0L, asLong(state.get("targets_version"))); - } - - @Test - void encodedFileIsNotValidBase64Data() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener(Product.ASM_DD, parseDeserializer(), listener); - start(); - - Map badBase64 = parseMap(SAMPLE_RESP_BODY); - List targetFiles = asList(badBase64.get("target_files")); - byte[] fileDecoded = Base64.getDecoder().decode((String) asMap(targetFiles.get(0)).get("raw")); - asMap(targetFiles.get(0)).put("raw", Base64.getEncoder().encodeToString(fileDecoded) + "##"); - - stubHttp(buildOKResponse(toJson(badBase64)), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - verify(listener).accept(any(), any(), any()); - - Map state = clientState(parseBody()); - assertEquals( - "Could not get file contents from remote config, file employee/ASM_DD/1.recommended.json/config", - state.get("error")); - } - - @Test - void deserializerCanReturnNullToIndicateNoConfigShouldBeApplied() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener(Product.ASM_DD, content -> null, listener); - start(); - - stubHttp(buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - - verify(listener, never()).accept(any(), any(), any()); - } - - @Test - void rejectsConfigurationIfTheHashIsWrong() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - String configKey = "employee/ASM_DD/1.recommended.json/config"; - - poller.addListener(Product.ASM_DD, parseDeserializer(), listener); - start(); - - Map wrongHash = parseMap(SAMPLE_RESP_BODY); - Map targets = decodeTargets(wrongHash.get("targets")); - asMap(asMap(asMap(asMap(targets.get("signed")).get("targets")).get(configKey)).get("hashes")) - .put("sha256", "0"); - wrongHash.put("targets", signAndBase64EncodeTargets(targets)); - - stubHttp(buildOKResponse(toJson(wrongHash))); - task.run(poller); - - verify(listener, never()).accept(any(), any(), any()); - } - - @Test - void acceptsAnEmptyObjectAsAResponseToIndicateNoChanges() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - ConfigurationDeserializer deserializer = deserializerMock(); - - poller.addListener(Product.ASM_DD, deserializer, listener); - start(); - - stubHttp(buildOKResponse("{}")); - task.run(poller); - - verifyNoInteractions(deserializer); - verifyNoInteractions(listener); - } - - @Test - void acceptsHttp204AsAResponseToIndicateNoChanges() throws IOException { - Response resp = - new Response.Builder() - .request(REQUEST) - .protocol(Protocol.HTTP_1_1) - .message("No Content") - .body(ResponseBody.create(MediaType.parse("application/json"), "")) - .code(204) - .build(); - ConfigurationChangesTypedListener listener = typedListener(); - ConfigurationDeserializer deserializer = deserializerMock(); - - poller.addListener(Product.ASM_DD, deserializer, listener); - start(); - - stubHttp(resp); - task.run(poller); - - verifyNoInteractions(deserializer); - verifyNoInteractions(listener); - } - - @Test - void appliesAndRemoveCalledForProductListener() throws IOException { - ProductListener listener = mock(ProductListener.class); - String cfgWithoutAsm = cfgWithoutAsm(); - - poller.addListener(Product.ASM_DD, listener); - start(); - - stubHttp( - buildOKResponse(SAMPLE_RESP_BODY), - buildOKResponse(SAMPLE_RESP_BODY), - buildOKResponse(cfgWithoutAsm), - buildOKResponse(cfgWithoutAsm)); - task.run(poller); // accept + commit - task.run(poller); // no commit, no change - task.run(poller); // remove + commit - task.run(poller); // no commit, no change - - verify(listener, times(1)).accept(any(), notNull(), any()); - verify(listener, times(1)).remove(any(), any()); - verify(listener, times(2)).commit(any()); - } - - @Test - void unappliesConfigurationsItHasStoppedSeeing() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - String cfgWithoutAsm = cfgWithoutAsm(); - - poller.addListener(Product.ASM_DD, parseDeserializer(), listener); - start(); - - stubHttp( - buildOKResponse(SAMPLE_RESP_BODY), - buildOKResponse(cfgWithoutAsm), - buildOKResponse(cfgWithoutAsm)); - task.run(poller); // accept with non-null config - task.run(poller); // unapply (accept with null config) - task.run(poller); // nothing more - - verify(listener, times(1)).accept(any(), notNull(), any()); - verify(listener, times(1)).accept(any(), isNull(), any()); - } - - @Test - void supportMultipleConfigurations() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - String recommended = "employee/ASM_DD/1.recommended.json/config"; - String suggested = "employee/ASM_DD/2.suggested.json/config"; - String multiConfigs = withClientConfigs(SAMPLE_RESP_BODY, list(recommended, suggested)); - String noConfigs = withClientConfigs(SAMPLE_RESP_BODY, list()); - - poller.addListener(Product.ASM_DD, parseDeserializer(), listener); - start(); - - stubHttp( - buildOKResponse(SAMPLE_RESP_BODY), // apply first configuration - buildOKResponse(multiConfigs), // apply second configuration - buildOKResponse(SAMPLE_RESP_BODY), // remove second configuration - buildOKResponse(noConfigs)); // remove all configurations - task.run(poller); - task.run(poller); - task.run(poller); - task.run(poller); - - verify(listener, times(1)).accept(eq(recommended), notNull(), any()); - verify(listener, times(1)).accept(eq(suggested), notNull(), any()); - verify(listener, times(1)).accept(eq(suggested), isNull(), any()); - verify(listener, times(1)).accept(eq(recommended), isNull(), any()); - } - - @Test - void exceptionApplyingOneConfigShouldNotPreventOthersFromBeingApplied() throws IOException { - String newConfigId = "1ba66cc9-146a-3479-9e66-2b63fd580f48"; - String newConfigKey = "datadog/2/LIVE_DEBUGGING/" + newConfigId + "/config"; - - poller.addListener( - Product.ASM_DD, - parseDeserializer(), - (configKey, config, hinter) -> { - throw new RuntimeException("throw here"); + "datadog/2/ASM_FEATURES/asm_features_activation/config", + map( + "custom", map("v", 1), + "hashes", + map( + "sha256", + "159658ab85be7207761a4111172b01558394bfc74a1fe1d314f2023f7c656db"), + "length", 24), + "foo/ASM_DD/bar/config", emptyTarget(), + "foo/ASM/bar/config", emptyTarget(), + "foo/ASM_DATA/bar/config", emptyTarget(), + "foo/LIVE_DEBUGGING/bar/config", emptyTarget()), + "version", + 1))))); + + poller.addListener(Product.ASM_DD, deserializer, listeners.get(1)); + poller.addListener(Product.ASM, deserializer, listeners.get(2)); + poller.addListener(Product.ASM_DATA, deserializer, listeners.get(3)); + poller.addListener(Product.LIVE_DEBUGGING, deserializer, listeners.get(0)); + poller.addListener(Product.ASM_FEATURES, deserializer, listeners.get(4)); + start(); + + when(deserializer.deserialize(any())).thenReturn(true); + stubHttp(buildOKResponse(respBody)); + task.run(poller); + + verify(deserializer, times(5)).deserialize(any()); + for (ConfigurationChangesTypedListener listener : listeners) { + verify(listener).accept(any(), any(), any()); + } + } + + @Test + void reschedulesIfInstructedToDoSo() throws IOException { + poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> { + hinter.suggestPollingRate(Duration.ofMillis(124)); + hinter.suggestPollingRate(Duration.ofMillis(123)); + hinter.suggestPollingRate(Duration.ofMillis(1230)); // higher is ignored }); - poller.addListener( - Product.LIVE_DEBUGGING, parseDeserializer(), (configKey, config, hinter) -> {}); - start(); - - Map withExtra = parseMap(SAMPLE_RESP_BODY); - asList(withExtra.get("client_configs")).add(newConfigKey); - Map targets = decodeTargets(withExtra.get("targets")); - asMap(asMap(targets.get("signed")).get("targets")) - .put( - newConfigKey, - map( - "custom", map("v", 3), - "hashes", - map( + start(); + + when(scheduler.scheduleAtFixedRate(any(), eq(poller), eq(123L), eq(123L), eq(MILLISECONDS))) + .thenAnswer(invocation -> scheduled); + stubHttp(buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + + verify(scheduler).scheduleAtFixedRate(any(), eq(poller), eq(123L), eq(123L), eq(MILLISECONDS)); + verify(scheduled).cancel(); + } + + @Test + void setsCachedFilesAndConfigStateOnSecondRequest() throws IOException { + poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> {}); + start(); + + stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + verify(okHttpClient, times(2)).newCall(any()); + + Map body = parseBody(); + List cachedTargetFiles = asList(body.get("cached_target_files")); + assertEquals(1, cachedTargetFiles.size()); + Map cached = asMap(cachedTargetFiles.get(0)); + assertEquals( + list(map( + "algorithm", "sha256", - "7a38bf81f383f69433ad6e900d35b3e2385593f76a7b7ab5d4355b8ba41ee24b"), - "length", "{\"foo\":\"bar\"}".length())); - asList(withExtra.get("target_files")) - .add(map("path", newConfigKey, "raw", b64("{\"foo\":\"bar\"}"))); - withExtra.put("targets", signAndBase64EncodeTargets(targets)); - - stubHttp(buildOKResponse(toJson(withExtra)), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - Map body = parseBody(); - List configStates = asList(clientState(body).get("config_states")); - assertEquals(2, configStates.size()); - Map liveDebuggingConfig = findConfigState(configStates, "LIVE_DEBUGGING"); - Map asmConfig = findConfigState(configStates, "ASM_DD"); - assertEquals(newConfigId, liveDebuggingConfig.get("id")); - assertEquals("LIVE_DEBUGGING", liveDebuggingConfig.get("product")); - assertEquals(3L, asLong(liveDebuggingConfig.get("version"))); - assertNull(liveDebuggingConfig.get("apply_error")); - assertEquals("1.recommended.json", asmConfig.get("id")); - assertEquals("ASM_DD", asmConfig.get("product")); - assertEquals(1L, asLong(asmConfig.get("version"))); - assertEquals(APPLY_STATE_ERROR, asInt(asmConfig.get("apply_state"))); - assertEquals("throw here", asmConfig.get("apply_error")); - } - - static Stream badResponsesArguments() { - return Stream.of( - arguments( - "404 with body", - new Response.Builder() - .request(REQUEST) - .protocol(Protocol.HTTP_1_1) - .message("Not Found") - .code(404) - .body(ResponseBody.create(MediaType.get("text/plain"), "not found!")) - .build()), - arguments( - "404 without body", - new Response.Builder() - .request(REQUEST) - .protocol(Protocol.HTTP_1_1) - .message("Not Found") - .code(404) - .build()), - arguments( - "success, no body", - new Response.Builder() - .request(REQUEST) - .protocol(Protocol.HTTP_1_1) - .message("Created") - .code(201) - .build()), - arguments( - "not json", - new Response.Builder() + "hash", + "6302258236e6051216b950583ec7136d946b463c17cbe64384ba5d566324819")), + cached.get("hashes")); + assertEquals(919L, asLong(cached.get("length"))); + assertEquals("employee/ASM_DD/1.recommended.json/config", cached.get("path")); + + Map state = clientState(body); + assertEquals("foobar", state.get("backend_client_state")); + List configStates = asList(state.get("config_states")); + assertEquals(1, configStates.size()); + Map configState = asMap(configStates.get(0)); + assertEquals("1.recommended.json", configState.get("id")); + assertEquals("ASM_DD", configState.get("product")); + assertEquals(1L, asLong(configState.get("version"))); + } + + @Test + void removesCachedFileIfConfigurationIsPulled() throws IOException { + poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> {}); + start(); + + String noConfigs = withClientConfigs(SAMPLE_RESP_BODY, list()); + stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(noConfigs), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + task.run(poller); + + Map body = parseBody(); + assertNull(body.get("cached_target_files")); + } + + @Test + void doesNotUpdateTargetsVersionNumberIfThereIsAnError() throws IOException { + poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> {}); + start(); + + Map errored = parseMap(SAMPLE_RESP_BODY); + errored.put("target_files", list()); + Map targets = decodeTargets(errored.get("targets")); + asMap(asMap(targets.get("signed")).get("targets")).remove("employee/ASM_DD/1.recommended.json/config"); + asMap(targets.get("signed")).put("version", 42); + errored.put("targets", signAndBase64EncodeTargets(targets)); + + stubHttp( + buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(toJson(errored)), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + task.run(poller); + + Map body = parseBody(); + assertNull(body.get("cached_target_files")); // previous hash should be cleared too + Map state = clientState(body); + assertEquals("foobar", state.get("backend_client_state")); + assertTrue(asList(state.get("config_states")).isEmpty()); + assertEquals(Boolean.TRUE, state.get("has_error")); + assertEquals( + "Told to apply config for employee/ASM_DD/1.recommended.json/config but no corresponding entry " + + "exists in targets.targets_signed.targets", + state.get("error")); + assertEquals(1L, asLong(state.get("root_version"))); + assertEquals(23337393L, asLong(state.get("targets_version"))); + } + + @Test + void appliesConfigurationOnlyIfTheHashHasChanged() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + String configKey = "employee/ASM_DD/1.recommended.json/config"; + + poller.addListener(Product.ASM_DD, parseDeserializer(), listener); + start(); + + Map changed = parseMap(SAMPLE_RESP_BODY); + List targetFiles = asList(changed.get("target_files")); + byte[] fileDecoded = + Base64.getDecoder().decode((String) asMap(targetFiles.get(0)).get("raw")); + byte[] newFile = Arrays.copyOf(fileDecoded, fileDecoded.length + 1); + newFile[fileDecoded.length] = '\n'; + asMap(targetFiles.get(0)).put("raw", Base64.getEncoder().encodeToString(newFile)); + Map targets = decodeTargets(changed.get("targets")); + Map targetEntry = + asMap(asMap(asMap(targets.get("signed")).get("targets")).get(configKey)); + asMap(targetEntry.get("hashes")).put("sha256", sha256(newFile).toString(16)); + targetEntry.put("length", asLong(targetEntry.get("length")) + 1); + changed.put("targets", signAndBase64EncodeTargets(targets)); + + stubHttp( + buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(toJson(changed))); + task.run(poller); + task.run(poller); + task.run(poller); + + // applied once on the first run (unchanged hash on the second), once again when the hash + // changed + verify(listener, times(2)).accept(eq(configKey), notNull(), any()); + } + + @Test + void configurationCannotBeAppliedWithoutHashes() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + String configKey = "employee/ASM_DD/1.recommended.json/config"; + + poller.addListener(Product.ASM_DD, parseDeserializer(), listener); + start(); + + Map noHashes = parseMap(SAMPLE_RESP_BODY); + Map targets = decodeTargets(noHashes.get("targets")); + asMap(asMap(asMap(asMap(targets.get("signed")).get("targets")).get(configKey)) + .get("hashes")) + .remove("sha256"); + noHashes.put("targets", signAndBase64EncodeTargets(targets)); + + stubHttp(buildOKResponse(toJson(noHashes)), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + verify(listener).accept(eq(configKey), any(), any()); + + Map state = clientState(parseBody()); + assertEquals(0, asList(state.get("config_states")).size()); + assertEquals("No sha256 hash present for " + configKey, state.get("error")); + assertEquals(0L, asLong(state.get("targets_version"))); + } + + @Test + void encodedFileIsNotValidBase64Data() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener(Product.ASM_DD, parseDeserializer(), listener); + start(); + + Map badBase64 = parseMap(SAMPLE_RESP_BODY); + List targetFiles = asList(badBase64.get("target_files")); + byte[] fileDecoded = + Base64.getDecoder().decode((String) asMap(targetFiles.get(0)).get("raw")); + asMap(targetFiles.get(0)).put("raw", Base64.getEncoder().encodeToString(fileDecoded) + "##"); + + stubHttp(buildOKResponse(toJson(badBase64)), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + verify(listener).accept(any(), any(), any()); + + Map state = clientState(parseBody()); + assertEquals( + "Could not get file contents from remote config, file employee/ASM_DD/1.recommended.json/config", + state.get("error")); + } + + @Test + void deserializerCanReturnNullToIndicateNoConfigShouldBeApplied() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener(Product.ASM_DD, content -> null, listener); + start(); + + stubHttp(buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + + verify(listener, never()).accept(any(), any(), any()); + } + + @Test + void rejectsConfigurationIfTheHashIsWrong() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + String configKey = "employee/ASM_DD/1.recommended.json/config"; + + poller.addListener(Product.ASM_DD, parseDeserializer(), listener); + start(); + + Map wrongHash = parseMap(SAMPLE_RESP_BODY); + Map targets = decodeTargets(wrongHash.get("targets")); + asMap(asMap(asMap(asMap(targets.get("signed")).get("targets")).get(configKey)) + .get("hashes")) + .put("sha256", "0"); + wrongHash.put("targets", signAndBase64EncodeTargets(targets)); + + stubHttp(buildOKResponse(toJson(wrongHash))); + task.run(poller); + + verify(listener, never()).accept(any(), any(), any()); + } + + @Test + void acceptsAnEmptyObjectAsAResponseToIndicateNoChanges() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + ConfigurationDeserializer deserializer = deserializerMock(); + + poller.addListener(Product.ASM_DD, deserializer, listener); + start(); + + stubHttp(buildOKResponse("{}")); + task.run(poller); + + verifyNoInteractions(deserializer); + verifyNoInteractions(listener); + } + + @Test + void acceptsHttp204AsAResponseToIndicateNoChanges() throws IOException { + Response resp = new Response.Builder() .request(REQUEST) .protocol(Protocol.HTTP_1_1) - .message("OK") - .body(ResponseBody.create(MediaType.get("text/plain"), SAMPLE_RESP_BODY)) - .code(200) - .build())); - } - - // autoCloseArguments disabled: a body-less okhttp Response throws on close() - @ParameterizedTest(name = "bad responses: {0}", autoCloseArguments = false) - @MethodSource("badResponsesArguments") - void badResponses(String scenario, Response resp) throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener(Product.ASM_DD, deserializerThrowing("should not be called"), listener); - start(); - - stubHttp(resp); - task.run(poller); - - // a single request is made and no configuration is applied - verify(okHttpClient, times(1)).newCall(any()); - verifyNoInteractions(listener); - } - - static Stream bodyDoesNotSatisfyFormatArguments() { - return Stream.of( - arguments("targets not a string", "{\"targets\": []}"), - arguments("targets not base64", "{\"targets\": \"ZZZZ=\"}"), - arguments( - "signed not an object", "{\"targets\": \"" + b64("{\"signed\": \"string\"}") + "\"}")); - } - - @ParameterizedTest(name = "body does not satisfy format: {0}") - @MethodSource("bodyDoesNotSatisfyFormatArguments") - void bodyDoesNotSatisfyFormat(String scenario, String bodyStr) throws IOException { - ConfigurationDeserializer deserializer = deserializerMock(); - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener(Product.ASM_DD, deserializer, listener); - start(); - - stubHttp(buildOKResponse(bodyStr)); - task.run(poller); - - verifyNoInteractions(deserializer); - verifyNoInteractions(listener); - } - - static Stream reportableErrorsArguments() { - // not a valid key - Map notValidKey = parseMap(SAMPLE_RESP_BODY); - asList(notValidKey.get("client_configs")).set(0, "foobar"); - - // no file for the given key - Map noFile = parseMap(SAMPLE_RESP_BODY); - noFile.put("target_files", list()); - - // two reportable errors - Map twoErrors = parseMap(SAMPLE_RESP_BODY); - twoErrors.put("client_configs", list("foobar", "employee/ASM_DD/1.recommended.json/config")); - twoErrors.put("target_files", list()); - - // in target_files, but not targets.signed.targets - Map notInTargets = parseMap(SAMPLE_RESP_BODY); - Map targetsNotInTargets = decodeTargets(notInTargets.get("targets")); - asMap(targetsNotInTargets.get("signed")).put("targets", map()); - notInTargets.put("targets", signAndBase64EncodeTargets(targetsNotInTargets)); - - // told to apply config that is not subscribed - Map notSubscribed = parseMap(SAMPLE_RESP_BODY); - notSubscribed.put( - "client_configs", - list("datadog/2/LIVE_DEBUGGING/1ba66cc9-146a-3479-9e66-2b63fd580f48/config")); - - // invalid signature - Map invalidSignature = parseMap(SAMPLE_RESP_BODY); - Map invalidSigTargets = decodeTargets(invalidSignature.get("targets")); - asMap(asList(invalidSigTargets.get("signatures")).get(0)) - .put( - "sig", - "59a6478aba87d171261e6995faaa8e36c95c3e75436c4e82f11ac625220e13b703ce9b912ee0731415121b5a47aa2abdb398a60656b7701b15e606c6327c880e"); - invalidSignature.put( - "targets", Base64.getEncoder().encodeToString(toJson(invalidSigTargets).getBytes(UTF_8))); - - // structurally invalid signature - Map structurallyInvalid = parseMap(SAMPLE_RESP_BODY); - Map structurallyInvalidTargets = - decodeTargets(structurallyInvalid.get("targets")); - asMap(asList(structurallyInvalidTargets.get("signatures")).get(0)).put("sig", repeat("a", 128)); - structurallyInvalid.put( - "targets", - Base64.getEncoder().encodeToString(toJson(structurallyInvalidTargets).getBytes(UTF_8))); - - return Stream.of( - arguments("not a valid key", toJson(notValidKey), "Not a valid config key: foobar"), - arguments( - "no file for key", - toJson(noFile), - "No content for employee/ASM_DD/1.recommended.json/config"), - arguments( - "two reportable errors", - toJson(twoErrors), - "Failed to apply configuration due to 2 errors:\n (1) Not a valid config key: foobar\n" - + " (2) No content for employee/ASM_DD/1.recommended.json/config\n"), - arguments( - "in target_files but not signed", - toJson(notInTargets), - "Path employee/ASM_DD/1.recommended.json/config is in target_files, but not in targets.signed"), - arguments( - "config not subscribed", - toJson(notSubscribed), - "Told to handle config key datadog/2/LIVE_DEBUGGING/1ba66cc9-146a-3479-9e66-2b63fd580f48/config," - + " but the product LIVE_DEBUGGING is not being handled"), - arguments( - "invalid signature", - toJson(invalidSignature), - "Signature verification failed for targets.signed. Key id: TEST_KEY_ID"), - arguments( - "structurally invalid signature", - toJson(structurallyInvalid), - "Error reading signature or canonicalizing targets.signed: Invalid scalar representation")); - } - - @ParameterizedTest(name = "reportable errors: {0}") - @MethodSource("reportableErrorsArguments") - void reportableErrors(String scenario, String bodyStr, String errorMsg) throws IOException { - poller.addListener( - Product.ASM_DD, - deserializerThrowing("should not be called"), - (configKey, config, hinter) -> {}); - start(); - - stubHttp(buildOKResponse(bodyStr), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - Map state = clientState(parseBody()); - assertTrue(asList(state.get("config_states")).isEmpty()); - assertEquals(Boolean.TRUE, state.get("has_error")); - assertEquals(errorMsg, state.get("error")); - } - - @Test - void reportsErrorDuringDeserialization() throws IOException { - poller.addListener( - Product.ASM_DD, - content -> { - throw new RuntimeException("my deserializer error"); - }, - (configKey, config, hinter) -> {}); - start(); - - stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - Map configState = - asMap(asList(clientState(parseBody()).get("config_states")).get(0)); - assertEquals(APPLY_STATE_ERROR, asInt(configState.get("apply_state"))); - assertEquals("my deserializer error", configState.get("apply_error")); - } - - @Test - void reportsErrorApplyingConfiguration() throws IOException { - poller.addListener( - Product.ASM_DD, - content -> true, - (configKey, config, hinter) -> { - throw new RuntimeException("error applying config"); + .message("No Content") + .body(ResponseBody.create(MediaType.parse("application/json"), "")) + .code(204) + .build(); + ConfigurationChangesTypedListener listener = typedListener(); + ConfigurationDeserializer deserializer = deserializerMock(); + + poller.addListener(Product.ASM_DD, deserializer, listener); + start(); + + stubHttp(resp); + task.run(poller); + + verifyNoInteractions(deserializer); + verifyNoInteractions(listener); + } + + @Test + void appliesAndRemoveCalledForProductListener() throws IOException { + ProductListener listener = mock(ProductListener.class); + String cfgWithoutAsm = cfgWithoutAsm(); + + poller.addListener(Product.ASM_DD, listener); + start(); + + stubHttp( + buildOKResponse(SAMPLE_RESP_BODY), + buildOKResponse(SAMPLE_RESP_BODY), + buildOKResponse(cfgWithoutAsm), + buildOKResponse(cfgWithoutAsm)); + task.run(poller); // accept + commit + task.run(poller); // no commit, no change + task.run(poller); // remove + commit + task.run(poller); // no commit, no change + + verify(listener, times(1)).accept(any(), notNull(), any()); + verify(listener, times(1)).remove(any(), any()); + verify(listener, times(2)).commit(any()); + } + + @Test + void unappliesConfigurationsItHasStoppedSeeing() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + String cfgWithoutAsm = cfgWithoutAsm(); + + poller.addListener(Product.ASM_DD, parseDeserializer(), listener); + start(); + + stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(cfgWithoutAsm), buildOKResponse(cfgWithoutAsm)); + task.run(poller); // accept with non-null config + task.run(poller); // unapply (accept with null config) + task.run(poller); // nothing more + + verify(listener, times(1)).accept(any(), notNull(), any()); + verify(listener, times(1)).accept(any(), isNull(), any()); + } + + @Test + void supportMultipleConfigurations() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + String recommended = "employee/ASM_DD/1.recommended.json/config"; + String suggested = "employee/ASM_DD/2.suggested.json/config"; + String multiConfigs = withClientConfigs(SAMPLE_RESP_BODY, list(recommended, suggested)); + String noConfigs = withClientConfigs(SAMPLE_RESP_BODY, list()); + + poller.addListener(Product.ASM_DD, parseDeserializer(), listener); + start(); + + stubHttp( + buildOKResponse(SAMPLE_RESP_BODY), // apply first configuration + buildOKResponse(multiConfigs), // apply second configuration + buildOKResponse(SAMPLE_RESP_BODY), // remove second configuration + buildOKResponse(noConfigs)); // remove all configurations + task.run(poller); + task.run(poller); + task.run(poller); + task.run(poller); + + verify(listener, times(1)).accept(eq(recommended), notNull(), any()); + verify(listener, times(1)).accept(eq(suggested), notNull(), any()); + verify(listener, times(1)).accept(eq(suggested), isNull(), any()); + verify(listener, times(1)).accept(eq(recommended), isNull(), any()); + } + + @Test + void exceptionApplyingOneConfigShouldNotPreventOthersFromBeingApplied() throws IOException { + String newConfigId = "1ba66cc9-146a-3479-9e66-2b63fd580f48"; + String newConfigKey = "datadog/2/LIVE_DEBUGGING/" + newConfigId + "/config"; + + poller.addListener(Product.ASM_DD, parseDeserializer(), (configKey, config, hinter) -> { + throw new RuntimeException("throw here"); }); - start(); - - stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); - task.run(poller); - task.run(poller); - - Map configState = - asMap(asList(clientState(parseBody()).get("config_states")).get(0)); - assertEquals(APPLY_STATE_ERROR, asInt(configState.get("apply_state"))); - assertEquals("error applying config", configState.get("apply_error")); - } - - @Test - void theMaxSizeIsExceeded() throws IOException { - ConfigurationDeserializer deserializer = deserializerMock(); - - poller.addListener( - Product.ASM_DD, deserializer, (configKey, config, hinter) -> fail("should not be called")); - start(); - - Map oversized = parseMap(SAMPLE_RESP_BODY); - asList(oversized.get("target_files")) - .add( - map( - "path", - "foo/bar", - "raw", - repeat("a", (int) Config.get().getRemoteConfigMaxPayloadSizeBytes()))); - - stubHttp(buildOKResponse(toJson(oversized))); - task.run(poller); - - verifyNoInteractions(deserializer); - } - - @Test - void canListenForChangesInALocalFile() throws IOException { - File file = Files.createTempFile(null, ".json").toFile(); - AtomicReference savedConf = new AtomicReference<>(); - - poller.addFileListener(file, parseDeserializer(), (path, conf, hinter) -> savedConf.set(conf)); - start(); - Files.write(file.toPath(), "{\"foo\":\"bar\"}".getBytes(UTF_8)); - - task.run(poller); - assertEquals("bar", asMap(savedConf.get()).get("foo")); - - file.delete(); - Files.write(file.toPath(), "{\"foo\":\"xpto\"}".getBytes(UTF_8)); - task.run(poller); - assertEquals("xpto", asMap(savedConf.get()).get("foo")); - - file.delete(); - } - - @Test - void distributesFeatures() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener); - start(); - - stubHttp(buildOKResponse(FEATURES_RESP_BODY)); - task.run(poller); - - verify(listener) - .accept( - any(), - cfgMatches(cfg -> Boolean.TRUE.equals(asMap(cfg.get("asm")).get("enabled"))), - any()); - } - - @Test - void distributesFeaturesUponSubscribing() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener( - Product.ASM_FEATURES, - deserializerThrowing("should not be called"), - (configKey, config, hinter) -> { - throw new RuntimeException("should not be called"); + poller.addListener(Product.LIVE_DEBUGGING, parseDeserializer(), (configKey, config, hinter) -> {}); + start(); + + Map withExtra = parseMap(SAMPLE_RESP_BODY); + asList(withExtra.get("client_configs")).add(newConfigKey); + Map targets = decodeTargets(withExtra.get("targets")); + asMap(asMap(targets.get("signed")).get("targets")) + .put( + newConfigKey, + map( + "custom", map("v", 3), + "hashes", + map( + "sha256", + "7a38bf81f383f69433ad6e900d35b3e2385593f76a7b7ab5d4355b8ba41ee24b"), + "length", "{\"foo\":\"bar\"}".length())); + asList(withExtra.get("target_files")).add(map("path", newConfigKey, "raw", b64("{\"foo\":\"bar\"}"))); + withExtra.put("targets", signAndBase64EncodeTargets(targets)); + + stubHttp(buildOKResponse(toJson(withExtra)), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + Map body = parseBody(); + List configStates = asList(clientState(body).get("config_states")); + assertEquals(2, configStates.size()); + Map liveDebuggingConfig = findConfigState(configStates, "LIVE_DEBUGGING"); + Map asmConfig = findConfigState(configStates, "ASM_DD"); + assertEquals(newConfigId, liveDebuggingConfig.get("id")); + assertEquals("LIVE_DEBUGGING", liveDebuggingConfig.get("product")); + assertEquals(3L, asLong(liveDebuggingConfig.get("version"))); + assertNull(liveDebuggingConfig.get("apply_error")); + assertEquals("1.recommended.json", asmConfig.get("id")); + assertEquals("ASM_DD", asmConfig.get("product")); + assertEquals(1L, asLong(asmConfig.get("version"))); + assertEquals(APPLY_STATE_ERROR, asInt(asmConfig.get("apply_state"))); + assertEquals("throw here", asmConfig.get("apply_error")); + } + + static Stream badResponsesArguments() { + return Stream.of( + arguments( + "404 with body", + new Response.Builder() + .request(REQUEST) + .protocol(Protocol.HTTP_1_1) + .message("Not Found") + .code(404) + .body(ResponseBody.create(MediaType.get("text/plain"), "not found!")) + .build()), + arguments( + "404 without body", + new Response.Builder() + .request(REQUEST) + .protocol(Protocol.HTTP_1_1) + .message("Not Found") + .code(404) + .build()), + arguments( + "success, no body", + new Response.Builder() + .request(REQUEST) + .protocol(Protocol.HTTP_1_1) + .message("Created") + .code(201) + .build()), + arguments( + "not json", + new Response.Builder() + .request(REQUEST) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .body(ResponseBody.create(MediaType.get("text/plain"), SAMPLE_RESP_BODY)) + .code(200) + .build())); + } + + // autoCloseArguments disabled: a body-less okhttp Response throws on close() + @ParameterizedTest(name = "bad responses: {0}", autoCloseArguments = false) + @MethodSource("badResponsesArguments") + void badResponses(String scenario, Response resp) throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener(Product.ASM_DD, deserializerThrowing("should not be called"), listener); + start(); + + stubHttp(resp); + task.run(poller); + + // a single request is made and no configuration is applied + verify(okHttpClient, times(1)).newCall(any()); + verifyNoInteractions(listener); + } + + static Stream bodyDoesNotSatisfyFormatArguments() { + return Stream.of( + arguments("targets not a string", "{\"targets\": []}"), + arguments("targets not base64", "{\"targets\": \"ZZZZ=\"}"), + arguments("signed not an object", "{\"targets\": \"" + b64("{\"signed\": \"string\"}") + "\"}")); + } + + @ParameterizedTest(name = "body does not satisfy format: {0}") + @MethodSource("bodyDoesNotSatisfyFormatArguments") + void bodyDoesNotSatisfyFormat(String scenario, String bodyStr) throws IOException { + ConfigurationDeserializer deserializer = deserializerMock(); + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener(Product.ASM_DD, deserializer, listener); + start(); + + stubHttp(buildOKResponse(bodyStr)); + task.run(poller); + + verifyNoInteractions(deserializer); + verifyNoInteractions(listener); + } + + static Stream reportableErrorsArguments() { + // not a valid key + Map notValidKey = parseMap(SAMPLE_RESP_BODY); + asList(notValidKey.get("client_configs")).set(0, "foobar"); + + // no file for the given key + Map noFile = parseMap(SAMPLE_RESP_BODY); + noFile.put("target_files", list()); + + // two reportable errors + Map twoErrors = parseMap(SAMPLE_RESP_BODY); + twoErrors.put("client_configs", list("foobar", "employee/ASM_DD/1.recommended.json/config")); + twoErrors.put("target_files", list()); + + // in target_files, but not targets.signed.targets + Map notInTargets = parseMap(SAMPLE_RESP_BODY); + Map targetsNotInTargets = decodeTargets(notInTargets.get("targets")); + asMap(targetsNotInTargets.get("signed")).put("targets", map()); + notInTargets.put("targets", signAndBase64EncodeTargets(targetsNotInTargets)); + + // told to apply config that is not subscribed + Map notSubscribed = parseMap(SAMPLE_RESP_BODY); + notSubscribed.put( + "client_configs", list("datadog/2/LIVE_DEBUGGING/1ba66cc9-146a-3479-9e66-2b63fd580f48/config")); + + // invalid signature + Map invalidSignature = parseMap(SAMPLE_RESP_BODY); + Map invalidSigTargets = decodeTargets(invalidSignature.get("targets")); + asMap(asList(invalidSigTargets.get("signatures")).get(0)) + .put( + "sig", + "59a6478aba87d171261e6995faaa8e36c95c3e75436c4e82f11ac625220e13b703ce9b912ee0731415121b5a47aa2abdb398a60656b7701b15e606c6327c880e"); + invalidSignature.put( + "targets", + Base64.getEncoder().encodeToString(toJson(invalidSigTargets).getBytes(UTF_8))); + + // structurally invalid signature + Map structurallyInvalid = parseMap(SAMPLE_RESP_BODY); + Map structurallyInvalidTargets = decodeTargets(structurallyInvalid.get("targets")); + asMap(asList(structurallyInvalidTargets.get("signatures")).get(0)).put("sig", repeat("a", 128)); + structurallyInvalid.put( + "targets", + Base64.getEncoder() + .encodeToString(toJson(structurallyInvalidTargets).getBytes(UTF_8))); + + return Stream.of( + arguments("not a valid key", toJson(notValidKey), "Not a valid config key: foobar"), + arguments( + "no file for key", toJson(noFile), "No content for employee/ASM_DD/1.recommended.json/config"), + arguments( + "two reportable errors", + toJson(twoErrors), + "Failed to apply configuration due to 2 errors:\n (1) Not a valid config key: foobar\n" + + " (2) No content for employee/ASM_DD/1.recommended.json/config\n"), + arguments( + "in target_files but not signed", + toJson(notInTargets), + "Path employee/ASM_DD/1.recommended.json/config is in target_files, but not in targets.signed"), + arguments( + "config not subscribed", + toJson(notSubscribed), + "Told to handle config key datadog/2/LIVE_DEBUGGING/1ba66cc9-146a-3479-9e66-2b63fd580f48/config," + + " but the product LIVE_DEBUGGING is not being handled"), + arguments( + "invalid signature", + toJson(invalidSignature), + "Signature verification failed for targets.signed. Key id: TEST_KEY_ID"), + arguments( + "structurally invalid signature", + toJson(structurallyInvalid), + "Error reading signature or canonicalizing targets.signed: Invalid scalar representation")); + } + + @ParameterizedTest(name = "reportable errors: {0}") + @MethodSource("reportableErrorsArguments") + void reportableErrors(String scenario, String bodyStr, String errorMsg) throws IOException { + poller.addListener( + Product.ASM_DD, deserializerThrowing("should not be called"), (configKey, config, hinter) -> {}); + start(); + + stubHttp(buildOKResponse(bodyStr), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + Map state = clientState(parseBody()); + assertTrue(asList(state.get("config_states")).isEmpty()); + assertEquals(Boolean.TRUE, state.get("has_error")); + assertEquals(errorMsg, state.get("error")); + } + + @Test + void reportsErrorDuringDeserialization() throws IOException { + poller.addListener( + Product.ASM_DD, + content -> { + throw new RuntimeException("my deserializer error"); + }, + (configKey, config, hinter) -> {}); + start(); + + stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + Map configState = + asMap(asList(clientState(parseBody()).get("config_states")).get(0)); + assertEquals(APPLY_STATE_ERROR, asInt(configState.get("apply_state"))); + assertEquals("my deserializer error", configState.get("apply_error")); + } + + @Test + void reportsErrorApplyingConfiguration() throws IOException { + poller.addListener(Product.ASM_DD, content -> true, (configKey, config, hinter) -> { + throw new RuntimeException("error applying config"); }); - start(); - - poller.removeListeners(Product.ASM_FEATURES); - - poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener); - stubHttp(buildOKResponse(FEATURES_RESP_BODY)); - task.run(poller); - - verify(listener) - .accept( - any(), - cfgMatches(cfg -> Boolean.TRUE.equals(asMap(cfg.get("asm")).get("enabled"))), - any()); - } - - @Test - void distributeConfigAcrossMultipleListenersForSameSubscriber() throws IOException { - ConfigurationChangesTypedListener listener1 = typedListener(); - ConfigurationChangesTypedListener listener2 = typedListener(); - - poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener1); - poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener2); - start(); - - stubHttp(buildOKResponse(FEATURES_RESP_BODY)); - task.run(poller); - - verify(listener1) - .accept( - any(), - cfgMatches(cfg -> Boolean.TRUE.equals(asMap(cfg.get("asm")).get("enabled"))), - any()); - verify(listener2) - .accept( - any(), - cfgMatches( - cfg -> - Double.valueOf(0.1) - .equals(asMap(cfg.get("api_security")).get("request_sample_rate"))), - any()); - } - - @Test - void errorApplyingFeatures() throws IOException { - AtomicBoolean called = new AtomicBoolean(false); - - poller.addListener( - Product.ASM_FEATURES, - content -> true, - (configKey, config, hinter) -> { - called.set(true); - throw new RuntimeException("throws"); + start(); + + stubHttp(buildOKResponse(SAMPLE_RESP_BODY), buildOKResponse(SAMPLE_RESP_BODY)); + task.run(poller); + task.run(poller); + + Map configState = + asMap(asList(clientState(parseBody()).get("config_states")).get(0)); + assertEquals(APPLY_STATE_ERROR, asInt(configState.get("apply_state"))); + assertEquals("error applying config", configState.get("apply_error")); + } + + @Test + void theMaxSizeIsExceeded() throws IOException { + ConfigurationDeserializer deserializer = deserializerMock(); + + poller.addListener(Product.ASM_DD, deserializer, (configKey, config, hinter) -> fail("should not be called")); + start(); + + Map oversized = parseMap(SAMPLE_RESP_BODY); + asList(oversized.get("target_files")).add(map("path", "foo/bar", "raw", repeat("a", (int) + Config.get().getRemoteConfigMaxPayloadSizeBytes()))); + + stubHttp(buildOKResponse(toJson(oversized))); + task.run(poller); + + verifyNoInteractions(deserializer); + } + + @Test + void canListenForChangesInALocalFile() throws IOException { + File file = Files.createTempFile(null, ".json").toFile(); + AtomicReference savedConf = new AtomicReference<>(); + + poller.addFileListener(file, parseDeserializer(), (path, conf, hinter) -> savedConf.set(conf)); + start(); + Files.write(file.toPath(), "{\"foo\":\"bar\"}".getBytes(UTF_8)); + + task.run(poller); + assertEquals("bar", asMap(savedConf.get()).get("foo")); + + file.delete(); + Files.write(file.toPath(), "{\"foo\":\"xpto\"}".getBytes(UTF_8)); + task.run(poller); + assertEquals("xpto", asMap(savedConf.get()).get("foo")); + + file.delete(); + } + + @Test + void distributesFeatures() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener); + start(); + + stubHttp(buildOKResponse(FEATURES_RESP_BODY)); + task.run(poller); + + verify(listener) + .accept( + any(), + cfgMatches( + cfg -> Boolean.TRUE.equals(asMap(cfg.get("asm")).get("enabled"))), + any()); + } + + @Test + void distributesFeaturesUponSubscribing() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener( + Product.ASM_FEATURES, deserializerThrowing("should not be called"), (configKey, config, hinter) -> { + throw new RuntimeException("should not be called"); + }); + start(); + + poller.removeListeners(Product.ASM_FEATURES); + + poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener); + stubHttp(buildOKResponse(FEATURES_RESP_BODY)); + task.run(poller); + + verify(listener) + .accept( + any(), + cfgMatches( + cfg -> Boolean.TRUE.equals(asMap(cfg.get("asm")).get("enabled"))), + any()); + } + + @Test + void distributeConfigAcrossMultipleListenersForSameSubscriber() throws IOException { + ConfigurationChangesTypedListener listener1 = typedListener(); + ConfigurationChangesTypedListener listener2 = typedListener(); + + poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener1); + poller.addListener(Product.ASM_FEATURES, parseDeserializer(), listener2); + start(); + + stubHttp(buildOKResponse(FEATURES_RESP_BODY)); + task.run(poller); + + verify(listener1) + .accept( + any(), + cfgMatches( + cfg -> Boolean.TRUE.equals(asMap(cfg.get("asm")).get("enabled"))), + any()); + verify(listener2) + .accept( + any(), + cfgMatches(cfg -> Double.valueOf(0.1) + .equals(asMap(cfg.get("api_security")).get("request_sample_rate"))), + any()); + } + + @Test + void errorApplyingFeatures() throws IOException { + AtomicBoolean called = new AtomicBoolean(false); + + poller.addListener(Product.ASM_FEATURES, content -> true, (configKey, config, hinter) -> { + called.set(true); + throw new RuntimeException("throws"); }); - start(); + start(); + + stubHttp(buildOKResponse(FEATURES_RESP_BODY)); + task.run(poller); + + assertTrue(called.get()); + // error does not escape + } + + @Test + void removingFeatureListeners() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener( + Product._UNKNOWN, deserializerThrowing("should not be called"), (configKey, config, hinter) -> { + throw new RuntimeException("should not be called"); + }); + poller.addListener(Product.ASM_FEATURES, content -> null, listener); + poller.removeListeners(Product.ASM_FEATURES); + start(); + + stubHttp(buildOKResponse(FEATURES_RESP_BODY)); + task.run(poller); + + verify(listener, never()).accept(any(), any(), any()); // listener is not called + + poller.removeListeners(Product._UNKNOWN); + task.run(poller); + + verify(okHttpClient, times(1)).newCall(any()); // not even a request is made the second time + } + + @Test + void checkSettingOfCapabilitiesNegativeTest() throws IOException { + ConfigurationChangesTypedListener listener = typedListener(); + + poller.addListener(Product._UNKNOWN, content -> true, listener); + start(); + + stubHttp(buildOKResponse(FEATURES_RESP_BODY)); + task.run(poller); + + List capabilities = asList(asMap(parseBody().get("client")).get("capabilities")); + assertEquals(0L, asLong(capabilities.get(0))); + } - stubHttp(buildOKResponse(FEATURES_RESP_BODY)); - task.run(poller); + static Stream checkSettingOfCapabilitiesPositiveTestArguments() { + return Stream.of( + arguments("zero", 0L, new byte[] {0}), + arguments("fourteen", 14L, new byte[] {14}), + arguments("1 << 8", 1L << 8, new byte[] {1, 0}), + arguments("1 << 9", 1L << 9, new byte[] {2, 0}), + arguments("long min plus one", -9223372036854775807L, new byte[] {(byte) 128, 0, 0, 0, 0, 0, 0, 1})); + } - assertTrue(called.get()); - // error does not escape - } + @ParameterizedTest(name = "check setting of capabilities positive test: {0}") + @MethodSource("checkSettingOfCapabilitiesPositiveTestArguments") + void checkSettingOfCapabilitiesPositiveTest(String scenario, long capabilities, byte[] encoded) throws IOException { + poller.addListener(Product._UNKNOWN, content -> true, (configKey, config, hinter) -> {}); + poller.addCapabilities(capabilities); + start(); + + stubHttp(buildOKResponse(FEATURES_RESP_BODY)); + task.run(poller); + + List capabilitiesList = asList(asMap(parseBody().get("client")).get("capabilities")); + byte[] actual = new byte[capabilitiesList.size()]; + for (int i = 0; i < actual.length; i++) { + actual[i] = (byte) asLong(capabilitiesList.get(i)); + } + assertArrayEquals(encoded, actual); + } - @Test - void removingFeatureListeners() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); + // ----- Helper methods ----- - poller.addListener( - Product._UNKNOWN, - deserializerThrowing("should not be called"), - (configKey, config, hinter) -> { - throw new RuntimeException("should not be called"); + private void start() { + when(scheduler.scheduleAtFixedRate(any(), eq(poller), eq(0L), eq((long) DEFAULT_POLL_PERIOD), eq(MILLISECONDS))) + .thenAnswer(invocation -> { + task = invocation.getArgument(0); + return scheduled; + }); + poller.start(); + assertNotNull(task); + } + + private void stubHttp(Response... responses) throws IOException { + when(okHttpClient.newCall(any(Request.class))).thenAnswer(invocation -> { + request = invocation.getArgument(0); + return call; }); - poller.addListener(Product.ASM_FEATURES, content -> null, listener); - poller.removeListeners(Product.ASM_FEATURES); - start(); - - stubHttp(buildOKResponse(FEATURES_RESP_BODY)); - task.run(poller); - - verify(listener, never()).accept(any(), any(), any()); // listener is not called - - poller.removeListeners(Product._UNKNOWN); - task.run(poller); - - verify(okHttpClient, times(1)).newCall(any()); // not even a request is made the second time - } - - @Test - void checkSettingOfCapabilitiesNegativeTest() throws IOException { - ConfigurationChangesTypedListener listener = typedListener(); - - poller.addListener(Product._UNKNOWN, content -> true, listener); - start(); - - stubHttp(buildOKResponse(FEATURES_RESP_BODY)); - task.run(poller); - - List capabilities = asList(asMap(parseBody().get("client")).get("capabilities")); - assertEquals(0L, asLong(capabilities.get(0))); - } - - static Stream checkSettingOfCapabilitiesPositiveTestArguments() { - return Stream.of( - arguments("zero", 0L, new byte[] {0}), - arguments("fourteen", 14L, new byte[] {14}), - arguments("1 << 8", 1L << 8, new byte[] {1, 0}), - arguments("1 << 9", 1L << 9, new byte[] {2, 0}), - arguments( - "long min plus one", - -9223372036854775807L, - new byte[] {(byte) 128, 0, 0, 0, 0, 0, 0, 1})); - } - - @ParameterizedTest(name = "check setting of capabilities positive test: {0}") - @MethodSource("checkSettingOfCapabilitiesPositiveTestArguments") - void checkSettingOfCapabilitiesPositiveTest(String scenario, long capabilities, byte[] encoded) - throws IOException { - poller.addListener(Product._UNKNOWN, content -> true, (configKey, config, hinter) -> {}); - poller.addCapabilities(capabilities); - start(); - - stubHttp(buildOKResponse(FEATURES_RESP_BODY)); - task.run(poller); - - List capabilitiesList = asList(asMap(parseBody().get("client")).get("capabilities")); - byte[] actual = new byte[capabilitiesList.size()]; - for (int i = 0; i < actual.length; i++) { - actual[i] = (byte) asLong(capabilitiesList.get(i)); - } - assertArrayEquals(encoded, actual); - } - - // ----- Helper methods ----- - - private void start() { - when(scheduler.scheduleAtFixedRate( - any(), eq(poller), eq(0L), eq((long) DEFAULT_POLL_PERIOD), eq(MILLISECONDS))) - .thenAnswer( - invocation -> { - task = invocation.getArgument(0); - return scheduled; - }); - poller.start(); - assertNotNull(task); - } - - private void stubHttp(Response... responses) throws IOException { - when(okHttpClient.newCall(any(Request.class))) - .thenAnswer( - invocation -> { - request = invocation.getArgument(0); - return call; - }); - OngoingStubbing stubbing = when(call.execute()); - for (Response response : responses) { - stubbing = stubbing.thenReturn(response); - } - } - - private String bodyJson() throws IOException { - Buffer buffer = new Buffer(); - request.body().writeTo(buffer); - return new String(buffer.readByteArray(), UTF_8); - } - - private Map parseBody() throws IOException { - return parseMap(bodyJson()); - } - - private static Map clientState(Map body) { - return asMap(asMap(body.get("client")).get("state")); - } - - private static Map findConfigState(List configStates, String product) { - for (Object configState : configStates) { - Map map = asMap(configState); - if (product.equals(map.get("product"))) { + OngoingStubbing stubbing = when(call.execute()); + for (Response response : responses) { + stubbing = stubbing.thenReturn(response); + } + } + + private String bodyJson() throws IOException { + Buffer buffer = new Buffer(); + request.body().writeTo(buffer); + return new String(buffer.readByteArray(), UTF_8); + } + + private Map parseBody() throws IOException { + return parseMap(bodyJson()); + } + + private static Map clientState(Map body) { + return asMap(asMap(body.get("client")).get("state")); + } + + private static Map findConfigState(List configStates, String product) { + for (Object configState : configStates) { + Map map = asMap(configState); + if (product.equals(map.get("product"))) { + return map; + } + } + return null; + } + + @SuppressWarnings("unchecked") + private static ConfigurationChangesTypedListener typedListener() { + return mock(ConfigurationChangesTypedListener.class); + } + + @SuppressWarnings("unchecked") + private static ConfigurationDeserializer deserializerMock() { + return mock(ConfigurationDeserializer.class); + } + + private static ConfigurationDeserializer parseDeserializer() { + return content -> parse(new String(content, UTF_8)); + } + + private static ConfigurationDeserializer deserializerThrowing(String message) { + return content -> { + throw new RuntimeException(message); + }; + } + + private static Object cfgMatches(Predicate> predicate) { + return argThat(arg -> arg != null && predicate.test(asMap(arg))); + } + + private static String cfgWithoutAsm() { + Map map = parseMap(SAMPLE_RESP_BODY); + map.put("client_configs", list()); + Map targets = decodeTargets(map.get("targets")); + asMap(asMap(asMap(targets.get("signed")).get("targets")).get("employee/ASM_DD/1.recommended.json/config")) + .put("hashes", map("sha256", "aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f")); + map.put("targets", signAndBase64EncodeTargets(targets)); + return toJson(map); + } + + private static String withClientConfigs(String body, List clientConfigs) { + Map map = parseMap(body); + map.put("client_configs", clientConfigs); + return toJson(map); + } + + private static Map emptyTarget() { + return map( + "custom", + map("v", 1), + "hashes", + map("sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + "length", + 0); + } + + private static String signAndBase64EncodeTargets(Map targets) { + Map targetsSigned = asMap(targets.get("signed")); + if (targetsSigned != null) { + byte[] canonicalTargetsSigned = JsonCanonicalizer.canonicalize(targetsSigned); + Ed25519Signature signature = PRIVATE_KEY.expand().sign(canonicalTargetsSigned, PUBLIC_KEY); + String sigBase16 = new BigInteger(1, signature.toByteArray()).toString(16); + targets.put("signatures", list(map("keyid", KEY_ID, "sig", sigBase16))); + } + return Base64.getEncoder().encodeToString(toJson(targets).getBytes(UTF_8)); + } + + private static Map decodeTargets(Object base64Targets) { + byte[] decoded = Base64.getDecoder().decode((String) base64Targets); + return parseMap(new String(decoded, UTF_8)); + } + + private static BigInteger sha256(byte[] bytes) { + try { + return new BigInteger(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String b64(String value) { + return Base64.getEncoder().encodeToString(value.getBytes(UTF_8)); + } + + private static String repeat(String value, int count) { + StringBuilder builder = new StringBuilder(value.length() * count); + for (int i = 0; i < count; i++) { + builder.append(value); + } + return builder.toString(); + } + + private static long asLong(Object value) { + return ((Number) value).longValue(); + } + + private static int asInt(Object value) { + return ((Number) value).intValue(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List asList(Object value) { + return (List) value; + } + + private static Map map(Object... keyValues) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], keyValues[i + 1]); + } return map; - } - } - return null; - } - - @SuppressWarnings("unchecked") - private static ConfigurationChangesTypedListener typedListener() { - return mock(ConfigurationChangesTypedListener.class); - } - - @SuppressWarnings("unchecked") - private static ConfigurationDeserializer deserializerMock() { - return mock(ConfigurationDeserializer.class); - } - - private static ConfigurationDeserializer parseDeserializer() { - return content -> parse(new String(content, UTF_8)); - } - - private static ConfigurationDeserializer deserializerThrowing(String message) { - return content -> { - throw new RuntimeException(message); - }; - } - - private static Object cfgMatches(Predicate> predicate) { - return argThat(arg -> arg != null && predicate.test(asMap(arg))); - } - - private static String cfgWithoutAsm() { - Map map = parseMap(SAMPLE_RESP_BODY); - map.put("client_configs", list()); - Map targets = decodeTargets(map.get("targets")); - asMap( - asMap(asMap(targets.get("signed")).get("targets")) - .get("employee/ASM_DD/1.recommended.json/config")) - .put( - "hashes", - map("sha256", "aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f")); - map.put("targets", signAndBase64EncodeTargets(targets)); - return toJson(map); - } - - private static String withClientConfigs(String body, List clientConfigs) { - Map map = parseMap(body); - map.put("client_configs", clientConfigs); - return toJson(map); - } - - private static Map emptyTarget() { - return map( - "custom", - map("v", 1), - "hashes", - map("sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), - "length", - 0); - } - - private static String signAndBase64EncodeTargets(Map targets) { - Map targetsSigned = asMap(targets.get("signed")); - if (targetsSigned != null) { - byte[] canonicalTargetsSigned = JsonCanonicalizer.canonicalize(targetsSigned); - Ed25519Signature signature = PRIVATE_KEY.expand().sign(canonicalTargetsSigned, PUBLIC_KEY); - String sigBase16 = new BigInteger(1, signature.toByteArray()).toString(16); - targets.put("signatures", list(map("keyid", KEY_ID, "sig", sigBase16))); - } - return Base64.getEncoder().encodeToString(toJson(targets).getBytes(UTF_8)); - } - - private static Map decodeTargets(Object base64Targets) { - byte[] decoded = Base64.getDecoder().decode((String) base64Targets); - return parseMap(new String(decoded, UTF_8)); - } - - private static BigInteger sha256(byte[] bytes) { - try { - return new BigInteger(MessageDigest.getInstance("SHA-256").digest(bytes)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private static String b64(String value) { - return Base64.getEncoder().encodeToString(value.getBytes(UTF_8)); - } - - private static String repeat(String value, int count) { - StringBuilder builder = new StringBuilder(value.length() * count); - for (int i = 0; i < count; i++) { - builder.append(value); - } - return builder.toString(); - } - - private static long asLong(Object value) { - return ((Number) value).longValue(); - } - - private static int asInt(Object value) { - return ((Number) value).intValue(); - } - - @SuppressWarnings("unchecked") - private static Map asMap(Object value) { - return (Map) value; - } - - @SuppressWarnings("unchecked") - private static List asList(Object value) { - return (List) value; - } - - private static Map map(Object... keyValues) { - Map map = new LinkedHashMap<>(); - for (int i = 0; i < keyValues.length; i += 2) { - map.put((String) keyValues[i], keyValues[i + 1]); - } - return map; - } - - private static List list(Object... items) { - return new ArrayList<>(Arrays.asList(items)); - } - - private static Object parse(String json) { - try { - return MOSHI.adapter(Object.class).fromJson(json); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private static Map parseMap(String json) { - return asMap(parse(json)); - } - - private static String toJson(Object value) { - return MOSHI.adapter(Object.class).toJson(value); - } - - private static final String SAMPLE_APPSEC_CONFIG = - "\n" - + "{\n" - + " \"version\": \"2.2\",\n" - + " \"metadata\": {\n" - + " \"rules_version\": \"1.3.1\"\n" - + " },\n" - + " \"rules\": [\n" - + " {\n" - + " \"id\": \"crs-913-110\",\n" - + " \"name\": \"Acunetix\",\n" - + " \"tags\": {\n" - + " \"type\": \"security_scanner\",\n" - + " \"crs_id\": \"913110\",\n" - + " \"category\": \"attack_attempt\"\n" - + " },\n" - + " \"conditions\": [\n" - + " {\n" - + " \"parameters\": {\n" - + " \"inputs\": [\n" - + " {\n" - + " \"address\": \"server.request.headers.no_cookies\"\n" - + " }\n" - + " ],\n" - + " \"list\": [\n" - + " \"acunetix-product\"\n" - + " ]\n" - + " },\n" - + " \"operator\": \"phrase_match\"\n" - + " }\n" - + " ],\n" - + " \"transformers\": [\n" - + " \"lowercase\"\n" - + " ]\n" - + " }\n" - + " ]\n" - + "}\n"; - - private static final String SAMPLE_TARGETS = - "\n" - + "{\n" - + " \"signatures\" : [\n" - + " {\n" - + " \"keyid\" : \"5c4ece41241a1bb513f6e3e5df74ab7d5183dfffbd71bfd43127920d880569fd\",\n" - + " \"sig\" : \"766871ed1acc60ef35f9f24262682283a55e79334f5154486176033b67568aed82fe8139a1f78689b96473537f0a2e55c8365d50bff345ea9ac350d57b90390d\"\n" - + " }\n" - + " ],\n" - + " \"signed\" : {\n" - + " \"_type\" : \"targets\",\n" - + " \"custom\" : {\n" - + " \"opaque_backend_state\" : \"foobar\"\n" - + " },\n" - + " \"expires\" : \"2022-09-17T12:49:15Z\",\n" - + " \"spec_version\" : \"1.0.0\",\n" - + " \"targets\" : {\n" - + " \"employee/ASM_DD/1.recommended.json/config\" : {\n" - + " \"custom\" : {\n" - + " \"v\" : 1\n" - + " },\n" - + " \"hashes\" : {\n" - + " \"sha256\" : \"6302258236e6051216b950583ec7136d946b463c17cbe64384ba5d566324819\"\n" - + " },\n" - + " \"length\" : 919\n" - + " },\n" - + " \"employee/ASM_DD/2.suggested.json/config\" : {\n" - + " \"custom\" : {\n" - + " \"v\" : 1\n" - + " },\n" - + " \"hashes\" : {\n" - + " \"sha256\" : \"6302258236e6051216b950583ec7136d946b463c17cbe64384ba5d566324819\"\n" - + " },\n" - + " \"length\" : 919\n" - + " },\n" - + " \"employee/CWS_DD/2.default.policy/config\" : {\n" - + " \"custom\" : {\n" - + " \"v\" : 2\n" - + " },\n" - + " \"hashes\" : {\n" - + " \"sha256\" : \"2f075fcaa9bdfc96bfc30d5a18711fbebf59d2cb3f3b5258d41ebdc1b1a54569\"\n" - + " },\n" - + " \"length\" : 34805\n" - + " }\n" - + " },\n" - + " \"version\" : 23337393\n" - + " }\n" - + "}\n"; - - private static final String SAMPLE_RESP_BODY = - "{\n" - + " \"client_configs\" : [\n" - + " \"employee/ASM_DD/1.recommended.json/config\"\n" - + " ],\n" - + " \"roots\" : [],\n" - + " \"target_files\" : [\n" - + " {\n" - + " \"path\" : \"employee/ASM_DD/1.recommended.json/config\",\n" - + " \"raw\" : \"" - + b64(SAMPLE_APPSEC_CONFIG) - + "\"\n" - + " },\n" - + " {\n" - + " \"path\" : \"employee/ASM_DD/2.suggested.json/config\",\n" - + " \"raw\" : \"" - + b64(SAMPLE_APPSEC_CONFIG) - + "\"\n" - + " }\n" - + " ],\n" - + " \"targets\" : \"" - + signAndBase64EncodeTargets(SAMPLE_TARGETS) - + "\"\n" - + "}\n"; - - private static final String FEATURES_RESP_BODY = - toJson( - map( - "client_configs", list("datadog/2/ASM_FEATURES/asm_features_activation/config"), - "roots", list(), - "target_files", - list( - map( - "path", - "datadog/2/ASM_FEATURES/asm_features_activation/config", - "raw", - b64( - "{\"asm\":{\"enabled\":true},\"api_security\":{\"request_sample_rate\":0.1}}"))), - "targets", - signAndBase64EncodeTargets( - map( - "signed", - map( - "expires", - "2022-09-17T12:49:15Z", - "spec_version", - "1.0.0", - "targets", - map( - "datadog/2/ASM_FEATURES/asm_features_activation/config", - map( - "custom", map("v", 1), - "hashes", - map( - "sha256", - "b01cb68f140fbfb7a2bb0ce39a473aa59c33664aca0c871cf07b8f4e09e3e360"), - "length", 67)), - "version", - 23337393))))); - - private static String signAndBase64EncodeTargets(String targetsJson) { - return signAndBase64EncodeTargets(parseMap(targetsJson)); - } + } + + private static List list(Object... items) { + return new ArrayList<>(Arrays.asList(items)); + } + + private static Object parse(String json) { + try { + return MOSHI.adapter(Object.class).fromJson(json); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static Map parseMap(String json) { + return asMap(parse(json)); + } + + private static String toJson(Object value) { + return MOSHI.adapter(Object.class).toJson(value); + } + + private static final String SAMPLE_APPSEC_CONFIG = "\n" + + "{\n" + + " \"version\": \"2.2\",\n" + + " \"metadata\": {\n" + + " \"rules_version\": \"1.3.1\"\n" + + " },\n" + + " \"rules\": [\n" + + " {\n" + + " \"id\": \"crs-913-110\",\n" + + " \"name\": \"Acunetix\",\n" + + " \"tags\": {\n" + + " \"type\": \"security_scanner\",\n" + + " \"crs_id\": \"913110\",\n" + + " \"category\": \"attack_attempt\"\n" + + " },\n" + + " \"conditions\": [\n" + + " {\n" + + " \"parameters\": {\n" + + " \"inputs\": [\n" + + " {\n" + + " \"address\": \"server.request.headers.no_cookies\"\n" + + " }\n" + + " ],\n" + + " \"list\": [\n" + + " \"acunetix-product\"\n" + + " ]\n" + + " },\n" + + " \"operator\": \"phrase_match\"\n" + + " }\n" + + " ],\n" + + " \"transformers\": [\n" + + " \"lowercase\"\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}\n"; + + private static final String SAMPLE_TARGETS = "\n" + + "{\n" + + " \"signatures\" : [\n" + + " {\n" + + " \"keyid\" : \"5c4ece41241a1bb513f6e3e5df74ab7d5183dfffbd71bfd43127920d880569fd\",\n" + + " \"sig\" : \"766871ed1acc60ef35f9f24262682283a55e79334f5154486176033b67568aed82fe8139a1f78689b96473537f0a2e55c8365d50bff345ea9ac350d57b90390d\"\n" + + " }\n" + + " ],\n" + + " \"signed\" : {\n" + + " \"_type\" : \"targets\",\n" + + " \"custom\" : {\n" + + " \"opaque_backend_state\" : \"foobar\"\n" + + " },\n" + + " \"expires\" : \"2022-09-17T12:49:15Z\",\n" + + " \"spec_version\" : \"1.0.0\",\n" + + " \"targets\" : {\n" + + " \"employee/ASM_DD/1.recommended.json/config\" : {\n" + + " \"custom\" : {\n" + + " \"v\" : 1\n" + + " },\n" + + " \"hashes\" : {\n" + + " \"sha256\" : \"6302258236e6051216b950583ec7136d946b463c17cbe64384ba5d566324819\"\n" + + " },\n" + + " \"length\" : 919\n" + + " },\n" + + " \"employee/ASM_DD/2.suggested.json/config\" : {\n" + + " \"custom\" : {\n" + + " \"v\" : 1\n" + + " },\n" + + " \"hashes\" : {\n" + + " \"sha256\" : \"6302258236e6051216b950583ec7136d946b463c17cbe64384ba5d566324819\"\n" + + " },\n" + + " \"length\" : 919\n" + + " },\n" + + " \"employee/CWS_DD/2.default.policy/config\" : {\n" + + " \"custom\" : {\n" + + " \"v\" : 2\n" + + " },\n" + + " \"hashes\" : {\n" + + " \"sha256\" : \"2f075fcaa9bdfc96bfc30d5a18711fbebf59d2cb3f3b5258d41ebdc1b1a54569\"\n" + + " },\n" + + " \"length\" : 34805\n" + + " }\n" + + " },\n" + + " \"version\" : 23337393\n" + + " }\n" + + "}\n"; + + private static final String SAMPLE_RESP_BODY = "{\n" + + " \"client_configs\" : [\n" + + " \"employee/ASM_DD/1.recommended.json/config\"\n" + + " ],\n" + + " \"roots\" : [],\n" + + " \"target_files\" : [\n" + + " {\n" + + " \"path\" : \"employee/ASM_DD/1.recommended.json/config\",\n" + + " \"raw\" : \"" + + b64(SAMPLE_APPSEC_CONFIG) + + "\"\n" + + " },\n" + + " {\n" + + " \"path\" : \"employee/ASM_DD/2.suggested.json/config\",\n" + + " \"raw\" : \"" + + b64(SAMPLE_APPSEC_CONFIG) + + "\"\n" + + " }\n" + + " ],\n" + + " \"targets\" : \"" + + signAndBase64EncodeTargets(SAMPLE_TARGETS) + + "\"\n" + + "}\n"; + + private static final String FEATURES_RESP_BODY = toJson(map( + "client_configs", list("datadog/2/ASM_FEATURES/asm_features_activation/config"), + "roots", list(), + "target_files", + list(map( + "path", + "datadog/2/ASM_FEATURES/asm_features_activation/config", + "raw", + b64("{\"asm\":{\"enabled\":true},\"api_security\":{\"request_sample_rate\":0.1}}"))), + "targets", + signAndBase64EncodeTargets(map( + "signed", + map( + "expires", + "2022-09-17T12:49:15Z", + "spec_version", + "1.0.0", + "targets", + map( + "datadog/2/ASM_FEATURES/asm_features_activation/config", + map( + "custom", map("v", 1), + "hashes", + map( + "sha256", + "b01cb68f140fbfb7a2bb0ce39a473aa59c33664aca0c871cf07b8f4e09e3e360"), + "length", 67)), + "version", + 23337393))))); + + private static String signAndBase64EncodeTargets(String targetsJson) { + return signAndBase64EncodeTargets(parseMap(targetsJson)); + } } diff --git a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/JsonCanonicalizerTests.java b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/JsonCanonicalizerTests.java index 48bf459b521..a36e67ee26b 100644 Binary files a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/JsonCanonicalizerTests.java and b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/JsonCanonicalizerTests.java differ diff --git a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/PollerRequestFactoryTest.java b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/PollerRequestFactoryTest.java index 9d5280e4783..332188566a1 100644 --- a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/PollerRequestFactoryTest.java +++ b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/PollerRequestFactoryTest.java @@ -25,99 +25,98 @@ class PollerRequestFactoryTest extends DDJavaSpecification { - static final String TRACER_VERSION = "v1.2.3"; - static final String CONTAINER_ID = "456"; - static final String ENTITY_ID = "32423"; - static final String INVALID_REMOTE_CONFIG_URL = "https://invalid.example.com/"; + static final String TRACER_VERSION = "v1.2.3"; + static final String CONTAINER_ID = "456"; + static final String ENTITY_ID = "32423"; + static final String INVALID_REMOTE_CONFIG_URL = "https://invalid.example.com/"; - @Test - @WithConfig(key = "service", value = "Service Name") - @WithConfig(key = "env", value = "PROD") - @WithConfig(key = "tags", value = "version:1.0.0-SNAPSHOT") - @WithConfig( - key = "trace.global.tags", - value = - Tags.GIT_REPOSITORY_URL - + ":https://github.com/DataDog/dd-trace-java," - + Tags.GIT_COMMIT_SHA - + ":1234") - void remoteConfigRequestFieldsBeenSanitized() { - PollerRequestFactory factory = - new PollerRequestFactory( - Config.get(), TRACER_VERSION, CONTAINER_ID, ENTITY_ID, INVALID_REMOTE_CONFIG_URL, null); + @Test + @WithConfig(key = "service", value = "Service Name") + @WithConfig(key = "env", value = "PROD") + @WithConfig(key = "tags", value = "version:1.0.0-SNAPSHOT") + @WithConfig( + key = "trace.global.tags", + value = Tags.GIT_REPOSITORY_URL + + ":https://github.com/DataDog/dd-trace-java," + + Tags.GIT_COMMIT_SHA + + ":1234") + void remoteConfigRequestFieldsBeenSanitized() { + PollerRequestFactory factory = new PollerRequestFactory( + Config.get(), TRACER_VERSION, CONTAINER_ID, ENTITY_ID, INVALID_REMOTE_CONFIG_URL, null); - RemoteConfigRequest request = - factory.buildRemoteConfigRequest( - Collections.singletonList("ASM"), null, null, 0, ServiceNameCollector.get()); + RemoteConfigRequest request = factory.buildRemoteConfigRequest( + Collections.singletonList("ASM"), null, null, 0, ServiceNameCollector.get()); - RemoteConfigRequest.ClientInfo.TracerInfo tracerInfo = request.getClient().getTracerInfo(); - assertEquals("service_name", tracerInfo.getServiceName()); - assertEquals("prod", tracerInfo.getServiceEnv()); - assertEquals("1.0.0-snapshot", tracerInfo.getServiceVersion()); - assertTrue(tracerInfo.getTags().contains("env:PROD")); - assertTrue( - tracerInfo - .getTags() - .contains(Tags.GIT_REPOSITORY_URL + ":https://github.com/DataDog/dd-trace-java")); - assertTrue(tracerInfo.getTags().contains(Tags.GIT_COMMIT_SHA + ":1234")); - } - - @Test - @WithConfig(key = "service", value = "Service Name") - @WithConfig(key = "env", value = "PROD") - @WithConfig(key = "tags", value = "version:1.0.0-SNAPSHOT") - void remoteConfigRequestExtraServices() { - String extraService = "fakeExtraService"; - ServiceNameCollector extraServicesProvider = ServiceNameCollector.get(); - extraServicesProvider.clear(); - extraServicesProvider.addService(extraService); - PollerRequestFactory factory = - new PollerRequestFactory( - Config.get(), TRACER_VERSION, CONTAINER_ID, ENTITY_ID, INVALID_REMOTE_CONFIG_URL, null); + RemoteConfigRequest.ClientInfo.TracerInfo tracerInfo = + request.getClient().getTracerInfo(); + assertEquals("service_name", tracerInfo.getServiceName()); + assertEquals("prod", tracerInfo.getServiceEnv()); + assertEquals("1.0.0-snapshot", tracerInfo.getServiceVersion()); + assertTrue(tracerInfo.getTags().contains("env:PROD")); + assertTrue( + tracerInfo.getTags().contains(Tags.GIT_REPOSITORY_URL + ":https://github.com/DataDog/dd-trace-java")); + assertTrue(tracerInfo.getTags().contains(Tags.GIT_COMMIT_SHA + ":1234")); + } - RemoteConfigRequest request = - factory.buildRemoteConfigRequest( - Collections.singletonList("ASM"), null, null, 0, extraServicesProvider); + @Test + @WithConfig(key = "service", value = "Service Name") + @WithConfig(key = "env", value = "PROD") + @WithConfig(key = "tags", value = "version:1.0.0-SNAPSHOT") + void remoteConfigRequestExtraServices() { + String extraService = "fakeExtraService"; + ServiceNameCollector extraServicesProvider = ServiceNameCollector.get(); + extraServicesProvider.clear(); + extraServicesProvider.addService(extraService); + PollerRequestFactory factory = new PollerRequestFactory( + Config.get(), TRACER_VERSION, CONTAINER_ID, ENTITY_ID, INVALID_REMOTE_CONFIG_URL, null); - assertTrue(request.getClient().getTracerInfo().getExtraServices().contains(extraService)); - } + RemoteConfigRequest request = factory.buildRemoteConfigRequest( + Collections.singletonList("ASM"), null, null, 0, extraServicesProvider); - @ParameterizedTest(name = "remote config provides process tags when enabled = {0}") - @ValueSource(booleans = {true, false}) - void remoteConfigProvidesProcessTagsWhenEnabled(boolean enabled) throws Exception { - if (!enabled) { - injectSysConfig(EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "false"); + assertTrue(request.getClient().getTracerInfo().getExtraServices().contains(extraService)); } - ProcessTags.reset(Config.get()); - PollerRequestFactory factory = - new PollerRequestFactory( - Config.get(), TRACER_VERSION, CONTAINER_ID, ENTITY_ID, INVALID_REMOTE_CONFIG_URL, null); - RemoteConfigRequest request = - factory.buildRemoteConfigRequest( - Collections.singletonList("ASM"), null, null, 0, ServiceNameCollector.get()); - String json = new Moshi.Builder().build().adapter(RemoteConfigRequest.class).toJson(request); + @ParameterizedTest(name = "remote config provides process tags when enabled = {0}") + @ValueSource(booleans = {true, false}) + void remoteConfigProvidesProcessTagsWhenEnabled(boolean enabled) throws Exception { + if (!enabled) { + injectSysConfig(EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "false"); + } + ProcessTags.reset(Config.get()); + PollerRequestFactory factory = new PollerRequestFactory( + Config.get(), TRACER_VERSION, CONTAINER_ID, ENTITY_ID, INVALID_REMOTE_CONFIG_URL, null); + + RemoteConfigRequest request = factory.buildRemoteConfigRequest( + Collections.singletonList("ASM"), null, null, 0, ServiceNameCollector.get()); + String json = + new Moshi.Builder().build().adapter(RemoteConfigRequest.class).toJson(request); - List processTags = request.getClient().getTracerInfo().getProcessTags(); - String entrypointName = findMatching(processTags, "entrypoint.name:.+"); - String workingDir = findMatching(processTags, "entrypoint.workdir:.+"); + List processTags = request.getClient().getTracerInfo().getProcessTags(); + String entrypointName = findMatching(processTags, "entrypoint.name:.+"); + String workingDir = findMatching(processTags, "entrypoint.workdir:.+"); - if (enabled) { - assertNotNull(workingDir); - assertNotNull(entrypointName); - assertThatJson(json).node("client.client_tracer.process_tags").isArray().isNotEmpty(); - } else { - assertNull(workingDir); - assertNull(entrypointName); - assertThatJson(json).node("client.client_tracer.process_tags").isAbsent(); + if (enabled) { + assertNotNull(workingDir); + assertNotNull(entrypointName); + assertThatJson(json) + .node("client.client_tracer.process_tags") + .isArray() + .isNotEmpty(); + } else { + assertNull(workingDir); + assertNull(entrypointName); + assertThatJson(json).node("client.client_tracer.process_tags").isAbsent(); + } } - } - private static String findMatching(List tags, String regex) { - if (tags == null) { - return null; + private static String findMatching(List tags, String regex) { + if (tags == null) { + return null; + } + Pattern pattern = Pattern.compile(regex); + return tags.stream() + .filter(tag -> pattern.matcher(tag).find()) + .findFirst() + .orElse(null); } - Pattern pattern = Pattern.compile(regex); - return tags.stream().filter(tag -> pattern.matcher(tag).find()).findFirst().orElse(null); - } } diff --git a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/Rcte1TestVectorsSpecification.java b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/Rcte1TestVectorsSpecification.java index 8cd8b03d3ed..2e0c08cec4c 100644 --- a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/Rcte1TestVectorsSpecification.java +++ b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/Rcte1TestVectorsSpecification.java @@ -34,137 +34,125 @@ import org.junit.jupiter.api.Test; import org.tabletest.junit.TableTest; -@WithConfig( - key = "rc.targets.key.id", - value = "ed7672c9a24abda78872ee32ee71c7cb1d5235e8db4ecbf1ca28b9c50eb75d9e") -@WithConfig( - key = "rc.targets.key", - value = "7d3102e39abe71044d207550bda239c71380d013ec5a115f79f51622630054e6") +@WithConfig(key = "rc.targets.key.id", value = "ed7672c9a24abda78872ee32ee71c7cb1d5235e8db4ecbf1ca28b9c50eb75d9e") +@WithConfig(key = "rc.targets.key", value = "7d3102e39abe71044d207550bda239c71380d013ec5a115f79f51622630054e6") @WithConfig(key = "remote_config.integrity_check.enabled", value = "true") class Rcte1TestVectorsSpecification extends DDJavaSpecification { - private static final int DEFAULT_POLL_PERIOD = 5000; - - private static final HttpUrl URL = HttpUrl.get("https://example.com/v0.7/config"); - private static final Request REQUEST = new Request.Builder().url("https://example.com").build(); - - private final Moshi moshi = new Moshi.Builder().build(); - - private final OkHttpClient okHttpClient = mock(OkHttpClient.class); - private final AgentTaskScheduler scheduler = mock(AgentTaskScheduler.class); - - @SuppressWarnings("unchecked") - private final AgentTaskScheduler.Scheduled scheduled = - mock(AgentTaskScheduler.Scheduled.class); - - private final Call call = mock(Call.class); - - private ConfigurationPoller poller; - private AgentTaskScheduler.Task task; - private Request request; - - private Response buildOKResponse(String bodyStr) { - ResponseBody body = ResponseBody.create(MediaType.get("application/json"), bodyStr); - return new Response.Builder() - .request(REQUEST) - .protocol(Protocol.HTTP_1_1) - .message("OK") - .body(body) - .code(200) - .build(); - } - - @BeforeEach - void setup() { - Supplier urlSupplier = URL::toString; - poller = - new DefaultConfigurationPoller( - Config.get(), "0.0.0", "containerid", "entityid", urlSupplier, okHttpClient, scheduler); - } - - private static String getFileContents(String baseFileName) throws IOException { - return new String( - Files.readAllBytes(Paths.get("src/test/resources/rcte1/" + baseFileName + ".json")), - StandardCharsets.UTF_8); - } - - private static String bodyToString(RequestBody body) throws IOException { - Buffer buffer = new Buffer(); - body.writeTo(buffer); - return buffer.readUtf8(); - } - - private void stubScheduling() { - when(scheduler.scheduleAtFixedRate( - any(), eq(poller), eq(0L), eq((long) DEFAULT_POLL_PERIOD), eq(TimeUnit.MILLISECONDS))) - .thenAnswer( - invocation -> { - task = invocation.getArgument(0); - return scheduled; - }); - } - - @Test - void validFile() throws IOException { - AtomicReference savedConfig = new AtomicReference<>(); - ConfigurationDeserializer deserializer = - content -> - moshi.adapter(Object.class).fromJson(new String(content, StandardCharsets.UTF_8)); - ConfigurationChangesTypedListener listener = - (configKey, config, hinter) -> savedConfig.set(config); - poller.addListener(Product.ASM_DD, deserializer, listener); - - stubScheduling(); - poller.start(); - assertNotNull(task); - - when(okHttpClient.newCall(any(Request.class))) - .thenAnswer( - invocation -> { - request = invocation.getArgument(0); - return call; - }); - when(call.execute()).thenReturn(buildOKResponse(getFileContents("validOneFile"))); - - task.run(poller); - - assertNotNull(savedConfig.get()); - } - - @TableTest({ - "scenario | baseFileName | message ", - "invalid signing key | targetsSignedWithInvalidKey | 'Missing signature for key ed7672c9a24abda78872ee32ee71c7cb1d5235e8db4ecbf1ca28b9c50eb75d9e'", - "invalid signature | tufTargetsInvalidSignature | 'Signature verification failed for targets.signed' ", - "invalid file hash | tufTargetsInvalidTargetFileHash | 'does not have the expected sha256 hash' ", - "missing target file | tufTargetsMissingTargetFile | 'is in target_files, but not in targets.signed' " - }) - void invalidFile(String baseFileName, String message) throws IOException { - ConfigurationDeserializer deserializer = - content -> { - fail("should never be called"); - return null; + private static final int DEFAULT_POLL_PERIOD = 5000; + + private static final HttpUrl URL = HttpUrl.get("https://example.com/v0.7/config"); + private static final Request REQUEST = + new Request.Builder().url("https://example.com").build(); + + private final Moshi moshi = new Moshi.Builder().build(); + + private final OkHttpClient okHttpClient = mock(OkHttpClient.class); + private final AgentTaskScheduler scheduler = mock(AgentTaskScheduler.class); + + @SuppressWarnings("unchecked") + private final AgentTaskScheduler.Scheduled scheduled = + mock(AgentTaskScheduler.Scheduled.class); + + private final Call call = mock(Call.class); + + private ConfigurationPoller poller; + private AgentTaskScheduler.Task task; + private Request request; + + private Response buildOKResponse(String bodyStr) { + ResponseBody body = ResponseBody.create(MediaType.get("application/json"), bodyStr); + return new Response.Builder() + .request(REQUEST) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .body(body) + .code(200) + .build(); + } + + @BeforeEach + void setup() { + Supplier urlSupplier = URL::toString; + poller = new DefaultConfigurationPoller( + Config.get(), "0.0.0", "containerid", "entityid", urlSupplier, okHttpClient, scheduler); + } + + private static String getFileContents(String baseFileName) throws IOException { + return new String( + Files.readAllBytes(Paths.get("src/test/resources/rcte1/" + baseFileName + ".json")), + StandardCharsets.UTF_8); + } + + private static String bodyToString(RequestBody body) throws IOException { + Buffer buffer = new Buffer(); + body.writeTo(buffer); + return buffer.readUtf8(); + } + + private void stubScheduling() { + when(scheduler.scheduleAtFixedRate( + any(), eq(poller), eq(0L), eq((long) DEFAULT_POLL_PERIOD), eq(TimeUnit.MILLISECONDS))) + .thenAnswer(invocation -> { + task = invocation.getArgument(0); + return scheduled; + }); + } + + @Test + void validFile() throws IOException { + AtomicReference savedConfig = new AtomicReference<>(); + ConfigurationDeserializer deserializer = + content -> moshi.adapter(Object.class).fromJson(new String(content, StandardCharsets.UTF_8)); + ConfigurationChangesTypedListener listener = (configKey, config, hinter) -> savedConfig.set(config); + poller.addListener(Product.ASM_DD, deserializer, listener); + + stubScheduling(); + poller.start(); + assertNotNull(task); + + when(okHttpClient.newCall(any(Request.class))).thenAnswer(invocation -> { + request = invocation.getArgument(0); + return call; + }); + when(call.execute()).thenReturn(buildOKResponse(getFileContents("validOneFile"))); + + task.run(poller); + + assertNotNull(savedConfig.get()); + } + + @TableTest({ + "scenario | baseFileName | message ", + "invalid signing key | targetsSignedWithInvalidKey | 'Missing signature for key ed7672c9a24abda78872ee32ee71c7cb1d5235e8db4ecbf1ca28b9c50eb75d9e'", + "invalid signature | tufTargetsInvalidSignature | 'Signature verification failed for targets.signed' ", + "invalid file hash | tufTargetsInvalidTargetFileHash | 'does not have the expected sha256 hash' ", + "missing target file | tufTargetsMissingTargetFile | 'is in target_files, but not in targets.signed' " + }) + void invalidFile(String baseFileName, String message) throws IOException { + ConfigurationDeserializer deserializer = content -> { + fail("should never be called"); + return null; }; - ConfigurationChangesTypedListener listener = (configKey, config, hinter) -> {}; - poller.addListener(Product.ASM_DD, deserializer, listener); - - stubScheduling(); - poller.start(); - assertNotNull(task); - - when(okHttpClient.newCall(any(Request.class))) - .thenAnswer( - invocation -> { - request = invocation.getArgument(0); - return call; - }); - when(call.execute()) - .thenReturn(buildOKResponse(getFileContents(baseFileName))) - .thenReturn(buildOKResponse("validOneFile")); - - task.run(poller); - task.run(poller); - - String bodyJson = bodyToString(request.body()); - assertThatJson(bodyJson).node("client.state.has_error").isEqualTo(true); - assertThatJson(bodyJson).node("client.state.error").asString().contains(message); - } + ConfigurationChangesTypedListener listener = (configKey, config, hinter) -> {}; + poller.addListener(Product.ASM_DD, deserializer, listener); + + stubScheduling(); + poller.start(); + assertNotNull(task); + + when(okHttpClient.newCall(any(Request.class))).thenAnswer(invocation -> { + request = invocation.getArgument(0); + return call; + }); + when(call.execute()) + .thenReturn(buildOKResponse(getFileContents(baseFileName))) + .thenReturn(buildOKResponse("validOneFile")); + + task.run(poller); + task.run(poller); + + String bodyJson = bodyToString(request.body()); + assertThatJson(bodyJson).node("client.state.has_error").isEqualTo(true); + assertThatJson(bodyJson).node("client.state.error").asString().contains(message); + } } diff --git a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ParsedConfigKeyTests.java b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ParsedConfigKeyTests.java index d0677adcbf2..2f429b27d08 100644 --- a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ParsedConfigKeyTests.java +++ b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ParsedConfigKeyTests.java @@ -10,27 +10,25 @@ class ParsedConfigKeyTests { - @TableTest({ - "scenario | configKey | org | version | product | configId ", - "no version | employee/ASM_DD/1.recommended.json/config | employee | | ASM_DD | 1.recommended.json ", - "live debugging | datadog/2/LIVE_DEBUGGING/Snapshot_1ba66cc9-146a-3479-9e66-2b63fd580f48/dog | datadog | 2 | LIVE_DEBUGGING | Snapshot_1ba66cc9-146a-3479-9e66-2b63fd580f48", - "unknown product | datadog/2/NOT_REAL_PRODUCT/123/dogoz | datadog | 2 | _UNKNOWN | 123 " - }) - void parseExtractsRightSegments( - String configKey, String org, Integer version, Product product, String configId) { - ParsedConfigKey parsed = ParsedConfigKey.parse(configKey); + @TableTest({ + "scenario | configKey | org | version | product | configId ", + "no version | employee/ASM_DD/1.recommended.json/config | employee | | ASM_DD | 1.recommended.json ", + "live debugging | datadog/2/LIVE_DEBUGGING/Snapshot_1ba66cc9-146a-3479-9e66-2b63fd580f48/dog | datadog | 2 | LIVE_DEBUGGING | Snapshot_1ba66cc9-146a-3479-9e66-2b63fd580f48", + "unknown product | datadog/2/NOT_REAL_PRODUCT/123/dogoz | datadog | 2 | _UNKNOWN | 123 " + }) + void parseExtractsRightSegments(String configKey, String org, Integer version, Product product, String configId) { + ParsedConfigKey parsed = ParsedConfigKey.parse(configKey); - assertEquals(org, parsed.getOrg()); - assertEquals(version, parsed.getVersion()); - assertEquals(product, parsed.getProduct()); - assertEquals(configId, parsed.getConfigId()); - assertEquals(configKey, parsed.toString()); - } + assertEquals(org, parsed.getOrg()); + assertEquals(version, parsed.getVersion()); + assertEquals(product, parsed.getProduct()); + assertEquals(configId, parsed.getConfigId()); + assertEquals(configKey, parsed.toString()); + } - @Test - void wrongFormatConfigKeyFails() { - ReportableException exception = - assertThrows(ReportableException.class, () -> ParsedConfigKey.parse("foo")); - assertEquals("Not a valid config key: foo", exception.getMessage()); - } + @Test + void wrongFormatConfigKeyFails() { + ReportableException exception = assertThrows(ReportableException.class, () -> ParsedConfigKey.parse("foo")); + assertEquals("Not a valid config key: foo", exception.getMessage()); + } } diff --git a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ProductStateSpecification.java b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ProductStateSpecification.java index 360c2d0a99c..1057f5584dc 100644 --- a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ProductStateSpecification.java +++ b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/state/ProductStateSpecification.java @@ -25,359 +25,343 @@ class ProductStateSpecification { - private final PollingRateHinter hinter = mock(PollingRateHinter.class); - - @Test - void testApplyForNonAsmDdProductAppliesChangesBeforeRemoves() { - // a ProductState for ASM_DATA - ProductState productState = new ProductState(Product.ASM_DATA); - OrderRecordingListener listener = new OrderRecordingListener(); - productState.addProductListener(listener); - - // first apply with config1 to cache it - RemoteConfigResponse response1 = - buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "oldhash1"))); - ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); - productState.apply(response1, Collections.singletonList(key1), hinter); - listener.operations.clear(); // Clear for the actual test - - // a new response with config1 (changed hash) and config2 (new) - RemoteConfigResponse response2 = - buildResponse( - targets( + private final PollingRateHinter hinter = mock(PollingRateHinter.class); + + @Test + void testApplyForNonAsmDdProductAppliesChangesBeforeRemoves() { + // a ProductState for ASM_DATA + ProductState productState = new ProductState(Product.ASM_DATA); + OrderRecordingListener listener = new OrderRecordingListener(); + productState.addProductListener(listener); + + // first apply with config1 to cache it + RemoteConfigResponse response1 = + buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "oldhash1"))); + ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); + productState.apply(response1, Collections.singletonList(key1), hinter); + listener.operations.clear(); // Clear for the actual test + + // a new response with config1 (changed hash) and config2 (new) + RemoteConfigResponse response2 = buildResponse(targets( "org/ASM_DATA/config1/foo", new TargetSpec(2, 8, "newhash1"), "org/ASM_DATA/config2/foo", new TargetSpec(1, 8, "hash2"))); - ParsedConfigKey key2 = ParsedConfigKey.parse("org/ASM_DATA/config2/foo"); - - // apply is called - boolean changed = productState.apply(response2, Arrays.asList(key1, key2), hinter); - - // changes are detected - assertTrue(changed); - - // operations happen in order: apply config1, apply config2, commit (no removes) - assertEquals( - Arrays.asList( - "accept:org/ASM_DATA/config1/foo", "accept:org/ASM_DATA/config2/foo", "commit"), - listener.operations); - } - - @Test - void testApplyForAsmDdProductAppliesChangesAfterRemoves() { - // a ProductState for ASM_DD - ProductState productState = new ProductState(Product.ASM_DD); - OrderRecordingListener listener = new OrderRecordingListener(); - productState.addProductListener(listener); - - // first apply with config1 and config2 to cache them - RemoteConfigResponse response1 = - buildResponse( - targets( + ParsedConfigKey key2 = ParsedConfigKey.parse("org/ASM_DATA/config2/foo"); + + // apply is called + boolean changed = productState.apply(response2, Arrays.asList(key1, key2), hinter); + + // changes are detected + assertTrue(changed); + + // operations happen in order: apply config1, apply config2, commit (no removes) + assertEquals( + Arrays.asList("accept:org/ASM_DATA/config1/foo", "accept:org/ASM_DATA/config2/foo", "commit"), + listener.operations); + } + + @Test + void testApplyForAsmDdProductAppliesChangesAfterRemoves() { + // a ProductState for ASM_DD + ProductState productState = new ProductState(Product.ASM_DD); + OrderRecordingListener listener = new OrderRecordingListener(); + productState.addProductListener(listener); + + // first apply with config1 and config2 to cache them + RemoteConfigResponse response1 = buildResponse(targets( "org/ASM_DD/config1/foo", new TargetSpec(1, 8, "oldhash1"), "org/ASM_DD/config2/foo", new TargetSpec(1, 8, "hash2"))); - ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DD/config1/foo"); - ParsedConfigKey key2 = ParsedConfigKey.parse("org/ASM_DD/config2/foo"); - productState.apply(response1, Arrays.asList(key1, key2), hinter); - listener.operations.clear(); // Clear for the actual test - - // a new response with only config1 (changed hash) - config2 will be removed - RemoteConfigResponse response2 = - buildResponse(targets("org/ASM_DD/config1/foo", new TargetSpec(2, 8, "newhash1"))); - - // apply is called - boolean changed = productState.apply(response2, Collections.singletonList(key1), hinter); - - // changes are detected - assertTrue(changed); - - // operations happen in order: remove config2 FIRST, then apply config1, then commit - assertEquals( - Arrays.asList("remove:org/ASM_DD/config2/foo", "accept:org/ASM_DD/config1/foo", "commit"), - listener.operations); - } - - @Test - void testAsmDdWithMultipleNewConfigsRemovesBeforeAppliesAll() { - // a ProductState for ASM_DD - ProductState productState = new ProductState(Product.ASM_DD); - OrderRecordingListener listener = new OrderRecordingListener(); - productState.addProductListener(listener); - - // first apply with old configs - RemoteConfigResponse response1 = - buildResponse( - targets( + ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DD/config1/foo"); + ParsedConfigKey key2 = ParsedConfigKey.parse("org/ASM_DD/config2/foo"); + productState.apply(response1, Arrays.asList(key1, key2), hinter); + listener.operations.clear(); // Clear for the actual test + + // a new response with only config1 (changed hash) - config2 will be removed + RemoteConfigResponse response2 = + buildResponse(targets("org/ASM_DD/config1/foo", new TargetSpec(2, 8, "newhash1"))); + + // apply is called + boolean changed = productState.apply(response2, Collections.singletonList(key1), hinter); + + // changes are detected + assertTrue(changed); + + // operations happen in order: remove config2 FIRST, then apply config1, then commit + assertEquals( + Arrays.asList("remove:org/ASM_DD/config2/foo", "accept:org/ASM_DD/config1/foo", "commit"), + listener.operations); + } + + @Test + void testAsmDdWithMultipleNewConfigsRemovesBeforeAppliesAll() { + // a ProductState for ASM_DD + ProductState productState = new ProductState(Product.ASM_DD); + OrderRecordingListener listener = new OrderRecordingListener(); + productState.addProductListener(listener); + + // first apply with old configs + RemoteConfigResponse response1 = buildResponse(targets( "org/ASM_DD/old1/foo", new TargetSpec(1, 8, "hash_old1"), "org/ASM_DD/old2/foo", new TargetSpec(1, 8, "hash_old2"))); - ParsedConfigKey oldKey1 = ParsedConfigKey.parse("org/ASM_DD/old1/foo"); - ParsedConfigKey oldKey2 = ParsedConfigKey.parse("org/ASM_DD/old2/foo"); - productState.apply(response1, Arrays.asList(oldKey1, oldKey2), hinter); - listener.operations.clear(); // Clear for the actual test - - // a response with completely new configs - RemoteConfigResponse response2 = - buildResponse( - targets( + ParsedConfigKey oldKey1 = ParsedConfigKey.parse("org/ASM_DD/old1/foo"); + ParsedConfigKey oldKey2 = ParsedConfigKey.parse("org/ASM_DD/old2/foo"); + productState.apply(response1, Arrays.asList(oldKey1, oldKey2), hinter); + listener.operations.clear(); // Clear for the actual test + + // a response with completely new configs + RemoteConfigResponse response2 = buildResponse(targets( "org/ASM_DD/new1/foo", new TargetSpec(1, 8, "hash_new1"), "org/ASM_DD/new2/foo", new TargetSpec(1, 8, "hash_new2"))); - ParsedConfigKey newKey1 = ParsedConfigKey.parse("org/ASM_DD/new1/foo"); - ParsedConfigKey newKey2 = ParsedConfigKey.parse("org/ASM_DD/new2/foo"); - - // apply is called - boolean changed = productState.apply(response2, Arrays.asList(newKey1, newKey2), hinter); - - // changes are detected - assertTrue(changed); - - // all removes happen before all applies - assertEquals(5, listener.operations.size()); // 2 removes + 2 accepts + 1 commit - assertEquals(2, countStartingWith(listener.operations, "remove:")); - assertEquals(2, countStartingWith(listener.operations, "accept:")); - - // removes come before accepts - int lastRemoveIdx = lastIndexStartingWith(listener.operations, "remove:"); - int firstAcceptIdx = firstIndexStartingWith(listener.operations, "accept:"); - assertTrue(lastRemoveIdx < firstAcceptIdx); - } - - @Test - void testNoChangesDetectedWhenConfigHashesMatch() { - // a ProductState - ProductState productState = new ProductState(Product.ASM_DATA); - OrderRecordingListener listener = new OrderRecordingListener(); - productState.addProductListener(listener); - - // first apply with a config - RemoteConfigResponse response = - buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); - ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); - productState.apply(response, Collections.singletonList(key1), hinter); - listener.operations.clear(); // Clear for the actual test - - // apply is called again with the same hash - boolean changed = productState.apply(response, Collections.singletonList(key1), hinter); - - // no changes are detected - assertFalse(changed); - - // no listener operations occurred - assertTrue(listener.operations.isEmpty()); - } - - @Test - void testErrorHandlingDuringApply() throws Exception { - // a ProductState - ProductState productState = new ProductState(Product.ASM_DATA); - ProductListener listener = mock(ProductListener.class); - productState.addProductListener(listener); - - // a response with a config - RemoteConfigResponse response = - buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); - - // listener throws an exception - doThrow(new RuntimeException("Listener error")).when(listener).accept(any(), any(), any()); - - ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); - - // apply is called - boolean changed = productState.apply(response, Collections.singletonList(key1), hinter); - - // changes are still detected - assertTrue(changed); - - // commit is still called despite the error - verify(listener).commit(hinter); - } - - @Test - void testReportableExceptionIsRecorded() throws Exception { - // a ProductState - ProductState productState = new ProductState(Product.ASM_DATA); - ProductListener listener = mock(ProductListener.class); - productState.addProductListener(listener); - - // a response with a config - RemoteConfigResponse response = - buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); - - // listener throws a ReportableException - ReportableException exception = new ReportableException("Test error"); - doThrow(exception).when(listener).accept(any(), any(), any()); - - ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); - - // apply is called - productState.apply(response, Collections.singletonList(key1), hinter); - - // error is recorded - assertTrue(productState.hasError()); - assertTrue(productState.getErrors().contains(exception)); - } - - @Test - void testConfigListenersAreCalledInAdditionToProductListeners() { - // a ProductState - ProductState productState = new ProductState(Product.ASM_DATA); - OrderRecordingListener productListener = new OrderRecordingListener(); - OrderRecordingListener configListener = new OrderRecordingListener(); - productState.addProductListener(productListener); - productState.addProductListener("config1", configListener); - - // a response with two configs - RemoteConfigResponse response = - buildResponse( - targets( + ParsedConfigKey newKey1 = ParsedConfigKey.parse("org/ASM_DD/new1/foo"); + ParsedConfigKey newKey2 = ParsedConfigKey.parse("org/ASM_DD/new2/foo"); + + // apply is called + boolean changed = productState.apply(response2, Arrays.asList(newKey1, newKey2), hinter); + + // changes are detected + assertTrue(changed); + + // all removes happen before all applies + assertEquals(5, listener.operations.size()); // 2 removes + 2 accepts + 1 commit + assertEquals(2, countStartingWith(listener.operations, "remove:")); + assertEquals(2, countStartingWith(listener.operations, "accept:")); + + // removes come before accepts + int lastRemoveIdx = lastIndexStartingWith(listener.operations, "remove:"); + int firstAcceptIdx = firstIndexStartingWith(listener.operations, "accept:"); + assertTrue(lastRemoveIdx < firstAcceptIdx); + } + + @Test + void testNoChangesDetectedWhenConfigHashesMatch() { + // a ProductState + ProductState productState = new ProductState(Product.ASM_DATA); + OrderRecordingListener listener = new OrderRecordingListener(); + productState.addProductListener(listener); + + // first apply with a config + RemoteConfigResponse response = + buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); + ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); + productState.apply(response, Collections.singletonList(key1), hinter); + listener.operations.clear(); // Clear for the actual test + + // apply is called again with the same hash + boolean changed = productState.apply(response, Collections.singletonList(key1), hinter); + + // no changes are detected + assertFalse(changed); + + // no listener operations occurred + assertTrue(listener.operations.isEmpty()); + } + + @Test + void testErrorHandlingDuringApply() throws Exception { + // a ProductState + ProductState productState = new ProductState(Product.ASM_DATA); + ProductListener listener = mock(ProductListener.class); + productState.addProductListener(listener); + + // a response with a config + RemoteConfigResponse response = + buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); + + // listener throws an exception + doThrow(new RuntimeException("Listener error")).when(listener).accept(any(), any(), any()); + + ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); + + // apply is called + boolean changed = productState.apply(response, Collections.singletonList(key1), hinter); + + // changes are still detected + assertTrue(changed); + + // commit is still called despite the error + verify(listener).commit(hinter); + } + + @Test + void testReportableExceptionIsRecorded() throws Exception { + // a ProductState + ProductState productState = new ProductState(Product.ASM_DATA); + ProductListener listener = mock(ProductListener.class); + productState.addProductListener(listener); + + // a response with a config + RemoteConfigResponse response = + buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); + + // listener throws a ReportableException + ReportableException exception = new ReportableException("Test error"); + doThrow(exception).when(listener).accept(any(), any(), any()); + + ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); + + // apply is called + productState.apply(response, Collections.singletonList(key1), hinter); + + // error is recorded + assertTrue(productState.hasError()); + assertTrue(productState.getErrors().contains(exception)); + } + + @Test + void testConfigListenersAreCalledInAdditionToProductListeners() { + // a ProductState + ProductState productState = new ProductState(Product.ASM_DATA); + OrderRecordingListener productListener = new OrderRecordingListener(); + OrderRecordingListener configListener = new OrderRecordingListener(); + productState.addProductListener(productListener); + productState.addProductListener("config1", configListener); + + // a response with two configs + RemoteConfigResponse response = buildResponse(targets( "org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"), "org/ASM_DATA/config2/foo", new TargetSpec(1, 8, "hash2"))); - ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); - ParsedConfigKey key2 = ParsedConfigKey.parse("org/ASM_DATA/config2/foo"); - - // apply is called - productState.apply(response, Arrays.asList(key1, key2), hinter); - - // productListener received both configs - assertEquals(2, countStartingWith(productListener.operations, "accept:")); - - // configListener only received config1 - assertEquals( - Arrays.asList("accept:org/ASM_DATA/config1/foo", "commit"), configListener.operations); - } - - @Test - void testRemoveOperationsCleanupCachedData() throws Exception { - // a ProductState - ProductState productState = new ProductState(Product.ASM_DATA); - ProductListener listener = mock(ProductListener.class); - productState.addProductListener(listener); - - // first apply with a config to cache it - RemoteConfigResponse response1 = - buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); - ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); - productState.apply(response1, Collections.singletonList(key1), hinter); - - // an empty response (config should be removed) - RemoteConfigResponse response2 = buildResponse(Collections.emptyMap()); - - // apply is called - boolean changed = - productState.apply(response2, Collections.emptyList(), hinter); - - // changes are detected - assertTrue(changed); - - // listener remove was called - verify(listener).remove(key1, hinter); - - // cached data is cleaned up - assertTrue(productState.getCachedTargetFiles().isEmpty()); - assertTrue(productState.getConfigStates().isEmpty()); - } - - // Helper methods - - private static Map targets(String path, TargetSpec spec) { - return Collections.singletonMap(path, spec); - } - - private static Map targets( - String path1, TargetSpec spec1, String path2, TargetSpec spec2) { - Map targets = new HashMap<>(); - targets.put(path1, spec1); - targets.put(path2, spec2); - return targets; - } - - private static RemoteConfigResponse buildResponse(Map targets) { - RemoteConfigResponse response = mock(RemoteConfigResponse.class); - - for (Map.Entry entry : targets.entrySet()) { - String path = entry.getKey(); - TargetSpec targetData = entry.getValue(); - - RemoteConfigResponse.Targets.ConfigTarget target = - new RemoteConfigResponse.Targets.ConfigTarget(); - target.hashes = singletonMap("sha256", targetData.hash); - target.length = targetData.length; - - RemoteConfigResponse.Targets.ConfigTarget.ConfigTargetCustom custom = - new RemoteConfigResponse.Targets.ConfigTarget.ConfigTargetCustom(); - custom.version = targetData.version; - target.custom = custom; - - when(response.getTarget(path)).thenReturn(target); - when(response.getFileContents(path)) - .thenReturn(("content_" + targetData.hash).getBytes(UTF_8)); + ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); + ParsedConfigKey key2 = ParsedConfigKey.parse("org/ASM_DATA/config2/foo"); + + // apply is called + productState.apply(response, Arrays.asList(key1, key2), hinter); + + // productListener received both configs + assertEquals(2, countStartingWith(productListener.operations, "accept:")); + + // configListener only received config1 + assertEquals(Arrays.asList("accept:org/ASM_DATA/config1/foo", "commit"), configListener.operations); } - // Handle empty targets case - if (targets.isEmpty()) { - when(response.getTarget(any())).thenReturn(null); + @Test + void testRemoveOperationsCleanupCachedData() throws Exception { + // a ProductState + ProductState productState = new ProductState(Product.ASM_DATA); + ProductListener listener = mock(ProductListener.class); + productState.addProductListener(listener); + + // first apply with a config to cache it + RemoteConfigResponse response1 = + buildResponse(targets("org/ASM_DATA/config1/foo", new TargetSpec(1, 8, "hash1"))); + ParsedConfigKey key1 = ParsedConfigKey.parse("org/ASM_DATA/config1/foo"); + productState.apply(response1, Collections.singletonList(key1), hinter); + + // an empty response (config should be removed) + RemoteConfigResponse response2 = buildResponse(Collections.emptyMap()); + + // apply is called + boolean changed = productState.apply(response2, Collections.emptyList(), hinter); + + // changes are detected + assertTrue(changed); + + // listener remove was called + verify(listener).remove(key1, hinter); + + // cached data is cleaned up + assertTrue(productState.getCachedTargetFiles().isEmpty()); + assertTrue(productState.getConfigStates().isEmpty()); } - return response; - } + // Helper methods - private static int countStartingWith(List operations, String prefix) { - int count = 0; - for (String operation : operations) { - if (operation.startsWith(prefix)) { - count++; - } + private static Map targets(String path, TargetSpec spec) { + return Collections.singletonMap(path, spec); } - return count; - } - - private static int firstIndexStartingWith(List operations, String prefix) { - for (int index = 0; index < operations.size(); index++) { - if (operations.get(index).startsWith(prefix)) { - return index; - } + + private static Map targets(String path1, TargetSpec spec1, String path2, TargetSpec spec2) { + Map targets = new HashMap<>(); + targets.put(path1, spec1); + targets.put(path2, spec2); + return targets; } - return -1; - } - - private static int lastIndexStartingWith(List operations, String prefix) { - for (int index = operations.size() - 1; index >= 0; index--) { - if (operations.get(index).startsWith(prefix)) { - return index; - } + + private static RemoteConfigResponse buildResponse(Map targets) { + RemoteConfigResponse response = mock(RemoteConfigResponse.class); + + for (Map.Entry entry : targets.entrySet()) { + String path = entry.getKey(); + TargetSpec targetData = entry.getValue(); + + RemoteConfigResponse.Targets.ConfigTarget target = new RemoteConfigResponse.Targets.ConfigTarget(); + target.hashes = singletonMap("sha256", targetData.hash); + target.length = targetData.length; + + RemoteConfigResponse.Targets.ConfigTarget.ConfigTargetCustom custom = + new RemoteConfigResponse.Targets.ConfigTarget.ConfigTargetCustom(); + custom.version = targetData.version; + target.custom = custom; + + when(response.getTarget(path)).thenReturn(target); + when(response.getFileContents(path)).thenReturn(("content_" + targetData.hash).getBytes(UTF_8)); + } + + // Handle empty targets case + if (targets.isEmpty()) { + when(response.getTarget(any())).thenReturn(null); + } + + return response; } - return -1; - } - - // Light structure describing a target's metadata for the mocked response - private static final class TargetSpec { - final long version; - final long length; - final String hash; - - TargetSpec(long version, long length, String hash) { - this.version = version; - this.length = length; - this.hash = hash; + + private static int countStartingWith(List operations, String prefix) { + int count = 0; + for (String operation : operations) { + if (operation.startsWith(prefix)) { + count++; + } + } + return count; } - } - // Test helper class to record operation order - static class OrderRecordingListener implements ProductListener { - final List operations = new ArrayList<>(); + private static int firstIndexStartingWith(List operations, String prefix) { + for (int index = 0; index < operations.size(); index++) { + if (operations.get(index).startsWith(prefix)) { + return index; + } + } + return -1; + } - @Override - public void accept(ConfigKey configKey, byte[] content, PollingRateHinter pollingRateHinter) { - operations.add("accept:" + configKey.toString()); + private static int lastIndexStartingWith(List operations, String prefix) { + for (int index = operations.size() - 1; index >= 0; index--) { + if (operations.get(index).startsWith(prefix)) { + return index; + } + } + return -1; } - @Override - public void remove(ConfigKey configKey, PollingRateHinter pollingRateHinter) { - operations.add("remove:" + configKey.toString()); + // Light structure describing a target's metadata for the mocked response + private static final class TargetSpec { + final long version; + final long length; + final String hash; + + TargetSpec(long version, long length, String hash) { + this.version = version; + this.length = length; + this.hash = hash; + } } - @Override - public void commit(PollingRateHinter pollingRateHinter) { - operations.add("commit"); + // Test helper class to record operation order + static class OrderRecordingListener implements ProductListener { + final List operations = new ArrayList<>(); + + @Override + public void accept(ConfigKey configKey, byte[] content, PollingRateHinter pollingRateHinter) { + operations.add("accept:" + configKey.toString()); + } + + @Override + public void remove(ConfigKey configKey, PollingRateHinter pollingRateHinter) { + operations.add("remove:" + configKey.toString()); + } + + @Override + public void commit(PollingRateHinter pollingRateHinter) { + operations.add("commit"); + } } - } } diff --git a/telemetry/src/jmh/java/datadog/telemetry/dependency/DependencyResolverBenchmark.java b/telemetry/src/jmh/java/datadog/telemetry/dependency/DependencyResolverBenchmark.java index c0b6d4dd7db..470c08d5bae 100644 --- a/telemetry/src/jmh/java/datadog/telemetry/dependency/DependencyResolverBenchmark.java +++ b/telemetry/src/jmh/java/datadog/telemetry/dependency/DependencyResolverBenchmark.java @@ -20,29 +20,25 @@ @State(Scope.Benchmark) public class DependencyResolverBenchmark { - private static final String PROJECT_DIR = System.getProperty("user.dir"); - - private static final URI SIMPLE_JAR_URI = - URI.create( - "file://" - + PROJECT_DIR - + "/src/test/resources/datadog/telemetry/dependencies/bson-4.2.0.jar"); - - private static final URI NESTED_SPRING_BOOT_JAR_URI = - URI.create( - "jar:file://" - + PROJECT_DIR - + "/src/test/resources/datadog/telemetry/dependencies/spring-boot-app.jar!/BOOT-INF/lib/opentracing-util-0.33.0.jar!/"); - - @Benchmark - public void resolveSimpleJar() { - final List result = DependencyResolver.resolve(SIMPLE_JAR_URI); - assert !result.isEmpty(); - } - - @Benchmark - public void resolveNestedSpringBootJar() { - final List result = DependencyResolver.resolve(NESTED_SPRING_BOOT_JAR_URI); - assert !result.isEmpty(); - } + private static final String PROJECT_DIR = System.getProperty("user.dir"); + + private static final URI SIMPLE_JAR_URI = + URI.create("file://" + PROJECT_DIR + "/src/test/resources/datadog/telemetry/dependencies/bson-4.2.0.jar"); + + private static final URI NESTED_SPRING_BOOT_JAR_URI = URI.create( + "jar:file://" + + PROJECT_DIR + + "/src/test/resources/datadog/telemetry/dependencies/spring-boot-app.jar!/BOOT-INF/lib/opentracing-util-0.33.0.jar!/"); + + @Benchmark + public void resolveSimpleJar() { + final List result = DependencyResolver.resolve(SIMPLE_JAR_URI); + assert !result.isEmpty(); + } + + @Benchmark + public void resolveNestedSpringBootJar() { + final List result = DependencyResolver.resolve(NESTED_SPRING_BOOT_JAR_URI); + assert !result.isEmpty(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/BufferedEvents.java b/telemetry/src/main/java/datadog/telemetry/BufferedEvents.java index e118b82303a..a4f5649ff28 100644 --- a/telemetry/src/main/java/datadog/telemetry/BufferedEvents.java +++ b/telemetry/src/main/java/datadog/telemetry/BufferedEvents.java @@ -15,165 +15,164 @@ * telemetry request attempt or telemetry metric calculation. */ public final class BufferedEvents implements EventSource, EventSink { - private static final int INITIAL_CAPACITY = 32; - private ArrayList configChangeEvents; - private int configChangeIndex; - private ArrayList integrationEvents; - private int integrationIndex; - private ArrayList dependencyEvents; - private int dependencyIndex; - private ArrayList metricEvents; - private int metricIndex; - private ArrayList distributionSeriesEvents; - private int distributionSeriesIndex; - private ArrayList logMessageEvents; - private int logMessageIndex; - private ArrayList productChangeEvents; - private int productChangeIndex; - private ArrayList endpointEvents; - private int endpointIndex; - - public void addConfigChangeEvent(ConfigSetting event) { - if (configChangeEvents == null) { - configChangeEvents = new ArrayList<>(INITIAL_CAPACITY); - } - configChangeEvents.add(event); - } - - @Override - public void addIntegrationEvent(Integration event) { - if (integrationEvents == null) { - integrationEvents = new ArrayList<>(INITIAL_CAPACITY); - } - integrationEvents.add(event); - } - - @Override - public void addDependencyEvent(Dependency event) { - if (dependencyEvents == null) { - dependencyEvents = new ArrayList<>(INITIAL_CAPACITY); - } - dependencyEvents.add(event); - } - - @Override - public void addMetricEvent(Metric event) { - if (metricEvents == null) { - metricEvents = new ArrayList<>(INITIAL_CAPACITY); - } - metricEvents.add(event); - } - - @Override - public void addDistributionSeriesEvent(DistributionSeries event) { - if (distributionSeriesEvents == null) { - distributionSeriesEvents = new ArrayList<>(INITIAL_CAPACITY); - } - distributionSeriesEvents.add(event); - } - - @Override - public void addLogMessageEvent(LogMessage event) { - if (logMessageEvents == null) { - logMessageEvents = new ArrayList<>(INITIAL_CAPACITY); - } - logMessageEvents.add(event); - } - - @Override - public void addProductChangeEvent(ProductChange event) { - if (productChangeEvents == null) { - productChangeEvents = new ArrayList<>(INITIAL_CAPACITY); - } - productChangeEvents.add(event); - } - - @Override - public void addEndpointEvent(final Endpoint event) { - if (endpointEvents == null) { - endpointEvents = new ArrayList<>(INITIAL_CAPACITY); - } - endpointEvents.add(event); - } - - @Override - public boolean hasConfigChangeEvent() { - return configChangeEvents != null && configChangeIndex < configChangeEvents.size(); - } - - @Override - public ConfigSetting nextConfigChangeEvent() { - return configChangeEvents.get(configChangeIndex++); - } - - @Override - public boolean hasIntegrationEvent() { - return integrationEvents != null && integrationIndex < integrationEvents.size(); - } - - @Override - public Integration nextIntegrationEvent() { - return integrationEvents.get(integrationIndex++); - } - - @Override - public boolean hasDependencyEvent() { - return dependencyEvents != null && dependencyIndex < dependencyEvents.size(); - } - - @Override - public Dependency nextDependencyEvent() { - return dependencyEvents.get(dependencyIndex++); - } - - @Override - public boolean hasMetricEvent() { - return metricEvents != null && metricIndex < metricEvents.size(); - } - - @Override - public Metric nextMetricEvent() { - return metricEvents.get(metricIndex++); - } - - @Override - public boolean hasDistributionSeriesEvent() { - return distributionSeriesEvents != null - && distributionSeriesIndex < distributionSeriesEvents.size(); - } - - @Override - public DistributionSeries nextDistributionSeriesEvent() { - return distributionSeriesEvents.get(distributionSeriesIndex++); - } - - @Override - public boolean hasLogMessageEvent() { - return logMessageEvents != null && logMessageIndex < logMessageEvents.size(); - } - - @Override - public LogMessage nextLogMessageEvent() { - return logMessageEvents.get(logMessageIndex++); - } - - @Override - public boolean hasProductChangeEvent() { - return productChangeEvents != null && productChangeIndex < productChangeEvents.size(); - } - - @Override - public ProductChange nextProductChangeEvent() { - return productChangeEvents.get(productChangeIndex++); - } - - @Override - public boolean hasEndpoint() { - return endpointEvents != null && endpointIndex < endpointEvents.size(); - } - - @Override - public Endpoint nextEndpoint() { - return endpointEvents.get(endpointIndex++); - } + private static final int INITIAL_CAPACITY = 32; + private ArrayList configChangeEvents; + private int configChangeIndex; + private ArrayList integrationEvents; + private int integrationIndex; + private ArrayList dependencyEvents; + private int dependencyIndex; + private ArrayList metricEvents; + private int metricIndex; + private ArrayList distributionSeriesEvents; + private int distributionSeriesIndex; + private ArrayList logMessageEvents; + private int logMessageIndex; + private ArrayList productChangeEvents; + private int productChangeIndex; + private ArrayList endpointEvents; + private int endpointIndex; + + public void addConfigChangeEvent(ConfigSetting event) { + if (configChangeEvents == null) { + configChangeEvents = new ArrayList<>(INITIAL_CAPACITY); + } + configChangeEvents.add(event); + } + + @Override + public void addIntegrationEvent(Integration event) { + if (integrationEvents == null) { + integrationEvents = new ArrayList<>(INITIAL_CAPACITY); + } + integrationEvents.add(event); + } + + @Override + public void addDependencyEvent(Dependency event) { + if (dependencyEvents == null) { + dependencyEvents = new ArrayList<>(INITIAL_CAPACITY); + } + dependencyEvents.add(event); + } + + @Override + public void addMetricEvent(Metric event) { + if (metricEvents == null) { + metricEvents = new ArrayList<>(INITIAL_CAPACITY); + } + metricEvents.add(event); + } + + @Override + public void addDistributionSeriesEvent(DistributionSeries event) { + if (distributionSeriesEvents == null) { + distributionSeriesEvents = new ArrayList<>(INITIAL_CAPACITY); + } + distributionSeriesEvents.add(event); + } + + @Override + public void addLogMessageEvent(LogMessage event) { + if (logMessageEvents == null) { + logMessageEvents = new ArrayList<>(INITIAL_CAPACITY); + } + logMessageEvents.add(event); + } + + @Override + public void addProductChangeEvent(ProductChange event) { + if (productChangeEvents == null) { + productChangeEvents = new ArrayList<>(INITIAL_CAPACITY); + } + productChangeEvents.add(event); + } + + @Override + public void addEndpointEvent(final Endpoint event) { + if (endpointEvents == null) { + endpointEvents = new ArrayList<>(INITIAL_CAPACITY); + } + endpointEvents.add(event); + } + + @Override + public boolean hasConfigChangeEvent() { + return configChangeEvents != null && configChangeIndex < configChangeEvents.size(); + } + + @Override + public ConfigSetting nextConfigChangeEvent() { + return configChangeEvents.get(configChangeIndex++); + } + + @Override + public boolean hasIntegrationEvent() { + return integrationEvents != null && integrationIndex < integrationEvents.size(); + } + + @Override + public Integration nextIntegrationEvent() { + return integrationEvents.get(integrationIndex++); + } + + @Override + public boolean hasDependencyEvent() { + return dependencyEvents != null && dependencyIndex < dependencyEvents.size(); + } + + @Override + public Dependency nextDependencyEvent() { + return dependencyEvents.get(dependencyIndex++); + } + + @Override + public boolean hasMetricEvent() { + return metricEvents != null && metricIndex < metricEvents.size(); + } + + @Override + public Metric nextMetricEvent() { + return metricEvents.get(metricIndex++); + } + + @Override + public boolean hasDistributionSeriesEvent() { + return distributionSeriesEvents != null && distributionSeriesIndex < distributionSeriesEvents.size(); + } + + @Override + public DistributionSeries nextDistributionSeriesEvent() { + return distributionSeriesEvents.get(distributionSeriesIndex++); + } + + @Override + public boolean hasLogMessageEvent() { + return logMessageEvents != null && logMessageIndex < logMessageEvents.size(); + } + + @Override + public LogMessage nextLogMessageEvent() { + return logMessageEvents.get(logMessageIndex++); + } + + @Override + public boolean hasProductChangeEvent() { + return productChangeEvents != null && productChangeIndex < productChangeEvents.size(); + } + + @Override + public ProductChange nextProductChangeEvent() { + return productChangeEvents.get(productChangeIndex++); + } + + @Override + public boolean hasEndpoint() { + return endpointEvents != null && endpointIndex < endpointEvents.size(); + } + + @Override + public Endpoint nextEndpoint() { + return endpointEvents.get(endpointIndex++); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/EventSink.java b/telemetry/src/main/java/datadog/telemetry/EventSink.java index 58464184e38..adc88d9f74b 100644 --- a/telemetry/src/main/java/datadog/telemetry/EventSink.java +++ b/telemetry/src/main/java/datadog/telemetry/EventSink.java @@ -15,49 +15,49 @@ * failing attempt. */ interface EventSink { - void addConfigChangeEvent(ConfigSetting event); + void addConfigChangeEvent(ConfigSetting event); - void addIntegrationEvent(Integration event); + void addIntegrationEvent(Integration event); - void addDependencyEvent(Dependency event); + void addDependencyEvent(Dependency event); - void addMetricEvent(Metric event); + void addMetricEvent(Metric event); - void addDistributionSeriesEvent(DistributionSeries event); + void addDistributionSeriesEvent(DistributionSeries event); - void addLogMessageEvent(LogMessage event); + void addLogMessageEvent(LogMessage event); - void addProductChangeEvent(ProductChange event); + void addProductChangeEvent(ProductChange event); - void addEndpointEvent(Endpoint event); + void addEndpointEvent(Endpoint event); - EventSink NOOP = new Noop(); + EventSink NOOP = new Noop(); - class Noop implements EventSink { - private Noop() {} + class Noop implements EventSink { + private Noop() {} - @Override - public void addConfigChangeEvent(ConfigSetting event) {} + @Override + public void addConfigChangeEvent(ConfigSetting event) {} - @Override - public void addIntegrationEvent(Integration event) {} + @Override + public void addIntegrationEvent(Integration event) {} - @Override - public void addDependencyEvent(Dependency event) {} + @Override + public void addDependencyEvent(Dependency event) {} - @Override - public void addMetricEvent(Metric event) {} + @Override + public void addMetricEvent(Metric event) {} - @Override - public void addDistributionSeriesEvent(DistributionSeries event) {} + @Override + public void addDistributionSeriesEvent(DistributionSeries event) {} - @Override - public void addLogMessageEvent(LogMessage event) {} + @Override + public void addLogMessageEvent(LogMessage event) {} - @Override - public void addProductChangeEvent(ProductChange event) {} + @Override + public void addProductChangeEvent(ProductChange event) {} - @Override - public void addEndpointEvent(Endpoint event) {} - } + @Override + public void addEndpointEvent(Endpoint event) {} + } } diff --git a/telemetry/src/main/java/datadog/telemetry/EventSource.java b/telemetry/src/main/java/datadog/telemetry/EventSource.java index b0088474c21..d84f1ed57a3 100644 --- a/telemetry/src/main/java/datadog/telemetry/EventSource.java +++ b/telemetry/src/main/java/datadog/telemetry/EventSource.java @@ -16,156 +16,156 @@ * buffered for a retry */ interface EventSource { - boolean hasConfigChangeEvent(); + boolean hasConfigChangeEvent(); - ConfigSetting nextConfigChangeEvent(); + ConfigSetting nextConfigChangeEvent(); - boolean hasIntegrationEvent(); + boolean hasIntegrationEvent(); - Integration nextIntegrationEvent(); + Integration nextIntegrationEvent(); - boolean hasDependencyEvent(); + boolean hasDependencyEvent(); - Dependency nextDependencyEvent(); + Dependency nextDependencyEvent(); - boolean hasMetricEvent(); + boolean hasMetricEvent(); - Metric nextMetricEvent(); + Metric nextMetricEvent(); + + boolean hasDistributionSeriesEvent(); - boolean hasDistributionSeriesEvent(); + DistributionSeries nextDistributionSeriesEvent(); + + boolean hasLogMessageEvent(); - DistributionSeries nextDistributionSeriesEvent(); - - boolean hasLogMessageEvent(); - - LogMessage nextLogMessageEvent(); - - boolean hasProductChangeEvent(); - - ProductChange nextProductChangeEvent(); - - boolean hasEndpoint(); - - Endpoint nextEndpoint(); - - default boolean isEmpty() { - return !hasConfigChangeEvent() - && !hasIntegrationEvent() - && !hasDependencyEvent() - && !hasMetricEvent() - && !hasDistributionSeriesEvent() - && !hasLogMessageEvent() - && !hasProductChangeEvent() - && !hasEndpoint(); - } - - final class Queued implements EventSource { - private final Queue configChangeQueue; - private final Queue integrationQueue; - private final Queue dependencyQueue; - private final Queue metricQueue; - private final Queue distributionSeriesQueue; - private final Queue logMessageQueue; - private final Queue productChanges; - private final Queue endpoints; - - Queued( - Queue configChangeQueue, - Queue integrationQueue, - Queue dependencyQueue, - Queue metricQueue, - Queue distributionSeriesQueue, - Queue logMessageQueue, - Queue productChanges, - Queue endpoints) { - this.configChangeQueue = configChangeQueue; - this.integrationQueue = integrationQueue; - this.dependencyQueue = dependencyQueue; - this.metricQueue = metricQueue; - this.distributionSeriesQueue = distributionSeriesQueue; - this.logMessageQueue = logMessageQueue; - this.productChanges = productChanges; - this.endpoints = endpoints; - } - - @Override - public boolean hasConfigChangeEvent() { - return !configChangeQueue.isEmpty(); - } - - @Override - public ConfigSetting nextConfigChangeEvent() { - return configChangeQueue.poll(); - } - - @Override - public boolean hasIntegrationEvent() { - return !integrationQueue.isEmpty(); - } - - @Override - public Integration nextIntegrationEvent() { - return integrationQueue.poll(); - } - - @Override - public boolean hasDependencyEvent() { - return !dependencyQueue.isEmpty(); - } - - @Override - public Dependency nextDependencyEvent() { - return dependencyQueue.poll(); - } - - @Override - public boolean hasMetricEvent() { - return !metricQueue.isEmpty(); - } - - @Override - public Metric nextMetricEvent() { - return metricQueue.poll(); - } - - @Override - public boolean hasDistributionSeriesEvent() { - return !distributionSeriesQueue.isEmpty(); - } - - @Override - public DistributionSeries nextDistributionSeriesEvent() { - return distributionSeriesQueue.poll(); - } - - @Override - public boolean hasLogMessageEvent() { - return !logMessageQueue.isEmpty(); - } - - @Override - public LogMessage nextLogMessageEvent() { - return logMessageQueue.poll(); - } - - @Override - public boolean hasProductChangeEvent() { - return !productChanges.isEmpty(); - } - - @Override - public ProductChange nextProductChangeEvent() { - return productChanges.poll(); - } - - @Override - public boolean hasEndpoint() { - return !endpoints.isEmpty(); - } - - @Override - public Endpoint nextEndpoint() { - return endpoints.poll(); + LogMessage nextLogMessageEvent(); + + boolean hasProductChangeEvent(); + + ProductChange nextProductChangeEvent(); + + boolean hasEndpoint(); + + Endpoint nextEndpoint(); + + default boolean isEmpty() { + return !hasConfigChangeEvent() + && !hasIntegrationEvent() + && !hasDependencyEvent() + && !hasMetricEvent() + && !hasDistributionSeriesEvent() + && !hasLogMessageEvent() + && !hasProductChangeEvent() + && !hasEndpoint(); + } + + final class Queued implements EventSource { + private final Queue configChangeQueue; + private final Queue integrationQueue; + private final Queue dependencyQueue; + private final Queue metricQueue; + private final Queue distributionSeriesQueue; + private final Queue logMessageQueue; + private final Queue productChanges; + private final Queue endpoints; + + Queued( + Queue configChangeQueue, + Queue integrationQueue, + Queue dependencyQueue, + Queue metricQueue, + Queue distributionSeriesQueue, + Queue logMessageQueue, + Queue productChanges, + Queue endpoints) { + this.configChangeQueue = configChangeQueue; + this.integrationQueue = integrationQueue; + this.dependencyQueue = dependencyQueue; + this.metricQueue = metricQueue; + this.distributionSeriesQueue = distributionSeriesQueue; + this.logMessageQueue = logMessageQueue; + this.productChanges = productChanges; + this.endpoints = endpoints; + } + + @Override + public boolean hasConfigChangeEvent() { + return !configChangeQueue.isEmpty(); + } + + @Override + public ConfigSetting nextConfigChangeEvent() { + return configChangeQueue.poll(); + } + + @Override + public boolean hasIntegrationEvent() { + return !integrationQueue.isEmpty(); + } + + @Override + public Integration nextIntegrationEvent() { + return integrationQueue.poll(); + } + + @Override + public boolean hasDependencyEvent() { + return !dependencyQueue.isEmpty(); + } + + @Override + public Dependency nextDependencyEvent() { + return dependencyQueue.poll(); + } + + @Override + public boolean hasMetricEvent() { + return !metricQueue.isEmpty(); + } + + @Override + public Metric nextMetricEvent() { + return metricQueue.poll(); + } + + @Override + public boolean hasDistributionSeriesEvent() { + return !distributionSeriesQueue.isEmpty(); + } + + @Override + public DistributionSeries nextDistributionSeriesEvent() { + return distributionSeriesQueue.poll(); + } + + @Override + public boolean hasLogMessageEvent() { + return !logMessageQueue.isEmpty(); + } + + @Override + public LogMessage nextLogMessageEvent() { + return logMessageQueue.poll(); + } + + @Override + public boolean hasProductChangeEvent() { + return !productChanges.isEmpty(); + } + + @Override + public ProductChange nextProductChangeEvent() { + return productChanges.poll(); + } + + @Override + public boolean hasEndpoint() { + return !endpoints.isEmpty(); + } + + @Override + public Endpoint nextEndpoint() { + return endpoints.poll(); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/ExtendedHeartbeatData.java b/telemetry/src/main/java/datadog/telemetry/ExtendedHeartbeatData.java index 75c26b4db9d..57a322e53c5 100644 --- a/telemetry/src/main/java/datadog/telemetry/ExtendedHeartbeatData.java +++ b/telemetry/src/main/java/datadog/telemetry/ExtendedHeartbeatData.java @@ -11,126 +11,126 @@ import java.util.ArrayList; public class ExtendedHeartbeatData { - private static final int DEFAULT_DEPENDENCIES_LIMIT = 2000; - private static final int INITIAL_CAPACITY = 32; - - private final int dependenciesLimit; - private final ArrayList configuration; - private final ArrayList dependencies; - private final ArrayList integrations; - - public ExtendedHeartbeatData() { - this(DEFAULT_DEPENDENCIES_LIMIT); - } - - ExtendedHeartbeatData(int dependenciesLimit) { - this.dependenciesLimit = dependenciesLimit; - configuration = new ArrayList<>(INITIAL_CAPACITY); - dependencies = new ArrayList<>(INITIAL_CAPACITY); - integrations = new ArrayList<>(INITIAL_CAPACITY); - } - - public void pushConfigSetting(ConfigSetting cs) { - configuration.add(cs); - } - - public void pushDependency(Dependency d) { - if (dependencies.size() < dependenciesLimit) { - dependencies.add(d); - } - } - - public void pushIntegration(Integration i) { - integrations.add(i); - } - - public EventSource snapshot() { - return new Snapshot(); - } + private static final int DEFAULT_DEPENDENCIES_LIMIT = 2000; + private static final int INITIAL_CAPACITY = 32; - private final class Snapshot implements EventSource { - private int configIndex; - private int dependencyIndex; - private int integrationIndex; + private final int dependenciesLimit; + private final ArrayList configuration; + private final ArrayList dependencies; + private final ArrayList integrations; - @Override - public boolean hasConfigChangeEvent() { - return configIndex < configuration.size(); + public ExtendedHeartbeatData() { + this(DEFAULT_DEPENDENCIES_LIMIT); } - @Override - public ConfigSetting nextConfigChangeEvent() { - return configuration.get(configIndex++); + ExtendedHeartbeatData(int dependenciesLimit) { + this.dependenciesLimit = dependenciesLimit; + configuration = new ArrayList<>(INITIAL_CAPACITY); + dependencies = new ArrayList<>(INITIAL_CAPACITY); + integrations = new ArrayList<>(INITIAL_CAPACITY); } - @Override - public boolean hasIntegrationEvent() { - return integrationIndex < integrations.size(); + public void pushConfigSetting(ConfigSetting cs) { + configuration.add(cs); } - @Override - public Integration nextIntegrationEvent() { - return integrations.get(integrationIndex++); + public void pushDependency(Dependency d) { + if (dependencies.size() < dependenciesLimit) { + dependencies.add(d); + } } - @Override - public boolean hasDependencyEvent() { - return dependencyIndex < dependencies.size(); + public void pushIntegration(Integration i) { + integrations.add(i); } - @Override - public Dependency nextDependencyEvent() { - return dependencies.get(dependencyIndex++); + public EventSource snapshot() { + return new Snapshot(); } - @Override - public boolean hasMetricEvent() { - return false; - } + private final class Snapshot implements EventSource { + private int configIndex; + private int dependencyIndex; + private int integrationIndex; - @Override - public Metric nextMetricEvent() { - return null; - } + @Override + public boolean hasConfigChangeEvent() { + return configIndex < configuration.size(); + } - @Override - public boolean hasDistributionSeriesEvent() { - return false; - } + @Override + public ConfigSetting nextConfigChangeEvent() { + return configuration.get(configIndex++); + } - @Override - public DistributionSeries nextDistributionSeriesEvent() { - return null; - } + @Override + public boolean hasIntegrationEvent() { + return integrationIndex < integrations.size(); + } - @Override - public boolean hasLogMessageEvent() { - return false; - } + @Override + public Integration nextIntegrationEvent() { + return integrations.get(integrationIndex++); + } - @Override - public LogMessage nextLogMessageEvent() { - return null; - } + @Override + public boolean hasDependencyEvent() { + return dependencyIndex < dependencies.size(); + } - @Override - public boolean hasProductChangeEvent() { - return false; - } + @Override + public Dependency nextDependencyEvent() { + return dependencies.get(dependencyIndex++); + } - @Override - public ProductChange nextProductChangeEvent() { - return null; - } + @Override + public boolean hasMetricEvent() { + return false; + } - @Override - public boolean hasEndpoint() { - return false; - } + @Override + public Metric nextMetricEvent() { + return null; + } + + @Override + public boolean hasDistributionSeriesEvent() { + return false; + } + + @Override + public DistributionSeries nextDistributionSeriesEvent() { + return null; + } + + @Override + public boolean hasLogMessageEvent() { + return false; + } + + @Override + public LogMessage nextLogMessageEvent() { + return null; + } + + @Override + public boolean hasProductChangeEvent() { + return false; + } + + @Override + public ProductChange nextProductChangeEvent() { + return null; + } + + @Override + public boolean hasEndpoint() { + return false; + } - @Override - public Endpoint nextEndpoint() { - return null; + @Override + public Endpoint nextEndpoint() { + return null; + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/FileBasedTelemetryClient.java b/telemetry/src/main/java/datadog/telemetry/FileBasedTelemetryClient.java index 83bd1ed1352..dab8b639672 100644 --- a/telemetry/src/main/java/datadog/telemetry/FileBasedTelemetryClient.java +++ b/telemetry/src/main/java/datadog/telemetry/FileBasedTelemetryClient.java @@ -20,72 +20,70 @@ */ public class FileBasedTelemetryClient extends TelemetryClient { - private static final Logger log = LoggerFactory.getLogger(FileBasedTelemetryClient.class); + private static final Logger log = LoggerFactory.getLogger(FileBasedTelemetryClient.class); - private static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; - private static final HttpUrl PLACEHOLDER_URL = - HttpUrl.get("http://localhost/bazel-file-telemetry"); + private static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; + private static final HttpUrl PLACEHOLDER_URL = HttpUrl.get("http://localhost/bazel-file-telemetry"); - private final File outputDir; - private final AtomicLong sequence = new AtomicLong(0); + private final File outputDir; + private final AtomicLong sequence = new AtomicLong(0); - public FileBasedTelemetryClient(String outputDirPath) { - super(null, null, PLACEHOLDER_URL, null); - this.outputDir = new File(outputDirPath); - } + public FileBasedTelemetryClient(String outputDirPath) { + super(null, null, PLACEHOLDER_URL, null); + this.outputDir = new File(outputDirPath); + } - @Override - public Result sendHttpRequest(Request.Builder httpRequestBuilder) { - Request request = httpRequestBuilder.url(PLACEHOLDER_URL).build(); - String requestType = request.header(DD_TELEMETRY_REQUEST_TYPE); + @Override + public Result sendHttpRequest(Request.Builder httpRequestBuilder) { + Request request = httpRequestBuilder.url(PLACEHOLDER_URL).build(); + String requestType = request.header(DD_TELEMETRY_REQUEST_TYPE); - try { - ensureOutputDir(); - byte[] bytes = readBody(request); - writeFileAtomically(bytes); - if (log.isDebugEnabled()) { - log.debug( - "[bazel mode] Wrote telemetry payload {} ({} bytes) to {}", - requestType, - bytes.length, - outputDir); - } - return Result.SUCCESS; - } catch (IOException e) { - log.error( - "[bazel mode] Failed to write telemetry payload {} to {}", requestType, outputDir, e); - return Result.FAILURE; + try { + ensureOutputDir(); + byte[] bytes = readBody(request); + writeFileAtomically(bytes); + if (log.isDebugEnabled()) { + log.debug( + "[bazel mode] Wrote telemetry payload {} ({} bytes) to {}", + requestType, + bytes.length, + outputDir); + } + return Result.SUCCESS; + } catch (IOException e) { + log.error("[bazel mode] Failed to write telemetry payload {} to {}", requestType, outputDir, e); + return Result.FAILURE; + } } - } - private void ensureOutputDir() throws IOException { - if (!outputDir.exists() && !outputDir.mkdirs() && !outputDir.exists()) { - throw new IOException("Failed to create output directory: " + outputDir); + private void ensureOutputDir() throws IOException { + if (!outputDir.exists() && !outputDir.mkdirs() && !outputDir.exists()) { + throw new IOException("Failed to create output directory: " + outputDir); + } } - } - private static byte[] readBody(Request request) throws IOException { - if (request.body() == null) { - return new byte[0]; + private static byte[] readBody(Request request) throws IOException { + if (request.body() == null) { + return new byte[0]; + } + Buffer buffer = new Buffer(); + request.body().writeTo(buffer); + return buffer.readByteArray(); } - Buffer buffer = new Buffer(); - request.body().writeTo(buffer); - return buffer.readByteArray(); - } - private void writeFileAtomically(byte[] data) throws IOException { - long seq = sequence.getAndIncrement(); - String pid = PidHelper.getPid(); - String filename = String.format("telemetry-%020d-%s.json", seq, pid); - File target = new File(outputDir, filename); - File tmp = new File(outputDir, filename + ".tmp"); + private void writeFileAtomically(byte[] data) throws IOException { + long seq = sequence.getAndIncrement(); + String pid = PidHelper.getPid(); + String filename = String.format("telemetry-%020d-%s.json", seq, pid); + File target = new File(outputDir, filename); + File tmp = new File(outputDir, filename + ".tmp"); - try (FileOutputStream out = new FileOutputStream(tmp)) { - out.write(data); - } - if (!tmp.renameTo(target)) { - tmp.delete(); - throw new IOException("Failed to rename " + tmp + " to " + target); + try (FileOutputStream out = new FileOutputStream(tmp)) { + out.write(data); + } + if (!tmp.renameTo(target)) { + tmp.delete(); + throw new IOException("Failed to rename " + tmp + " to " + target); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/HostInfo.java b/telemetry/src/main/java/datadog/telemetry/HostInfo.java index 1d950ece8e2..a7a772b2be0 100644 --- a/telemetry/src/main/java/datadog/telemetry/HostInfo.java +++ b/telemetry/src/main/java/datadog/telemetry/HostInfo.java @@ -17,126 +17,125 @@ import org.slf4j.LoggerFactory; public class HostInfo { - private static final Path PROC_VERSION = FileSystems.getDefault().getPath("/proc/version"); - private static final Logger log = LoggerFactory.getLogger(TelemetryRequestBody.class); + private static final Path PROC_VERSION = FileSystems.getDefault().getPath("/proc/version"); + private static final Logger log = LoggerFactory.getLogger(TelemetryRequestBody.class); - private static String hostname; - private static String osName; - private static String osVersion; - private static String kernelRelease; - private static String kernelVersion; - private static String architecture; + private static String hostname; + private static String osName; + private static String osVersion; + private static String kernelRelease; + private static String kernelVersion; + private static String architecture; - public static String getHostname() { - if (hostname != null) { - return hostname; + public static String getHostname() { + if (hostname != null) { + return hostname; + } + HostInfo.hostname = Config.get().getHostName(); + return hostname; } - HostInfo.hostname = Config.get().getHostName(); - return hostname; - } - public static String getOsName() { - if (osName == null) { - if (OperatingSystem.isMacOs()) { - // os.name == Mac OS X, while uanme -s == Darwin. We'll hardcode it to Darwin. - osName = "Darwin"; - } else { - osName = SystemProperties.get("os.name"); - } + public static String getOsName() { + if (osName == null) { + if (OperatingSystem.isMacOs()) { + // os.name == Mac OS X, while uanme -s == Darwin. We'll hardcode it to Darwin. + osName = "Darwin"; + } else { + osName = SystemProperties.get("os.name"); + } + } + return osName; } - return osName; - } - public static String getOsVersion() { - if (osVersion == null) { - osVersion = Os.getOsVersion(); + public static String getOsVersion() { + if (osVersion == null) { + osVersion = Os.getOsVersion(); + } + return osVersion; } - return osVersion; - } - public static String getKernelName() { - return getOsName(); - } + public static String getKernelName() { + return getOsName(); + } - public static String getKernelRelease() { - if (kernelRelease == null) { - // In Linux, os.version == uname -r - kernelRelease = SystemProperties.get("os.version"); + public static String getKernelRelease() { + if (kernelRelease == null) { + // In Linux, os.version == uname -r + kernelRelease = SystemProperties.get("os.version"); + } + return kernelRelease; } - return kernelRelease; - } - public static String getKernelVersion() { - if (kernelVersion == null) { - // This is not really equivalent to uname -v in Linux, it has some additional info at the end, - // like arch. - String version = tryReadFile(PROC_VERSION); - if (version != null) { - version = version.trim(); - final int dashIdx = version.indexOf('#'); - if (dashIdx > 0) { - version = version.substring(dashIdx); + public static String getKernelVersion() { + if (kernelVersion == null) { + // This is not really equivalent to uname -v in Linux, it has some additional info at the end, + // like arch. + String version = tryReadFile(PROC_VERSION); + if (version != null) { + version = version.trim(); + final int dashIdx = version.indexOf('#'); + if (dashIdx > 0) { + version = version.substring(dashIdx); + } + kernelVersion = version; + } else { + kernelVersion = SystemProperties.get("os.version"); + } } - kernelVersion = version; - } else { - kernelVersion = SystemProperties.get("os.version"); - } + return kernelVersion; } - return kernelVersion; - } - public static String getArchitecture() { - if (architecture == null) { - // In Linux, os.arch == uname -me - architecture = SystemProperties.get("os.arch"); + public static String getArchitecture() { + if (architecture == null) { + // In Linux, os.arch == uname -me + architecture = SystemProperties.get("os.arch"); + } + return architecture; } - return architecture; - } - private static String tryReadFile(Path file) { - String content = null; - if (Files.isRegularFile(file)) { - try { - byte[] bytes = Files.readAllBytes(file); - content = new String(bytes, StandardCharsets.ISO_8859_1); - } catch (IOException | AccessControlException e) { - log.debug("Could not read {}", file, e); - } + private static String tryReadFile(Path file) { + String content = null; + if (Files.isRegularFile(file)) { + try { + byte[] bytes = Files.readAllBytes(file); + content = new String(bytes, StandardCharsets.ISO_8859_1); + } catch (IOException | AccessControlException e) { + log.debug("Could not read {}", file, e); + } + } + return content; } - return content; - } - private static class Os { - private static final Pattern OS_RELEASE_PATTERN = - Pattern.compile("(?[A-Z]+)=\"(?[^\"]+)\""); - private static final Path OS_RELEASE_PATH = Paths.get("/etc/os-release"); + private static class Os { + private static final Pattern OS_RELEASE_PATTERN = Pattern.compile("(?[A-Z]+)=\"(?[^\"]+)\""); + private static final Path OS_RELEASE_PATH = Paths.get("/etc/os-release"); - public static String getOsVersion() { - if (Files.isRegularFile(OS_RELEASE_PATH)) { - String name = null; - String version = null; - try { - List lines = Files.readAllLines(OS_RELEASE_PATH, StandardCharsets.ISO_8859_1); - for (String l : lines) { - Matcher matcher = OS_RELEASE_PATTERN.matcher(l); - if (!matcher.matches()) { - continue; - } - String nameGroup = matcher.group("name"); - if ("NAME".equals(nameGroup)) { - name = matcher.group("value"); - } else if ("VERSION".equals(nameGroup)) { - version = matcher.group("value"); + public static String getOsVersion() { + if (Files.isRegularFile(OS_RELEASE_PATH)) { + String name = null; + String version = null; + try { + List lines = Files.readAllLines(OS_RELEASE_PATH, StandardCharsets.ISO_8859_1); + for (String l : lines) { + Matcher matcher = OS_RELEASE_PATTERN.matcher(l); + if (!matcher.matches()) { + continue; + } + String nameGroup = matcher.group("name"); + if ("NAME".equals(nameGroup)) { + name = matcher.group("value"); + } else if ("VERSION".equals(nameGroup)) { + version = matcher.group("value"); + } + } + } catch (IOException | AccessControlException e) { + log.debug("Could not read {}", OS_RELEASE_PATH, e); + } + if (name != null && version != null) { + return name + " " + version; + } } - } - } catch (IOException | AccessControlException e) { - log.debug("Could not read {}", OS_RELEASE_PATH, e); - } - if (name != null && version != null) { - return name + " " + version; + return SystemProperties.get("os.version"); } - } - return SystemProperties.get("os.version"); } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java b/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java index c13411e0e69..a8c1297752e 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java @@ -15,110 +15,102 @@ public class TelemetryClient { - public enum Result { - SUCCESS, - FAILURE, - NOT_FOUND, - INTERRUPTED - } - - public static TelemetryClient buildAgentClient( - OkHttpClient okHttpClient, HttpUrl agentUrl, HttpRetryPolicy.Factory httpRetryPolicy) { - HttpUrl agentTelemetryUrl = - agentUrl.newBuilder().addPathSegments(AGENT_TELEMETRY_API_ENDPOINT).build(); - return new TelemetryClient(okHttpClient, httpRetryPolicy, agentTelemetryUrl, null); - } - - public static TelemetryClient buildIntakeClient( - Config config, HttpRetryPolicy.Factory httpRetryPolicy) { - String apiKey = config.getApiKey(); - if (apiKey == null) { - log.debug("Cannot create Telemetry Intake because DD_API_KEY unspecified."); - return null; + public enum Result { + SUCCESS, + FAILURE, + NOT_FOUND, + INTERRUPTED } - String telemetryUrl = buildIntakeTelemetryUrl(config); - HttpUrl url; - try { - url = HttpUrl.get(telemetryUrl); - } catch (IllegalArgumentException e) { - log.error("Can't create Telemetry URL for {}", telemetryUrl); - return null; + public static TelemetryClient buildAgentClient( + OkHttpClient okHttpClient, HttpUrl agentUrl, HttpRetryPolicy.Factory httpRetryPolicy) { + HttpUrl agentTelemetryUrl = agentUrl.newBuilder() + .addPathSegments(AGENT_TELEMETRY_API_ENDPOINT) + .build(); + return new TelemetryClient(okHttpClient, httpRetryPolicy, agentTelemetryUrl, null); } - long timeoutMillis = TimeUnit.SECONDS.toMillis(config.getAgentTimeout()); - OkHttpClient httpClient = OkHttpUtils.buildHttpClient(url, timeoutMillis); - return new TelemetryClient(httpClient, httpRetryPolicy, url, apiKey); - } - - private static String buildIntakeTelemetryUrl(Config config) { - if (config.isCiVisibilityEnabled() && config.isCiVisibilityAgentlessEnabled()) { - String agentlessUrl = config.getCiVisibilityAgentlessUrl(); - if (Strings.isNotBlank(agentlessUrl)) { - return agentlessUrl + "/api/v2/apmtelemetry"; - } + public static TelemetryClient buildIntakeClient(Config config, HttpRetryPolicy.Factory httpRetryPolicy) { + String apiKey = config.getApiKey(); + if (apiKey == null) { + log.debug("Cannot create Telemetry Intake because DD_API_KEY unspecified."); + return null; + } + + String telemetryUrl = buildIntakeTelemetryUrl(config); + HttpUrl url; + try { + url = HttpUrl.get(telemetryUrl); + } catch (IllegalArgumentException e) { + log.error("Can't create Telemetry URL for {}", telemetryUrl); + return null; + } + + long timeoutMillis = TimeUnit.SECONDS.toMillis(config.getAgentTimeout()); + OkHttpClient httpClient = OkHttpUtils.buildHttpClient(url, timeoutMillis); + return new TelemetryClient(httpClient, httpRetryPolicy, url, apiKey); } - return config.getDefaultTelemetryUrl(); - } - - private static final Logger log = LoggerFactory.getLogger(TelemetryClient.class); - - private static final String AGENT_TELEMETRY_API_ENDPOINT = "telemetry/proxy/api/v2/apmtelemetry"; - private static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; - - private final OkHttpClient okHttpClient; - private final HttpRetryPolicy.Factory httpRetryPolicy; - private final HttpUrl url; - private final String apiKey; - - public TelemetryClient( - OkHttpClient okHttpClient, - HttpRetryPolicy.Factory httpRetryPolicy, - HttpUrl url, - String apiKey) { - this.okHttpClient = okHttpClient; - this.httpRetryPolicy = httpRetryPolicy; - this.url = url; - this.apiKey = apiKey; - } - - public HttpUrl getUrl() { - return url; - } - - public Result sendHttpRequest(Request.Builder httpRequestBuilder) { - httpRequestBuilder.url(url); - if (apiKey != null) { - httpRequestBuilder.addHeader("DD-API-KEY", apiKey); + + private static String buildIntakeTelemetryUrl(Config config) { + if (config.isCiVisibilityEnabled() && config.isCiVisibilityAgentlessEnabled()) { + String agentlessUrl = config.getCiVisibilityAgentlessUrl(); + if (Strings.isNotBlank(agentlessUrl)) { + return agentlessUrl + "/api/v2/apmtelemetry"; + } + } + return config.getDefaultTelemetryUrl(); } - Request httpRequest = httpRequestBuilder.build(); - String requestType = httpRequest.header(DD_TELEMETRY_REQUEST_TYPE); - - try (okhttp3.Response response = - OkHttpUtils.sendWithRetries(okHttpClient, httpRetryPolicy, httpRequest)) { - if (response.code() == 404) { - log.debug("Telemetry endpoint is disabled, dropping {} message.", requestType); - return Result.NOT_FOUND; - } - if (!response.isSuccessful()) { - log.debug( - "Telemetry message {} failed with: {} {}.", - requestType, - response.code(), - response.message()); - return Result.FAILURE; - } - } catch (InterruptedIOException e) { - log.debug("Telemetry message {} sending interrupted: {}.", requestType, e.toString()); - return Result.INTERRUPTED; - - } catch (IOException e) { - log.debug("Telemetry message {} failed with exception: {}.", requestType, e.toString()); - return Result.FAILURE; + private static final Logger log = LoggerFactory.getLogger(TelemetryClient.class); + + private static final String AGENT_TELEMETRY_API_ENDPOINT = "telemetry/proxy/api/v2/apmtelemetry"; + private static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; + + private final OkHttpClient okHttpClient; + private final HttpRetryPolicy.Factory httpRetryPolicy; + private final HttpUrl url; + private final String apiKey; + + public TelemetryClient( + OkHttpClient okHttpClient, HttpRetryPolicy.Factory httpRetryPolicy, HttpUrl url, String apiKey) { + this.okHttpClient = okHttpClient; + this.httpRetryPolicy = httpRetryPolicy; + this.url = url; + this.apiKey = apiKey; } - log.debug("Telemetry message {} sent successfully to {}.", requestType, url); - return Result.SUCCESS; - } + public HttpUrl getUrl() { + return url; + } + + public Result sendHttpRequest(Request.Builder httpRequestBuilder) { + httpRequestBuilder.url(url); + if (apiKey != null) { + httpRequestBuilder.addHeader("DD-API-KEY", apiKey); + } + + Request httpRequest = httpRequestBuilder.build(); + String requestType = httpRequest.header(DD_TELEMETRY_REQUEST_TYPE); + + try (okhttp3.Response response = OkHttpUtils.sendWithRetries(okHttpClient, httpRetryPolicy, httpRequest)) { + if (response.code() == 404) { + log.debug("Telemetry endpoint is disabled, dropping {} message.", requestType); + return Result.NOT_FOUND; + } + if (!response.isSuccessful()) { + log.debug("Telemetry message {} failed with: {} {}.", requestType, response.code(), response.message()); + return Result.FAILURE; + } + } catch (InterruptedIOException e) { + log.debug("Telemetry message {} sending interrupted: {}.", requestType, e.toString()); + return Result.INTERRUPTED; + + } catch (IOException e) { + log.debug("Telemetry message {} failed with exception: {}.", requestType, e.toString()); + return Result.FAILURE; + } + + log.debug("Telemetry message {} sent successfully to {}.", requestType, url); + return Result.SUCCESS; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRequest.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRequest.java index 5c756d73ad1..1b724123678 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryRequest.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRequest.java @@ -27,249 +27,248 @@ public class TelemetryRequest { - private static final Logger log = LoggerFactory.getLogger(TelemetryRequest.class); + private static final Logger log = LoggerFactory.getLogger(TelemetryRequest.class); - static final String API_VERSION = "v2"; - static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + static final String API_VERSION = "v2"; + static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); - private final EventSource eventSource; - private final EventSink eventSink; - private final long messageBytesSoftLimit; - private final RequestType requestType; - private final boolean debug; - private final TelemetryRequestBody requestBody; + private final EventSource eventSource; + private final EventSink eventSink; + private final long messageBytesSoftLimit; + private final RequestType requestType; + private final boolean debug; + private final TelemetryRequestBody requestBody; - TelemetryRequest( - EventSource eventSource, - EventSink eventSink, - long messageBytesSoftLimit, - RequestType requestType, - boolean debug) { - this.eventSource = eventSource; - this.eventSink = eventSink; - this.messageBytesSoftLimit = messageBytesSoftLimit; - this.requestType = requestType; - this.debug = debug; - this.requestBody = new TelemetryRequestBody(requestType); - this.requestBody.beginRequest(debug); - } + TelemetryRequest( + EventSource eventSource, + EventSink eventSink, + long messageBytesSoftLimit, + RequestType requestType, + boolean debug) { + this.eventSource = eventSource; + this.eventSink = eventSink; + this.messageBytesSoftLimit = messageBytesSoftLimit; + this.requestType = requestType; + this.debug = debug; + this.requestBody = new TelemetryRequestBody(requestType); + this.requestBody.beginRequest(debug); + } - public Request.Builder httpRequest() { - long bodySize = requestBody.endRequest(); + public Request.Builder httpRequest() { + long bodySize = requestBody.endRequest(); - Request.Builder builder = - new Request.Builder() - .addHeader("Content-Type", String.valueOf(JSON)) - .addHeader("Content-Length", String.valueOf(bodySize)) - .addHeader("DD-Telemetry-API-Version", API_VERSION) - .addHeader("DD-Telemetry-Request-Type", String.valueOf(this.requestType)) - .addHeader("DD-Client-Library-Language", DDTags.LANGUAGE_TAG_VALUE) - .addHeader("DD-Client-Library-Version", TracerVersion.TRACER_VERSION) - .post(requestBody); + Request.Builder builder = new Request.Builder() + .addHeader("Content-Type", String.valueOf(JSON)) + .addHeader("Content-Length", String.valueOf(bodySize)) + .addHeader("DD-Telemetry-API-Version", API_VERSION) + .addHeader("DD-Telemetry-Request-Type", String.valueOf(this.requestType)) + .addHeader("DD-Client-Library-Language", DDTags.LANGUAGE_TAG_VALUE) + .addHeader("DD-Client-Library-Version", TracerVersion.TRACER_VERSION) + .post(requestBody); - final String containerId = ContainerInfo.get().getContainerId(); - if (containerId != null) { - builder.addHeader("Datadog-Container-ID", containerId); - } - final String entityId = ContainerInfo.getEntityId(); - if (entityId != null) { - builder.addHeader("Datadog-Entity-ID", entityId); - } - - if (debug) { - builder.addHeader("DD-Telemetry-Debug-Enabled", "true"); - } + final String containerId = ContainerInfo.get().getContainerId(); + if (containerId != null) { + builder.addHeader("Datadog-Container-ID", containerId); + } + final String entityId = ContainerInfo.getEntityId(); + if (entityId != null) { + builder.addHeader("Datadog-Entity-ID", entityId); + } - Config config = Config.get(); - String sessionId = config.getRuntimeId(); - if (sessionId != null && !sessionId.isEmpty()) { - builder.addHeader("DD-Session-ID", sessionId); - } - String rootSessionId = config.getRootSessionId(); - if (rootSessionId != null && !rootSessionId.equals(sessionId)) { - builder.addHeader("DD-Root-Session-ID", rootSessionId); - } + if (debug) { + builder.addHeader("DD-Telemetry-Debug-Enabled", "true"); + } - return builder; - } + Config config = Config.get(); + String sessionId = config.getRuntimeId(); + if (sessionId != null && !sessionId.isEmpty()) { + builder.addHeader("DD-Session-ID", sessionId); + } + String rootSessionId = config.getRootSessionId(); + if (rootSessionId != null && !rootSessionId.equals(sessionId)) { + builder.addHeader("DD-Root-Session-ID", rootSessionId); + } - public void writeConfigurations() { - if (!isWithinSizeLimits() || !eventSource.hasConfigChangeEvent()) { - return; + return builder; } - try { - requestBody.beginConfiguration(); - while (eventSource.hasConfigChangeEvent() && isWithinSizeLimits()) { - ConfigSetting event = eventSource.nextConfigChangeEvent(); - requestBody.writeConfiguration(event); - eventSink.addConfigChangeEvent(event); - } - requestBody.endConfiguration(); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("configuration-object", e); + + public void writeConfigurations() { + if (!isWithinSizeLimits() || !eventSource.hasConfigChangeEvent()) { + return; + } + try { + requestBody.beginConfiguration(); + while (eventSource.hasConfigChangeEvent() && isWithinSizeLimits()) { + ConfigSetting event = eventSource.nextConfigChangeEvent(); + requestBody.writeConfiguration(event); + eventSink.addConfigChangeEvent(event); + } + requestBody.endConfiguration(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("configuration-object", e); + } } - } - public void writeProducts() { - try { - requestBody.writeProducts( - InstrumenterConfig.get().getAppSecActivation() != ProductActivation.FULLY_DISABLED, - InstrumenterConfig.get().isProfilingEnabled(), - Config.get().isDynamicInstrumentationEnabled()); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("products", e); + public void writeProducts() { + try { + requestBody.writeProducts( + InstrumenterConfig.get().getAppSecActivation() != ProductActivation.FULLY_DISABLED, + InstrumenterConfig.get().isProfilingEnabled(), + Config.get().isDynamicInstrumentationEnabled()); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("products", e); + } } - } - public void writeInstallSignature() { - String installId = ConfigHelper.env("DD_INSTRUMENTATION_INSTALL_ID"); - String installType = ConfigHelper.env("DD_INSTRUMENTATION_INSTALL_TYPE"); - String installTime = ConfigHelper.env("DD_INSTRUMENTATION_INSTALL_TIME"); + public void writeInstallSignature() { + String installId = ConfigHelper.env("DD_INSTRUMENTATION_INSTALL_ID"); + String installType = ConfigHelper.env("DD_INSTRUMENTATION_INSTALL_TYPE"); + String installTime = ConfigHelper.env("DD_INSTRUMENTATION_INSTALL_TIME"); - try { - requestBody.writeInstallSignature(installId, installType, installTime); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("install-signature", e); + try { + requestBody.writeInstallSignature(installId, installType, installTime); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("install-signature", e); + } } - } - public void writeIntegrations() { - if (!isWithinSizeLimits() || !eventSource.hasIntegrationEvent()) { - return; - } - try { - requestBody.beginIntegrations(); - while (eventSource.hasIntegrationEvent() && isWithinSizeLimits()) { - Integration event = eventSource.nextIntegrationEvent(); - requestBody.writeIntegration(event); - eventSink.addIntegrationEvent(event); - } - requestBody.endIntegrations(); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("integrations-message", e); + public void writeIntegrations() { + if (!isWithinSizeLimits() || !eventSource.hasIntegrationEvent()) { + return; + } + try { + requestBody.beginIntegrations(); + while (eventSource.hasIntegrationEvent() && isWithinSizeLimits()) { + Integration event = eventSource.nextIntegrationEvent(); + requestBody.writeIntegration(event); + eventSink.addIntegrationEvent(event); + } + requestBody.endIntegrations(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("integrations-message", e); + } } - } - public void writeDependencies() { - if (!isWithinSizeLimits() || !eventSource.hasDependencyEvent()) { - return; - } - try { - requestBody.beginDependencies(); - while (eventSource.hasDependencyEvent() && isWithinSizeLimits()) { - Dependency event = eventSource.nextDependencyEvent(); - requestBody.writeDependency(event); - eventSink.addDependencyEvent(event); - } - requestBody.endDependencies(); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("dependencies-message", e); + public void writeDependencies() { + if (!isWithinSizeLimits() || !eventSource.hasDependencyEvent()) { + return; + } + try { + requestBody.beginDependencies(); + while (eventSource.hasDependencyEvent() && isWithinSizeLimits()) { + Dependency event = eventSource.nextDependencyEvent(); + requestBody.writeDependency(event); + eventSink.addDependencyEvent(event); + } + requestBody.endDependencies(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("dependencies-message", e); + } } - } - public void writeMetrics() { - if (!isWithinSizeLimits() || !eventSource.hasMetricEvent()) { - return; - } - try { - requestBody.beginMetrics(); - while (eventSource.hasMetricEvent() && isWithinSizeLimits()) { - Metric event = eventSource.nextMetricEvent(); - requestBody.writeMetric(event); - eventSink.addMetricEvent(event); - } - requestBody.endMetrics(); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("metrics-message", e); + public void writeMetrics() { + if (!isWithinSizeLimits() || !eventSource.hasMetricEvent()) { + return; + } + try { + requestBody.beginMetrics(); + while (eventSource.hasMetricEvent() && isWithinSizeLimits()) { + Metric event = eventSource.nextMetricEvent(); + requestBody.writeMetric(event); + eventSink.addMetricEvent(event); + } + requestBody.endMetrics(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("metrics-message", e); + } } - } - public void writeDistributions() { - if (!isWithinSizeLimits() || !eventSource.hasDistributionSeriesEvent()) { - return; - } - try { - requestBody.beginDistributions(); - while (eventSource.hasDistributionSeriesEvent() && isWithinSizeLimits()) { - DistributionSeries event = eventSource.nextDistributionSeriesEvent(); - requestBody.writeDistribution(event); - eventSink.addDistributionSeriesEvent(event); - } - requestBody.endDistributions(); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("distributions-message", e); + public void writeDistributions() { + if (!isWithinSizeLimits() || !eventSource.hasDistributionSeriesEvent()) { + return; + } + try { + requestBody.beginDistributions(); + while (eventSource.hasDistributionSeriesEvent() && isWithinSizeLimits()) { + DistributionSeries event = eventSource.nextDistributionSeriesEvent(); + requestBody.writeDistribution(event); + eventSink.addDistributionSeriesEvent(event); + } + requestBody.endDistributions(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("distributions-message", e); + } } - } - public void writeLogs() { - if (!isWithinSizeLimits() || !eventSource.hasLogMessageEvent()) { - return; - } - try { - requestBody.beginLogs(); - while (eventSource.hasLogMessageEvent() && isWithinSizeLimits()) { - LogMessage event = eventSource.nextLogMessageEvent(); - requestBody.writeLog(event); - eventSink.addLogMessageEvent(event); - } - requestBody.endLogs(); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("logs-message", e); + public void writeLogs() { + if (!isWithinSizeLimits() || !eventSource.hasLogMessageEvent()) { + return; + } + try { + requestBody.beginLogs(); + while (eventSource.hasLogMessageEvent() && isWithinSizeLimits()) { + LogMessage event = eventSource.nextLogMessageEvent(); + requestBody.writeLog(event); + eventSink.addLogMessageEvent(event); + } + requestBody.endLogs(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("logs-message", e); + } } - } - public void writeChangedProducts() { - if (!isWithinSizeLimits() || !eventSource.hasProductChangeEvent()) { - return; - } - try { - log.debug("Writing changed products"); - requestBody.beginProducts(); - Map products = new EnumMap<>(ProductType.class); - while (eventSource.hasProductChangeEvent() && isWithinSizeLimits()) { - ProductChange event = eventSource.nextProductChangeEvent(); - products.put(event.getProductType(), event.isEnabled()); - eventSink.addProductChangeEvent(event); - } - requestBody.writeProducts(products); - requestBody.endProducts(); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("changed-products", e); + public void writeChangedProducts() { + if (!isWithinSizeLimits() || !eventSource.hasProductChangeEvent()) { + return; + } + try { + log.debug("Writing changed products"); + requestBody.beginProducts(); + Map products = new EnumMap<>(ProductType.class); + while (eventSource.hasProductChangeEvent() && isWithinSizeLimits()) { + ProductChange event = eventSource.nextProductChangeEvent(); + products.put(event.getProductType(), event.isEnabled()); + eventSink.addProductChangeEvent(event); + } + requestBody.writeProducts(products); + requestBody.endProducts(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("changed-products", e); + } } - } - public void writeEndpoints() { - if (!isWithinSizeLimits() || !eventSource.hasEndpoint()) { - return; - } - try { - log.debug("Writing endpoints"); - requestBody.beginEndpoints(); - boolean first = false; - int remaining = Config.get().getApiSecurityEndpointCollectionMessageLimit(); - while (eventSource.hasEndpoint() && remaining > 0) { - final Endpoint event = eventSource.nextEndpoint(); - remaining--; - if (event.isFirst()) { - first = true; + public void writeEndpoints() { + if (!isWithinSizeLimits() || !eventSource.hasEndpoint()) { + return; } - requestBody.writeEndpoint(event); - eventSink.addEndpointEvent(event); - if (!isWithinSizeLimits()) { - break; + try { + log.debug("Writing endpoints"); + requestBody.beginEndpoints(); + boolean first = false; + int remaining = Config.get().getApiSecurityEndpointCollectionMessageLimit(); + while (eventSource.hasEndpoint() && remaining > 0) { + final Endpoint event = eventSource.nextEndpoint(); + remaining--; + if (event.isFirst()) { + first = true; + } + requestBody.writeEndpoint(event); + eventSink.addEndpointEvent(event); + if (!isWithinSizeLimits()) { + break; + } + } + requestBody.endEndpoints(first); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("asm-endpoints", e); } - } - requestBody.endEndpoints(first); - } catch (IOException e) { - throw new TelemetryRequestBody.SerializationException("asm-endpoints", e); } - } - public void writeHeartbeat() { - requestBody.writeHeartbeatEvent(); - } + public void writeHeartbeat() { + requestBody.writeHeartbeatEvent(); + } - private boolean isWithinSizeLimits() { - return requestBody.size() < messageBytesSoftLimit; - } + private boolean isWithinSizeLimits() { + return requestBody.size() < messageBytesSoftLimit; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java index 1926c3ff8c8..8f7970a351e 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java @@ -27,405 +27,403 @@ public class TelemetryRequestBody extends RequestBody { - public static class SerializationException extends RuntimeException { - public SerializationException(String requestPartName, Throwable cause) { - super("Failed serializing Telemetry " + requestPartName + " part!", cause); - } - } - - private static final AtomicLong SEQ_ID = new AtomicLong(); - private static final String TELEMETRY_NAMESPACE_TAG_TRACER = "tracers"; - - private final RequestType requestType; - private final Buffer body; - private final JsonWriter bodyWriter; - - /** Exists in a separate class to avoid startup toll */ - private static class CommonData { - final Config config = Config.get(); - final String env = config.getEnv(); - final String langVersion = JavaVirtualMachine.getLangVersion(); - final String runtimeName = JavaVirtualMachine.getRuntimeVendor(); - final String runtimePatches = JavaVirtualMachine.getRuntimePatches(); - final String runtimeVersion = JavaVirtualMachine.getRuntimeVersion(); - final String serviceName = config.getServiceName(); - final String serviceVersion = config.getVersion(); - final String runtimeId = config.getRuntimeId(); - final String architecture = HostInfo.getArchitecture(); - final String hostname = HostInfo.getHostname(); - final String kernelName = HostInfo.getKernelName(); - final String kernelRelease = HostInfo.getKernelRelease(); - final String kernelVersion = HostInfo.getKernelVersion(); - final String osName = HostInfo.getOsName(); - final String osVersion = HostInfo.getOsVersion(); - } - - private static final CommonData commonData = new CommonData(); - - TelemetryRequestBody(RequestType requestType) { - this.requestType = requestType; - this.body = new Buffer(); - this.bodyWriter = JsonWriter.of(body); - } - - public void beginRequest(boolean debug) { - try { - bodyWriter.beginObject(); - bodyWriter.name("api_version").value(TelemetryRequest.API_VERSION); - // naming_schema_version - optional - bodyWriter.name("runtime_id").value(commonData.runtimeId); - bodyWriter.name("seq_id").value(SEQ_ID.incrementAndGet()); - bodyWriter.name("tracer_time").value(System.currentTimeMillis() / 1000L); - - bodyWriter.name("application"); - bodyWriter.beginObject(); - bodyWriter.name("service_name").value(commonData.serviceName); - bodyWriter.name("env").value(commonData.env); - bodyWriter.name("service_version").value(commonData.serviceVersion); - bodyWriter.name("tracer_version").value(TracerVersion.TRACER_VERSION); - bodyWriter.name("language_name").value(DDTags.LANGUAGE_TAG_VALUE); - bodyWriter.name("language_version").value(commonData.langVersion); - bodyWriter.name("runtime_name").value(commonData.runtimeName); - bodyWriter.name("runtime_version").value(commonData.runtimeVersion); - bodyWriter.name("runtime_patches").value(commonData.runtimePatches); // optional - final CharSequence processTags = ProcessTags.getTagsForSerialization(); - if (processTags != null) { - bodyWriter.name("process_tags").value(processTags.toString()); - } - bodyWriter.endObject(); - - if (debug) { - bodyWriter.name("debug").value(true); - } - bodyWriter.name("host"); - bodyWriter.beginObject(); - bodyWriter.name("hostname").value(commonData.hostname); - bodyWriter.name("os").value(commonData.osName); - bodyWriter.name("os_version").value(commonData.osVersion); // optional - bodyWriter.name("architecture").value(commonData.architecture); - // only applicable to UNIX based OS - bodyWriter.name("kernel_name").value(commonData.kernelName); - bodyWriter.name("kernel_release").value(commonData.kernelRelease); - bodyWriter.name("kernel_version").value(commonData.kernelVersion); - bodyWriter.endObject(); - - bodyWriter.name("request_type").value(this.requestType.toString()); - - switch (this.requestType) { - case APP_STARTED: - case APP_EXTENDED_HEARTBEAT: - bodyWriter.name("payload"); - bodyWriter.beginObject(); - break; - case MESSAGE_BATCH: - bodyWriter.name("payload"); - bodyWriter.beginArray(); - break; - default: - } - } catch (Exception ex) { - throw new SerializationException("begin-request", ex); - } - } - - /** - * @return body size in bytes - */ - public long endRequest() { - try { - switch (this.requestType) { - case APP_STARTED: - case APP_EXTENDED_HEARTBEAT: - bodyWriter.endObject(); // payload - break; - case MESSAGE_BATCH: - bodyWriter.endArray(); // payloads - break; - default: - } - bodyWriter.endObject(); // request - return body.size(); - } catch (Exception ex) { - throw new SerializationException("end-request", ex); - } - } - - public void writeHeartbeatEvent() { - beginMessageIfBatch(RequestType.APP_HEARTBEAT); - endMessageIfBatch(RequestType.APP_HEARTBEAT); - } - - public void beginMetrics() throws IOException { - beginMessageIfBatch(RequestType.GENERATE_METRICS); - bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); - bodyWriter.name("series").beginArray(); - } - - public void writeMetric(Metric m) throws IOException { - bodyWriter.beginObject(); - bodyWriter.name("metric").value(m.getMetric()); - bodyWriter.name("points").jsonValue(m.getPoints()); - // interval - optional - if (m.getType() != null) bodyWriter.name("type").value(m.getType().toString()); - bodyWriter.name("tags").jsonValue(m.getTags()); // optional - bodyWriter.name("common").value(m.getCommon()); // optional - bodyWriter.name("namespace").value(m.getNamespace()); // optional - bodyWriter.endObject(); - } - - public void endMetrics() throws IOException { - bodyWriter.endArray(); - endMessageIfBatch(RequestType.GENERATE_METRICS); - } - - public void beginDistributions() throws IOException { - beginMessageIfBatch(RequestType.DISTRIBUTIONS); - bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); - bodyWriter.name("series").beginArray(); - } - - public void writeDistribution(DistributionSeries ds) throws IOException { - bodyWriter.beginObject(); - bodyWriter.name("metric").value(ds.getMetric()); - bodyWriter.name("points").jsonValue(ds.getPoints()); - bodyWriter.name("tags").jsonValue(ds.getTags()); - bodyWriter.name("common").value(ds.getCommon()); - bodyWriter.name("namespace").value(ds.getNamespace()); - bodyWriter.endObject(); - } - - public void endDistributions() throws IOException { - bodyWriter.endArray(); - endMessageIfBatch(RequestType.DISTRIBUTIONS); - } - - public void beginLogs() throws IOException { - beginMessageIfBatch(RequestType.LOGS); - bodyWriter.name("logs").beginArray(); - } - - public void writeLog(LogMessage m) throws IOException { - bodyWriter.beginObject(); - bodyWriter.name("message").value(m.getMessage()); - bodyWriter.name("level").value(String.valueOf(m.getLevel())); - bodyWriter.name("tags").value(m.getTags()); // optional - bodyWriter.name("stack_trace").value(m.getStackTrace()); // optional - bodyWriter.name("tracer_time").value(m.getTracerTime()); // optional - bodyWriter.name("count").value(m.getCount()); // optional - bodyWriter.endObject(); - } - - public void endLogs() throws IOException { - bodyWriter.endArray(); - endMessageIfBatch(RequestType.LOGS); - } - - public void beginConfiguration() throws IOException { - beginMessageIfBatch(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); - bodyWriter.name("configuration").beginArray(); - } - - public void writeConfiguration(ConfigSetting configSetting) throws IOException { - bodyWriter.beginObject(); - bodyWriter.name("name").value(configSetting.key); - bodyWriter.setSerializeNulls(true); - bodyWriter.name("value").value(configSetting.stringValue()); - bodyWriter.setSerializeNulls(false); - bodyWriter.name("origin").value(configSetting.origin.value); - bodyWriter.name("seq_id").value(configSetting.seqId); - if (configSetting.configId != null) { - bodyWriter.name("config_id").value(configSetting.configId); - } - bodyWriter.endObject(); - } - - public void endConfiguration() throws IOException { - bodyWriter.endArray(); - endMessageIfBatch(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); - } - - public void beginIntegrations() throws IOException { - beginMessageIfBatch(RequestType.APP_INTEGRATIONS_CHANGE); - bodyWriter.name("integrations").beginArray(); - } - - public void writeIntegration(Integration i) throws IOException { - bodyWriter.beginObject(); - bodyWriter.name("enabled").value(i.enabled); - bodyWriter.name("name").value(i.name); - bodyWriter.endObject(); - } - - public void endIntegrations() throws IOException { - bodyWriter.endArray(); - endMessageIfBatch(RequestType.APP_INTEGRATIONS_CHANGE); - } - - public void beginDependencies() throws IOException { - beginMessageIfBatch(RequestType.APP_DEPENDENCIES_LOADED); - bodyWriter.name("dependencies").beginArray(); - } - - public void writeDependency(Dependency d) throws IOException { - bodyWriter.beginObject(); - bodyWriter.name("hash").value(d.hash); // optional - bodyWriter.name("name").value(d.name); - bodyWriter.name("version").value(d.version); // optional - if (d.reachabilityMetadata != null) { - // Write metadata array even when empty: empty list signals "SCA is active for this dep" - // (RFC: all deps get metadata:[] at startup when DD_APPSEC_SCA_ENABLED=true). - // Null means SCA is disabled; empty list means SCA is enabled but no CVEs detected yet. - bodyWriter.name("metadata").beginArray(); - for (String value : d.reachabilityMetadata) { + public static class SerializationException extends RuntimeException { + public SerializationException(String requestPartName, Throwable cause) { + super("Failed serializing Telemetry " + requestPartName + " part!", cause); + } + } + + private static final AtomicLong SEQ_ID = new AtomicLong(); + private static final String TELEMETRY_NAMESPACE_TAG_TRACER = "tracers"; + + private final RequestType requestType; + private final Buffer body; + private final JsonWriter bodyWriter; + + /** Exists in a separate class to avoid startup toll */ + private static class CommonData { + final Config config = Config.get(); + final String env = config.getEnv(); + final String langVersion = JavaVirtualMachine.getLangVersion(); + final String runtimeName = JavaVirtualMachine.getRuntimeVendor(); + final String runtimePatches = JavaVirtualMachine.getRuntimePatches(); + final String runtimeVersion = JavaVirtualMachine.getRuntimeVersion(); + final String serviceName = config.getServiceName(); + final String serviceVersion = config.getVersion(); + final String runtimeId = config.getRuntimeId(); + final String architecture = HostInfo.getArchitecture(); + final String hostname = HostInfo.getHostname(); + final String kernelName = HostInfo.getKernelName(); + final String kernelRelease = HostInfo.getKernelRelease(); + final String kernelVersion = HostInfo.getKernelVersion(); + final String osName = HostInfo.getOsName(); + final String osVersion = HostInfo.getOsVersion(); + } + + private static final CommonData commonData = new CommonData(); + + TelemetryRequestBody(RequestType requestType) { + this.requestType = requestType; + this.body = new Buffer(); + this.bodyWriter = JsonWriter.of(body); + } + + public void beginRequest(boolean debug) { + try { + bodyWriter.beginObject(); + bodyWriter.name("api_version").value(TelemetryRequest.API_VERSION); + // naming_schema_version - optional + bodyWriter.name("runtime_id").value(commonData.runtimeId); + bodyWriter.name("seq_id").value(SEQ_ID.incrementAndGet()); + bodyWriter.name("tracer_time").value(System.currentTimeMillis() / 1000L); + + bodyWriter.name("application"); + bodyWriter.beginObject(); + bodyWriter.name("service_name").value(commonData.serviceName); + bodyWriter.name("env").value(commonData.env); + bodyWriter.name("service_version").value(commonData.serviceVersion); + bodyWriter.name("tracer_version").value(TracerVersion.TRACER_VERSION); + bodyWriter.name("language_name").value(DDTags.LANGUAGE_TAG_VALUE); + bodyWriter.name("language_version").value(commonData.langVersion); + bodyWriter.name("runtime_name").value(commonData.runtimeName); + bodyWriter.name("runtime_version").value(commonData.runtimeVersion); + bodyWriter.name("runtime_patches").value(commonData.runtimePatches); // optional + final CharSequence processTags = ProcessTags.getTagsForSerialization(); + if (processTags != null) { + bodyWriter.name("process_tags").value(processTags.toString()); + } + bodyWriter.endObject(); + + if (debug) { + bodyWriter.name("debug").value(true); + } + bodyWriter.name("host"); + bodyWriter.beginObject(); + bodyWriter.name("hostname").value(commonData.hostname); + bodyWriter.name("os").value(commonData.osName); + bodyWriter.name("os_version").value(commonData.osVersion); // optional + bodyWriter.name("architecture").value(commonData.architecture); + // only applicable to UNIX based OS + bodyWriter.name("kernel_name").value(commonData.kernelName); + bodyWriter.name("kernel_release").value(commonData.kernelRelease); + bodyWriter.name("kernel_version").value(commonData.kernelVersion); + bodyWriter.endObject(); + + bodyWriter.name("request_type").value(this.requestType.toString()); + + switch (this.requestType) { + case APP_STARTED: + case APP_EXTENDED_HEARTBEAT: + bodyWriter.name("payload"); + bodyWriter.beginObject(); + break; + case MESSAGE_BATCH: + bodyWriter.name("payload"); + bodyWriter.beginArray(); + break; + default: + } + } catch (Exception ex) { + throw new SerializationException("begin-request", ex); + } + } + + /** + * @return body size in bytes + */ + public long endRequest() { + try { + switch (this.requestType) { + case APP_STARTED: + case APP_EXTENDED_HEARTBEAT: + bodyWriter.endObject(); // payload + break; + case MESSAGE_BATCH: + bodyWriter.endArray(); // payloads + break; + default: + } + bodyWriter.endObject(); // request + return body.size(); + } catch (Exception ex) { + throw new SerializationException("end-request", ex); + } + } + + public void writeHeartbeatEvent() { + beginMessageIfBatch(RequestType.APP_HEARTBEAT); + endMessageIfBatch(RequestType.APP_HEARTBEAT); + } + + public void beginMetrics() throws IOException { + beginMessageIfBatch(RequestType.GENERATE_METRICS); + bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); + bodyWriter.name("series").beginArray(); + } + + public void writeMetric(Metric m) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("metric").value(m.getMetric()); + bodyWriter.name("points").jsonValue(m.getPoints()); + // interval - optional + if (m.getType() != null) bodyWriter.name("type").value(m.getType().toString()); + bodyWriter.name("tags").jsonValue(m.getTags()); // optional + bodyWriter.name("common").value(m.getCommon()); // optional + bodyWriter.name("namespace").value(m.getNamespace()); // optional + bodyWriter.endObject(); + } + + public void endMetrics() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.GENERATE_METRICS); + } + + public void beginDistributions() throws IOException { + beginMessageIfBatch(RequestType.DISTRIBUTIONS); + bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); + bodyWriter.name("series").beginArray(); + } + + public void writeDistribution(DistributionSeries ds) throws IOException { bodyWriter.beginObject(); - bodyWriter.name("type").value("reachability"); - bodyWriter.name("value").value(value); // stringified JSON per RFC + bodyWriter.name("metric").value(ds.getMetric()); + bodyWriter.name("points").jsonValue(ds.getPoints()); + bodyWriter.name("tags").jsonValue(ds.getTags()); + bodyWriter.name("common").value(ds.getCommon()); + bodyWriter.name("namespace").value(ds.getNamespace()); bodyWriter.endObject(); - } - bodyWriter.endArray(); } - bodyWriter.endObject(); - } - - public void endDependencies() throws IOException { - bodyWriter.endArray(); - endMessageIfBatch(RequestType.APP_DEPENDENCIES_LOADED); - } - - public void beginProducts() throws IOException { - beginMessageIfBatch(RequestType.APP_PRODUCT_CHANGE); - } - - public void writeProducts(final Map products) throws IOException { - - if (products == null || products.isEmpty()) { - return; - } - bodyWriter.name("products"); - bodyWriter.beginObject(); - - for (Map.Entry entry : products.entrySet()) { - bodyWriter.name(entry.getKey().getName()); - bodyWriter.beginObject(); - bodyWriter.name("enabled").value(entry.getValue()); - bodyWriter.endObject(); - } - - bodyWriter.endObject(); - } - - public void writeProducts( - boolean appsecEnabled, boolean profilerEnabled, boolean dynamicInstrumentationEnabled) - throws IOException { - Map products = new EnumMap<>(ProductType.class); - products.put(ProductType.APPSEC, appsecEnabled); - products.put(ProductType.PROFILER, profilerEnabled); - products.put(ProductType.DYNAMIC_INSTRUMENTATION, dynamicInstrumentationEnabled); - writeProducts(products); - } - - public void endProducts() throws IOException { - endMessageIfBatch(RequestType.APP_PRODUCT_CHANGE); - } - - public void beginEndpoints() throws IOException { - beginMessageIfBatch(RequestType.APP_ENDPOINTS); - bodyWriter.name("endpoints"); - bodyWriter.beginArray(); - } - - public void writeEndpoint(final Endpoint endpoint) throws IOException { - bodyWriter.beginObject(); - if (endpoint.getType() != null) { - bodyWriter.name("type").value(endpoint.getType()); - } - if (endpoint.getMethod() != null) { - bodyWriter.name("method").value(endpoint.getMethod()); - } - if (endpoint.getPath() != null) { - bodyWriter.name("path").value(endpoint.getPath()); - } - bodyWriter.name("operation_name").value(endpoint.getOperation()); - bodyWriter.name("resource_name").value(endpoint.getResource()); - if (endpoint.getRequestBodyType() != null) { - bodyWriter.name("request_body_type").jsonValue(endpoint.getRequestBodyType()); - } - if (endpoint.getResponseBodyType() != null) { - bodyWriter.name("response_body_type").jsonValue(endpoint.getResponseBodyType()); - } - if (endpoint.getResponseCode() != null) { - bodyWriter.name("response_code").jsonValue(endpoint.getResponseCode()); - } - if (endpoint.getAuthentication() != null) { - bodyWriter.name("authentication").jsonValue(endpoint.getAuthentication()); - } - if (endpoint.getMetadata() != null) { - bodyWriter.name("metadata").jsonValue(endpoint.getMetadata()); - } - bodyWriter.endObject(); - } - - public void endEndpoints(final boolean first) throws IOException { - bodyWriter.endArray(); - bodyWriter.name("is_first").value(first); - endMessageIfBatch(RequestType.APP_ENDPOINTS); - } - - public void writeInstallSignature(String installId, String installType, String installTime) - throws IOException { - if (installId == null && installType == null && installTime == null) { - return; - } - bodyWriter.name("install_signature"); - bodyWriter.beginObject(); - bodyWriter.name("install_id").value(installId); - bodyWriter.name("install_type").value(installType); - bodyWriter.name("install_time").value(installTime); - bodyWriter.endObject(); - } - - @Nullable - @Override - public MediaType contentType() { - return TelemetryRequest.JSON; - } - - @Override - public void writeTo(BufferedSink sink) throws IOException { - sink.write(body, body.size()); - } - - public long size() { - return body.size(); - } - - private void beginMessageIfBatch(RequestType messageType) { - if (requestType != RequestType.MESSAGE_BATCH) { - return; - } - try { - bodyWriter.beginObject(); - bodyWriter.name("request_type").value(String.valueOf(messageType)); - if (messageType != RequestType.APP_HEARTBEAT) { - bodyWriter.name("payload"); + + public void endDistributions() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.DISTRIBUTIONS); + } + + public void beginLogs() throws IOException { + beginMessageIfBatch(RequestType.LOGS); + bodyWriter.name("logs").beginArray(); + } + + public void writeLog(LogMessage m) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("message").value(m.getMessage()); + bodyWriter.name("level").value(String.valueOf(m.getLevel())); + bodyWriter.name("tags").value(m.getTags()); // optional + bodyWriter.name("stack_trace").value(m.getStackTrace()); // optional + bodyWriter.name("tracer_time").value(m.getTracerTime()); // optional + bodyWriter.name("count").value(m.getCount()); // optional + bodyWriter.endObject(); + } + + public void endLogs() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.LOGS); + } + + public void beginConfiguration() throws IOException { + beginMessageIfBatch(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); + bodyWriter.name("configuration").beginArray(); + } + + public void writeConfiguration(ConfigSetting configSetting) throws IOException { bodyWriter.beginObject(); - } - } catch (Exception ex) { - throw new SerializationException("begin-message", ex); + bodyWriter.name("name").value(configSetting.key); + bodyWriter.setSerializeNulls(true); + bodyWriter.name("value").value(configSetting.stringValue()); + bodyWriter.setSerializeNulls(false); + bodyWriter.name("origin").value(configSetting.origin.value); + bodyWriter.name("seq_id").value(configSetting.seqId); + if (configSetting.configId != null) { + bodyWriter.name("config_id").value(configSetting.configId); + } + bodyWriter.endObject(); + } + + public void endConfiguration() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); } - } - private void endMessageIfBatch(RequestType messageType) { - if (requestType != RequestType.MESSAGE_BATCH) { - return; + public void beginIntegrations() throws IOException { + beginMessageIfBatch(RequestType.APP_INTEGRATIONS_CHANGE); + bodyWriter.name("integrations").beginArray(); } - try { - if (messageType != RequestType.APP_HEARTBEAT) { - bodyWriter.endObject(); // payload - } - bodyWriter.endObject(); // message - } catch (Exception ex) { - throw new SerializationException("end-message", ex); + + public void writeIntegration(Integration i) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("enabled").value(i.enabled); + bodyWriter.name("name").value(i.name); + bodyWriter.endObject(); + } + + public void endIntegrations() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.APP_INTEGRATIONS_CHANGE); + } + + public void beginDependencies() throws IOException { + beginMessageIfBatch(RequestType.APP_DEPENDENCIES_LOADED); + bodyWriter.name("dependencies").beginArray(); + } + + public void writeDependency(Dependency d) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("hash").value(d.hash); // optional + bodyWriter.name("name").value(d.name); + bodyWriter.name("version").value(d.version); // optional + if (d.reachabilityMetadata != null) { + // Write metadata array even when empty: empty list signals "SCA is active for this dep" + // (RFC: all deps get metadata:[] at startup when DD_APPSEC_SCA_ENABLED=true). + // Null means SCA is disabled; empty list means SCA is enabled but no CVEs detected yet. + bodyWriter.name("metadata").beginArray(); + for (String value : d.reachabilityMetadata) { + bodyWriter.beginObject(); + bodyWriter.name("type").value("reachability"); + bodyWriter.name("value").value(value); // stringified JSON per RFC + bodyWriter.endObject(); + } + bodyWriter.endArray(); + } + bodyWriter.endObject(); + } + + public void endDependencies() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.APP_DEPENDENCIES_LOADED); + } + + public void beginProducts() throws IOException { + beginMessageIfBatch(RequestType.APP_PRODUCT_CHANGE); + } + + public void writeProducts(final Map products) throws IOException { + + if (products == null || products.isEmpty()) { + return; + } + bodyWriter.name("products"); + bodyWriter.beginObject(); + + for (Map.Entry entry : products.entrySet()) { + bodyWriter.name(entry.getKey().getName()); + bodyWriter.beginObject(); + bodyWriter.name("enabled").value(entry.getValue()); + bodyWriter.endObject(); + } + + bodyWriter.endObject(); + } + + public void writeProducts(boolean appsecEnabled, boolean profilerEnabled, boolean dynamicInstrumentationEnabled) + throws IOException { + Map products = new EnumMap<>(ProductType.class); + products.put(ProductType.APPSEC, appsecEnabled); + products.put(ProductType.PROFILER, profilerEnabled); + products.put(ProductType.DYNAMIC_INSTRUMENTATION, dynamicInstrumentationEnabled); + writeProducts(products); + } + + public void endProducts() throws IOException { + endMessageIfBatch(RequestType.APP_PRODUCT_CHANGE); + } + + public void beginEndpoints() throws IOException { + beginMessageIfBatch(RequestType.APP_ENDPOINTS); + bodyWriter.name("endpoints"); + bodyWriter.beginArray(); + } + + public void writeEndpoint(final Endpoint endpoint) throws IOException { + bodyWriter.beginObject(); + if (endpoint.getType() != null) { + bodyWriter.name("type").value(endpoint.getType()); + } + if (endpoint.getMethod() != null) { + bodyWriter.name("method").value(endpoint.getMethod()); + } + if (endpoint.getPath() != null) { + bodyWriter.name("path").value(endpoint.getPath()); + } + bodyWriter.name("operation_name").value(endpoint.getOperation()); + bodyWriter.name("resource_name").value(endpoint.getResource()); + if (endpoint.getRequestBodyType() != null) { + bodyWriter.name("request_body_type").jsonValue(endpoint.getRequestBodyType()); + } + if (endpoint.getResponseBodyType() != null) { + bodyWriter.name("response_body_type").jsonValue(endpoint.getResponseBodyType()); + } + if (endpoint.getResponseCode() != null) { + bodyWriter.name("response_code").jsonValue(endpoint.getResponseCode()); + } + if (endpoint.getAuthentication() != null) { + bodyWriter.name("authentication").jsonValue(endpoint.getAuthentication()); + } + if (endpoint.getMetadata() != null) { + bodyWriter.name("metadata").jsonValue(endpoint.getMetadata()); + } + bodyWriter.endObject(); + } + + public void endEndpoints(final boolean first) throws IOException { + bodyWriter.endArray(); + bodyWriter.name("is_first").value(first); + endMessageIfBatch(RequestType.APP_ENDPOINTS); + } + + public void writeInstallSignature(String installId, String installType, String installTime) throws IOException { + if (installId == null && installType == null && installTime == null) { + return; + } + bodyWriter.name("install_signature"); + bodyWriter.beginObject(); + bodyWriter.name("install_id").value(installId); + bodyWriter.name("install_type").value(installType); + bodyWriter.name("install_time").value(installTime); + bodyWriter.endObject(); + } + + @Nullable + @Override + public MediaType contentType() { + return TelemetryRequest.JSON; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + sink.write(body, body.size()); + } + + public long size() { + return body.size(); + } + + private void beginMessageIfBatch(RequestType messageType) { + if (requestType != RequestType.MESSAGE_BATCH) { + return; + } + try { + bodyWriter.beginObject(); + bodyWriter.name("request_type").value(String.valueOf(messageType)); + if (messageType != RequestType.APP_HEARTBEAT) { + bodyWriter.name("payload"); + bodyWriter.beginObject(); + } + } catch (Exception ex) { + throw new SerializationException("begin-message", ex); + } + } + + private void endMessageIfBatch(RequestType messageType) { + if (requestType != RequestType.MESSAGE_BATCH) { + return; + } + try { + if (messageType != RequestType.APP_HEARTBEAT) { + bodyWriter.endObject(); // payload + } + bodyWriter.endObject(); // message + } catch (Exception ex) { + throw new SerializationException("end-message", ex); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java index 9a7192f9375..faa9496a8c3 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java @@ -8,85 +8,89 @@ import org.slf4j.LoggerFactory; public class TelemetryRouter { - private static final Logger log = LoggerFactory.getLogger(TelemetryRouter.class); + private static final Logger log = LoggerFactory.getLogger(TelemetryRouter.class); - @Nullable private final DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery; - private final TelemetryClient agentClient; - @Nullable private final TelemetryClient intakeClient; - private final boolean useIntakeClientByDefault; - private TelemetryClient currentClient; - private boolean errorReported; + @Nullable + private final DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery; - public TelemetryRouter( - DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery, - TelemetryClient agentClient, - @Nullable TelemetryClient intakeClient, - boolean useIntakeClientByDefault) { - this.ddAgentFeaturesDiscovery = ddAgentFeaturesDiscovery; - this.agentClient = agentClient; - this.intakeClient = intakeClient; - this.useIntakeClientByDefault = useIntakeClientByDefault; - this.currentClient = useIntakeClientByDefault ? intakeClient : agentClient; - } + private final TelemetryClient agentClient; - /** - * Single-client constructor used for CiVis' Bazel mode file-based telemetry. Feature discovery - * and client-switching logic are skipped. - */ - public TelemetryRouter(TelemetryClient singleClient) { - this.ddAgentFeaturesDiscovery = null; - this.agentClient = singleClient; - this.intakeClient = null; - this.useIntakeClientByDefault = false; - this.currentClient = singleClient; - } + @Nullable + private final TelemetryClient intakeClient; - public TelemetryClient.Result sendRequest(TelemetryRequest request) { - if (ddAgentFeaturesDiscovery == null) { - return currentClient.sendHttpRequest(request.httpRequest()); + private final boolean useIntakeClientByDefault; + private TelemetryClient currentClient; + private boolean errorReported; + + public TelemetryRouter( + DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery, + TelemetryClient agentClient, + @Nullable TelemetryClient intakeClient, + boolean useIntakeClientByDefault) { + this.ddAgentFeaturesDiscovery = ddAgentFeaturesDiscovery; + this.agentClient = agentClient; + this.intakeClient = intakeClient; + this.useIntakeClientByDefault = useIntakeClientByDefault; + this.currentClient = useIntakeClientByDefault ? intakeClient : agentClient; } - ddAgentFeaturesDiscovery.discoverIfOutdated(); - boolean agentSupportsTelemetryProxy = ddAgentFeaturesDiscovery.supportsTelemetryProxy(); - Request.Builder httpRequestBuilder = request.httpRequest(); - TelemetryClient.Result result = currentClient.sendHttpRequest(httpRequestBuilder); + /** + * Single-client constructor used for CiVis' Bazel mode file-based telemetry. Feature discovery + * and client-switching logic are skipped. + */ + public TelemetryRouter(TelemetryClient singleClient) { + this.ddAgentFeaturesDiscovery = null; + this.agentClient = singleClient; + this.intakeClient = null; + this.useIntakeClientByDefault = false; + this.currentClient = singleClient; + } - boolean requestFailed = - result != TelemetryClient.Result.SUCCESS - // interrupted request is most likely due to telemetry system shutdown, - // we do not want to log errors and reattempt in this case - && result != TelemetryClient.Result.INTERRUPTED; - if (currentClient == agentClient) { - if (requestFailed) { - reportErrorOnce(currentClient.getUrl(), result); - if (intakeClient != null) { - log.info("Agent Telemetry endpoint failed. Telemetry will be sent to Intake."); - errorReported = false; - currentClient = intakeClient; + public TelemetryClient.Result sendRequest(TelemetryRequest request) { + if (ddAgentFeaturesDiscovery == null) { + return currentClient.sendHttpRequest(request.httpRequest()); } - } - } else { - if (requestFailed) { - reportErrorOnce(currentClient.getUrl(), result); - } - if ((agentSupportsTelemetryProxy && !useIntakeClientByDefault) || requestFailed) { - errorReported = false; - if (requestFailed) { - log.info("Intake Telemetry endpoint failed. Telemetry will be sent to Agent."); + ddAgentFeaturesDiscovery.discoverIfOutdated(); + boolean agentSupportsTelemetryProxy = ddAgentFeaturesDiscovery.supportsTelemetryProxy(); + + Request.Builder httpRequestBuilder = request.httpRequest(); + TelemetryClient.Result result = currentClient.sendHttpRequest(httpRequestBuilder); + + boolean requestFailed = result != TelemetryClient.Result.SUCCESS + // interrupted request is most likely due to telemetry system shutdown, + // we do not want to log errors and reattempt in this case + && result != TelemetryClient.Result.INTERRUPTED; + if (currentClient == agentClient) { + if (requestFailed) { + reportErrorOnce(currentClient.getUrl(), result); + if (intakeClient != null) { + log.info("Agent Telemetry endpoint failed. Telemetry will be sent to Intake."); + errorReported = false; + currentClient = intakeClient; + } + } } else { - log.info("Agent Telemetry endpoint is now available. Telemetry will be sent to Agent."); + if (requestFailed) { + reportErrorOnce(currentClient.getUrl(), result); + } + if ((agentSupportsTelemetryProxy && !useIntakeClientByDefault) || requestFailed) { + errorReported = false; + if (requestFailed) { + log.info("Intake Telemetry endpoint failed. Telemetry will be sent to Agent."); + } else { + log.info("Agent Telemetry endpoint is now available. Telemetry will be sent to Agent."); + } + currentClient = agentClient; + } } - currentClient = agentClient; - } - } - return result; - } + return result; + } - private void reportErrorOnce(HttpUrl requestUrl, TelemetryClient.Result result) { - if (!errorReported) { - log.warn("Got {} sending telemetry request to {}.", result, requestUrl); - errorReported = true; + private void reportErrorOnce(HttpUrl requestUrl, TelemetryClient.Result result) { + if (!errorReported) { + log.warn("Got {} sending telemetry request to {}.", result, requestUrl); + errorReported = true; + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java index e991ee5ee52..e215b76e4f6 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java @@ -15,257 +15,252 @@ public class TelemetryRunnable implements Runnable { - private static final Logger log = LoggerFactory.getLogger(TelemetryRunnable.class); - private static final int APP_STARTED_RETRIES = 3; - private static final int APP_STARTED_PAUSE_BETWEEN_RETRIES_MILLIS = 500; - private static final int MAX_CONSECUTIVE_REQUESTS = 3; - - private final TelemetryService telemetryService; - private final List actions; - private final List actionsAtMetricsInterval; - private final Scheduler scheduler; - private boolean startupEventSent; - - public TelemetryRunnable( - final TelemetryService telemetryService, final List actions) { - this(telemetryService, actions, new ThreadSleeperImpl(), SystemTimeSource.INSTANCE); - } - - TelemetryRunnable( - final TelemetryService telemetryService, - final List actions, - final ThreadSleeper sleeper, - final TimeSource timeSource) { - this.telemetryService = telemetryService; - this.actions = actions; - this.actionsAtMetricsInterval = findMetricPeriodicActions(actions); - this.scheduler = - new Scheduler( - timeSource, - sleeper, - (long) (Config.get().getTelemetryHeartbeatInterval() * 1000), - (long) (Config.get().getTelemetryMetricsInterval() * 1000), - Config.get().getTelemetryExtendedHeartbeatInterval() * 1000); - } - - private List findMetricPeriodicActions( - final List actions) { - return actions.stream() - .filter(MetricPeriodicAction.class::isInstance) - .map(it -> (MetricPeriodicAction) it) - .collect(Collectors.toList()); - } - - @Override - public void run() { - // Ensure that Config has been initialized, so ConfigCollector can collect all settings first. - Config.get(); - - collectConfigChanges(); - - scheduler.init(); - - while (!Thread.interrupted()) { - try { - if (!startupEventSent) { - startupEventSent = sendAppStartedEvent(); - } - if (startupEventSent) { - mainLoopIteration(); - } else { - // wait until next heartbeat interval before reattempting startup event - scheduler.shouldRunHeartbeat(); - } - scheduler.sleepUntilNextIteration(); - } catch (InterruptedException e) { - log.debug("Interrupted; finishing telemetry thread"); - Thread.currentThread().interrupt(); - } + private static final Logger log = LoggerFactory.getLogger(TelemetryRunnable.class); + private static final int APP_STARTED_RETRIES = 3; + private static final int APP_STARTED_PAUSE_BETWEEN_RETRIES_MILLIS = 500; + private static final int MAX_CONSECUTIVE_REQUESTS = 3; + + private final TelemetryService telemetryService; + private final List actions; + private final List actionsAtMetricsInterval; + private final Scheduler scheduler; + private boolean startupEventSent; + + public TelemetryRunnable(final TelemetryService telemetryService, final List actions) { + this(telemetryService, actions, new ThreadSleeperImpl(), SystemTimeSource.INSTANCE); } - if (startupEventSent) { - flushPendingTelemetryData(); - telemetryService.sendAppClosingEvent(); + TelemetryRunnable( + final TelemetryService telemetryService, + final List actions, + final ThreadSleeper sleeper, + final TimeSource timeSource) { + this.telemetryService = telemetryService; + this.actions = actions; + this.actionsAtMetricsInterval = findMetricPeriodicActions(actions); + this.scheduler = new Scheduler( + timeSource, + sleeper, + (long) (Config.get().getTelemetryHeartbeatInterval() * 1000), + (long) (Config.get().getTelemetryMetricsInterval() * 1000), + Config.get().getTelemetryExtendedHeartbeatInterval() * 1000); } - log.debug("Telemetry thread finished"); - } - - /** - * Attempts to send an app-started event. - * - * @return `true` - if attempt was successful and `false` otherwise - */ - private boolean sendAppStartedEvent() throws InterruptedException { - int attempt = 0; - while (attempt < APP_STARTED_RETRIES && !telemetryService.sendAppStartedEvent()) { - attempt += 1; - log.debug( - "Couldn't send an app-started event on {} attempt out of {}.", - attempt, - APP_STARTED_RETRIES); - // Sleep between retries to allow OkHttp to release a non-daemon writer thread that would - // otherwise prevent the application from exiting. - Thread.sleep(APP_STARTED_PAUSE_BETWEEN_RETRIES_MILLIS); + + private List findMetricPeriodicActions(final List actions) { + return actions.stream() + .filter(MetricPeriodicAction.class::isInstance) + .map(it -> (MetricPeriodicAction) it) + .collect(Collectors.toList()); } - return attempt < APP_STARTED_RETRIES; - } - private void mainLoopIteration() throws InterruptedException { - collectConfigChanges(); + @Override + public void run() { + // Ensure that Config has been initialized, so ConfigCollector can collect all settings first. + Config.get(); + + collectConfigChanges(); + + scheduler.init(); + + while (!Thread.interrupted()) { + try { + if (!startupEventSent) { + startupEventSent = sendAppStartedEvent(); + } + if (startupEventSent) { + mainLoopIteration(); + } else { + // wait until next heartbeat interval before reattempting startup event + scheduler.shouldRunHeartbeat(); + } + scheduler.sleepUntilNextIteration(); + } catch (InterruptedException e) { + log.debug("Interrupted; finishing telemetry thread"); + Thread.currentThread().interrupt(); + } + } - // Collect request metrics every N seconds (default 10s) - if (scheduler.shouldRunMetrics()) { - for (MetricPeriodicAction action : actionsAtMetricsInterval) { - action.collector().prepareMetrics(); - } + if (startupEventSent) { + flushPendingTelemetryData(); + telemetryService.sendAppClosingEvent(); + } + log.debug("Telemetry thread finished"); } - if (scheduler.shouldRunHeartbeat()) { - for (final TelemetryPeriodicAction action : this.actions) { - action.doIteration(this.telemetryService); - } - for (int i = 0; i < MAX_CONSECUTIVE_REQUESTS; i++) { - if (!telemetryService.sendTelemetryEvents()) { - // stop if there is no more data to be sent, or it failed to send a request - break; + /** + * Attempts to send an app-started event. + * + * @return `true` - if attempt was successful and `false` otherwise + */ + private boolean sendAppStartedEvent() throws InterruptedException { + int attempt = 0; + while (attempt < APP_STARTED_RETRIES && !telemetryService.sendAppStartedEvent()) { + attempt += 1; + log.debug("Couldn't send an app-started event on {} attempt out of {}.", attempt, APP_STARTED_RETRIES); + // Sleep between retries to allow OkHttp to release a non-daemon writer thread that would + // otherwise prevent the application from exiting. + Thread.sleep(APP_STARTED_PAUSE_BETWEEN_RETRIES_MILLIS); } - } + return attempt < APP_STARTED_RETRIES; } - if (scheduler.shouldRunExtendedHeartbeat()) { - if (telemetryService.sendExtendedHeartbeat()) { - // advance next extended-heartbeat only if request was successful, otherwise reattempt it - // next heartbeat interval - scheduler.scheduleNextExtendedHeartbeat(); - } - } - } + private void mainLoopIteration() throws InterruptedException { + collectConfigChanges(); - private void collectConfigChanges() { - Map> collectedConfig = ConfigCollector.get().collect(); - if (!collectedConfig.isEmpty()) { - telemetryService.addConfiguration(collectedConfig); - } - } + // Collect request metrics every N seconds (default 10s) + if (scheduler.shouldRunMetrics()) { + for (MetricPeriodicAction action : actionsAtMetricsInterval) { + action.collector().prepareMetrics(); + } + } - private void flushPendingTelemetryData() { - collectConfigChanges(); + if (scheduler.shouldRunHeartbeat()) { + for (final TelemetryPeriodicAction action : this.actions) { + action.doIteration(this.telemetryService); + } + for (int i = 0; i < MAX_CONSECUTIVE_REQUESTS; i++) { + if (!telemetryService.sendTelemetryEvents()) { + // stop if there is no more data to be sent, or it failed to send a request + break; + } + } + } - for (MetricPeriodicAction action : actionsAtMetricsInterval) { - action.collector().prepareMetrics(); + if (scheduler.shouldRunExtendedHeartbeat()) { + if (telemetryService.sendExtendedHeartbeat()) { + // advance next extended-heartbeat only if request was successful, otherwise reattempt it + // next heartbeat interval + scheduler.scheduleNextExtendedHeartbeat(); + } + } } - for (final TelemetryPeriodicAction action : actions) { - action.doIteration(telemetryService); + private void collectConfigChanges() { + Map> collectedConfig = + ConfigCollector.get().collect(); + if (!collectedConfig.isEmpty()) { + telemetryService.addConfiguration(collectedConfig); + } } - telemetryService.sendTelemetryEvents(); - } - interface ThreadSleeper { - void sleep(long timeoutMs); - } + private void flushPendingTelemetryData() { + collectConfigChanges(); - static class ThreadSleeperImpl implements ThreadSleeper { - @Override - public void sleep(long timeoutMs) { - try { - Thread.sleep(timeoutMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } - - public interface TelemetryPeriodicAction { - void doIteration(TelemetryService service); - } - - static class Scheduler { - private final TimeSource timeSource; - private final ThreadSleeper sleeper; - private final long heartbeatIntervalMs; - private final long extendedHeartbeatIntervalMs; - private final long metricsIntervalMs; - private long nextHeartbeatIntervalMs; - private long nextExtendedHeartbeatIntervalMs; - private long nextMetricsIntervalMs; - private long currentTime; - - public Scheduler( - final TimeSource timeSource, - final ThreadSleeper sleeper, - final long heartbeatIntervalMs, - final long metricsIntervalMs, - final long extendedHeartbeatIntervalMs) { - this.timeSource = timeSource; - this.sleeper = sleeper; - this.heartbeatIntervalMs = heartbeatIntervalMs; - this.metricsIntervalMs = metricsIntervalMs; - this.extendedHeartbeatIntervalMs = extendedHeartbeatIntervalMs; - nextHeartbeatIntervalMs = 0; - nextMetricsIntervalMs = 0; - nextExtendedHeartbeatIntervalMs = 0; - currentTime = 0; - } + for (MetricPeriodicAction action : actionsAtMetricsInterval) { + action.collector().prepareMetrics(); + } - public void init() { - final long currentTime = timeSource.getCurrentTimeMillis(); - this.currentTime = currentTime; - nextMetricsIntervalMs = currentTime; - nextHeartbeatIntervalMs = currentTime; - scheduleNextExtendedHeartbeat(); + for (final TelemetryPeriodicAction action : actions) { + action.doIteration(telemetryService); + } + telemetryService.sendTelemetryEvents(); } - public boolean shouldRunMetrics() { - if (currentTime >= nextMetricsIntervalMs) { - nextMetricsIntervalMs += metricsIntervalMs; - return true; - } - return false; + interface ThreadSleeper { + void sleep(long timeoutMs); } - public boolean shouldRunHeartbeat() { - if (currentTime >= nextHeartbeatIntervalMs) { - nextHeartbeatIntervalMs += heartbeatIntervalMs; - return true; - } - return false; + static class ThreadSleeperImpl implements ThreadSleeper { + @Override + public void sleep(long timeoutMs) { + try { + Thread.sleep(timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } - public boolean shouldRunExtendedHeartbeat() { - return currentTime >= nextExtendedHeartbeatIntervalMs; + public interface TelemetryPeriodicAction { + void doIteration(TelemetryService service); } - public void scheduleNextExtendedHeartbeat() { - // schedule very first extended-heartbeat only after interval elapses - nextExtendedHeartbeatIntervalMs = currentTime + extendedHeartbeatIntervalMs; - } + static class Scheduler { + private final TimeSource timeSource; + private final ThreadSleeper sleeper; + private final long heartbeatIntervalMs; + private final long extendedHeartbeatIntervalMs; + private final long metricsIntervalMs; + private long nextHeartbeatIntervalMs; + private long nextExtendedHeartbeatIntervalMs; + private long nextMetricsIntervalMs; + private long currentTime; + + public Scheduler( + final TimeSource timeSource, + final ThreadSleeper sleeper, + final long heartbeatIntervalMs, + final long metricsIntervalMs, + final long extendedHeartbeatIntervalMs) { + this.timeSource = timeSource; + this.sleeper = sleeper; + this.heartbeatIntervalMs = heartbeatIntervalMs; + this.metricsIntervalMs = metricsIntervalMs; + this.extendedHeartbeatIntervalMs = extendedHeartbeatIntervalMs; + nextHeartbeatIntervalMs = 0; + nextMetricsIntervalMs = 0; + nextExtendedHeartbeatIntervalMs = 0; + currentTime = 0; + } + + public void init() { + final long currentTime = timeSource.getCurrentTimeMillis(); + this.currentTime = currentTime; + nextMetricsIntervalMs = currentTime; + nextHeartbeatIntervalMs = currentTime; + scheduleNextExtendedHeartbeat(); + } + + public boolean shouldRunMetrics() { + if (currentTime >= nextMetricsIntervalMs) { + nextMetricsIntervalMs += metricsIntervalMs; + return true; + } + return false; + } + + public boolean shouldRunHeartbeat() { + if (currentTime >= nextHeartbeatIntervalMs) { + nextHeartbeatIntervalMs += heartbeatIntervalMs; + return true; + } + return false; + } + + public boolean shouldRunExtendedHeartbeat() { + return currentTime >= nextExtendedHeartbeatIntervalMs; + } + + public void scheduleNextExtendedHeartbeat() { + // schedule very first extended-heartbeat only after interval elapses + nextExtendedHeartbeatIntervalMs = currentTime + extendedHeartbeatIntervalMs; + } - public void sleepUntilNextIteration() { - currentTime = timeSource.getCurrentTimeMillis(); - - if (currentTime >= nextHeartbeatIntervalMs) { - // We are probably under high load or, more likely (?) slow network and the time to send - // telemetry has overrun the interval. In this case, we reset metrics and heartbeat interval - // to trigger them immediately. - nextMetricsIntervalMs = currentTime; - nextHeartbeatIntervalMs = currentTime; - log.debug( - "Time to run telemetry actions exceeded the interval, triggering the next iteration immediately"); - return; - } - - while (currentTime >= nextMetricsIntervalMs) { - // If metric collection exceeded the interval, something went really wrong. Either there's a - // very short interval (not default), or metric collection is taking abnormally long. We - // skip intervals in this case. - nextMetricsIntervalMs += metricsIntervalMs; - } - - long nextIntervalMs = Math.min(nextMetricsIntervalMs, nextHeartbeatIntervalMs); - final long waitMs = nextIntervalMs - currentTime; - sleeper.sleep(waitMs); - currentTime = timeSource.getCurrentTimeMillis(); + public void sleepUntilNextIteration() { + currentTime = timeSource.getCurrentTimeMillis(); + + if (currentTime >= nextHeartbeatIntervalMs) { + // We are probably under high load or, more likely (?) slow network and the time to send + // telemetry has overrun the interval. In this case, we reset metrics and heartbeat interval + // to trigger them immediately. + nextMetricsIntervalMs = currentTime; + nextHeartbeatIntervalMs = currentTime; + log.debug( + "Time to run telemetry actions exceeded the interval, triggering the next iteration immediately"); + return; + } + + while (currentTime >= nextMetricsIntervalMs) { + // If metric collection exceeded the interval, something went really wrong. Either there's a + // very short interval (not default), or metric collection is taking abnormally long. We + // skip intervals in this case. + nextMetricsIntervalMs += metricsIntervalMs; + } + + long nextIntervalMs = Math.min(nextMetricsIntervalMs, nextHeartbeatIntervalMs); + final long waitMs = nextIntervalMs - currentTime; + sleeper.sleep(waitMs); + currentTime = timeSource.getCurrentTimeMillis(); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryService.java b/telemetry/src/main/java/datadog/telemetry/TelemetryService.java index a5490e0649c..e6b9764dc27 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryService.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryService.java @@ -19,261 +19,243 @@ public class TelemetryService { - private static final Logger log = LoggerFactory.getLogger(TelemetryService.class); - - private static final long DEFAULT_MESSAGE_BYTES_SOFT_LIMIT = Math.round(5 * 1024 * 1024 * 0.75); - - private final TelemetryRouter telemetryRouter; - private final BlockingQueue configurations = new LinkedBlockingQueue<>(); - private final BlockingQueue integrations = new LinkedBlockingQueue<>(); - private final BlockingQueue dependencies = new LinkedBlockingQueue<>(); - private final BlockingQueue metrics = - new LinkedBlockingQueue<>(1024); // recommended capacity? - - private final BlockingQueue logMessages = new LinkedBlockingQueue<>(1024); - - private final BlockingQueue distributionSeries = - new LinkedBlockingQueue<>(1024); - - private final BlockingQueue productChanges = new LinkedBlockingQueue<>(); - - private final ExtendedHeartbeatData extendedHeartbeatData = new ExtendedHeartbeatData(); - - private final BlockingQueue endpoints = new LinkedBlockingQueue<>(); - - private final EventSource.Queued eventSource = - new EventSource.Queued( - configurations, - integrations, - dependencies, - metrics, - distributionSeries, - logMessages, - productChanges, - endpoints); - - private final long messageBytesSoftLimit; - private final boolean debug; - - /* - * Keep track of Open Tracing and Open Telemetry integrations activation as they are mutually exclusive. - */ - private boolean openTracingIntegrationEnabled; - private boolean openTelemetryIntegrationEnabled; - - public static TelemetryService build( - DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery, - TelemetryClient agentClient, - TelemetryClient intakeClient, - boolean useIntakeClientByDefault, - boolean debug) { - TelemetryRouter telemetryRouter = - new TelemetryRouter( - ddAgentFeaturesDiscovery, agentClient, intakeClient, useIntakeClientByDefault); - return new TelemetryService(telemetryRouter, DEFAULT_MESSAGE_BYTES_SOFT_LIMIT, debug); - } - - public static TelemetryService buildFileBased(FileBasedTelemetryClient client, boolean debug) { - TelemetryRouter telemetryRouter = new TelemetryRouter(client); - return new TelemetryService(telemetryRouter, DEFAULT_MESSAGE_BYTES_SOFT_LIMIT, debug); - } - - // For testing purposes - TelemetryService( - final TelemetryRouter telemetryRouter, - final long messageBytesSoftLimit, - final boolean debug) { - this.telemetryRouter = telemetryRouter; - this.openTracingIntegrationEnabled = false; - this.openTelemetryIntegrationEnabled = false; - this.messageBytesSoftLimit = messageBytesSoftLimit; - this.debug = debug; - } - - public boolean addConfiguration(Map> configuration) { - for (Map settings : configuration.values()) { - for (ConfigSetting cs : settings.values()) { - extendedHeartbeatData.pushConfigSetting(cs); - if (!this.configurations.offer(cs)) { - return false; + private static final Logger log = LoggerFactory.getLogger(TelemetryService.class); + + private static final long DEFAULT_MESSAGE_BYTES_SOFT_LIMIT = Math.round(5 * 1024 * 1024 * 0.75); + + private final TelemetryRouter telemetryRouter; + private final BlockingQueue configurations = new LinkedBlockingQueue<>(); + private final BlockingQueue integrations = new LinkedBlockingQueue<>(); + private final BlockingQueue dependencies = new LinkedBlockingQueue<>(); + private final BlockingQueue metrics = new LinkedBlockingQueue<>(1024); // recommended capacity? + + private final BlockingQueue logMessages = new LinkedBlockingQueue<>(1024); + + private final BlockingQueue distributionSeries = new LinkedBlockingQueue<>(1024); + + private final BlockingQueue productChanges = new LinkedBlockingQueue<>(); + + private final ExtendedHeartbeatData extendedHeartbeatData = new ExtendedHeartbeatData(); + + private final BlockingQueue endpoints = new LinkedBlockingQueue<>(); + + private final EventSource.Queued eventSource = new EventSource.Queued( + configurations, + integrations, + dependencies, + metrics, + distributionSeries, + logMessages, + productChanges, + endpoints); + + private final long messageBytesSoftLimit; + private final boolean debug; + + /* + * Keep track of Open Tracing and Open Telemetry integrations activation as they are mutually exclusive. + */ + private boolean openTracingIntegrationEnabled; + private boolean openTelemetryIntegrationEnabled; + + public static TelemetryService build( + DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery, + TelemetryClient agentClient, + TelemetryClient intakeClient, + boolean useIntakeClientByDefault, + boolean debug) { + TelemetryRouter telemetryRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentClient, intakeClient, useIntakeClientByDefault); + return new TelemetryService(telemetryRouter, DEFAULT_MESSAGE_BYTES_SOFT_LIMIT, debug); + } + + public static TelemetryService buildFileBased(FileBasedTelemetryClient client, boolean debug) { + TelemetryRouter telemetryRouter = new TelemetryRouter(client); + return new TelemetryService(telemetryRouter, DEFAULT_MESSAGE_BYTES_SOFT_LIMIT, debug); + } + + // For testing purposes + TelemetryService(final TelemetryRouter telemetryRouter, final long messageBytesSoftLimit, final boolean debug) { + this.telemetryRouter = telemetryRouter; + this.openTracingIntegrationEnabled = false; + this.openTelemetryIntegrationEnabled = false; + this.messageBytesSoftLimit = messageBytesSoftLimit; + this.debug = debug; + } + + public boolean addConfiguration(Map> configuration) { + for (Map settings : configuration.values()) { + for (ConfigSetting cs : settings.values()) { + extendedHeartbeatData.pushConfigSetting(cs); + if (!this.configurations.offer(cs)) { + return false; + } + } } - } + return true; } - return true; - } - public boolean addDependency(Dependency dependency) { - extendedHeartbeatData.pushDependency(dependency); - return this.dependencies.offer(dependency); - } + public boolean addDependency(Dependency dependency) { + extendedHeartbeatData.pushDependency(dependency); + return this.dependencies.offer(dependency); + } - public boolean addIntegration(Integration integration) { - if ("opentelemetry-1".equals(integration.name)) { - openTelemetryIntegrationEnabled = integration.enabled; + public boolean addIntegration(Integration integration) { + if ("opentelemetry-1".equals(integration.name)) { + openTelemetryIntegrationEnabled = integration.enabled; + } + if ("opentracing".equals(integration.name)) { + openTracingIntegrationEnabled = integration.enabled; + } + if (openTelemetryIntegrationEnabled && openTracingIntegrationEnabled) { + warnAboutExclusiveIntegrations(); + } + extendedHeartbeatData.pushIntegration(integration); + return this.integrations.offer(integration); } - if ("opentracing".equals(integration.name)) { - openTracingIntegrationEnabled = integration.enabled; + + public boolean addMetric(Metric metric) { + return this.metrics.offer(metric); } - if (openTelemetryIntegrationEnabled && openTracingIntegrationEnabled) { - warnAboutExclusiveIntegrations(); + + public boolean addLogMessage(LogMessage message) { + return this.logMessages.offer(message); } - extendedHeartbeatData.pushIntegration(integration); - return this.integrations.offer(integration); - } - - public boolean addMetric(Metric metric) { - return this.metrics.offer(metric); - } - - public boolean addLogMessage(LogMessage message) { - return this.logMessages.offer(message); - } - - public boolean addProductChange(ProductChange productChange) { - return this.productChanges.offer(productChange); - } - - public boolean addDistributionSeries(DistributionSeries series) { - return this.distributionSeries.offer(series); - } - - public boolean addEndpoint(final Endpoint endpoint) { - return this.endpoints.offer(endpoint); - } - - public void sendAppClosingEvent() { - TelemetryRequest telemetryRequest = - new TelemetryRequest( - this.eventSource, - EventSink.NOOP, - messageBytesSoftLimit, - RequestType.APP_CLOSING, - debug); - if (telemetryRouter.sendRequest(telemetryRequest) != TelemetryClient.Result.SUCCESS) { - log.warn("Couldn't send app-closing event!"); + + public boolean addProductChange(ProductChange productChange) { + return this.productChanges.offer(productChange); } - } - - // keeps track of unsent events from the previous attempt - private BufferedEvents bufferedEvents; - - /** - * @return true - if an app-started event has been successfully sent, false - otherwise - */ - public boolean sendAppStartedEvent() { - EventSource eventSource; - EventSink eventSink; - if (bufferedEvents == null) { - eventSource = this.eventSource; - } else { - log.debug( - "Sending buffered telemetry events that couldn't have been sent on previous attempt"); - eventSource = bufferedEvents; + + public boolean addDistributionSeries(DistributionSeries series) { + return this.distributionSeries.offer(series); } - // use a buffer as a sink, so we can retry on the next attempt in case of a request failure - bufferedEvents = new BufferedEvents(); - eventSink = bufferedEvents; - - log.debug("Preparing app-started request"); - TelemetryRequest request = - new TelemetryRequest( - eventSource, eventSink, messageBytesSoftLimit, RequestType.APP_STARTED, debug); - - request.writeProducts(); - request.writeConfigurations(); - request.writeInstallSignature(); - if (telemetryRouter.sendRequest(request) == TelemetryClient.Result.SUCCESS) { - // discard already sent buffered event on the successful attempt - bufferedEvents = null; - return true; + + public boolean addEndpoint(final Endpoint endpoint) { + return this.endpoints.offer(endpoint); } - return false; - } - - /** - * @return true - only part of data has been sent because of the request size limit false - all - * data has been sent, or it has failed sending a request - */ - public boolean sendTelemetryEvents() { - EventSource eventSource; - EventSink eventSink; - if (bufferedEvents == null) { - log.debug("Sending telemetry events"); - eventSource = this.eventSource; - // use a buffer as a sink, so we can retry on the next attempt in case of a request failure - bufferedEvents = new BufferedEvents(); - eventSink = bufferedEvents; - } else { - log.debug( - "Sending buffered telemetry events that couldn't have been sent on previous attempt"); - eventSource = bufferedEvents; - eventSink = EventSink.NOOP; // TODO collect metrics for unsent events + + public void sendAppClosingEvent() { + TelemetryRequest telemetryRequest = new TelemetryRequest( + this.eventSource, EventSink.NOOP, messageBytesSoftLimit, RequestType.APP_CLOSING, debug); + if (telemetryRouter.sendRequest(telemetryRequest) != TelemetryClient.Result.SUCCESS) { + log.warn("Couldn't send app-closing event!"); + } } - TelemetryRequest request; - boolean isMoreDataAvailable = false; - if (eventSource.isEmpty()) { - log.debug("Preparing app-heartbeat request"); - request = - new TelemetryRequest( - eventSource, eventSink, messageBytesSoftLimit, RequestType.APP_HEARTBEAT, debug); - } else { - log.debug("Preparing message-batch request"); - request = - new TelemetryRequest( - eventSource, eventSink, messageBytesSoftLimit, RequestType.MESSAGE_BATCH, debug); - request.writeHeartbeat(); - request.writeConfigurations(); - request.writeIntegrations(); - request.writeDependencies(); - request.writeMetrics(); - request.writeDistributions(); - request.writeLogs(); - request.writeChangedProducts(); - request.writeEndpoints(); - isMoreDataAvailable = !this.eventSource.isEmpty(); + + // keeps track of unsent events from the previous attempt + private BufferedEvents bufferedEvents; + + /** + * @return true - if an app-started event has been successfully sent, false - otherwise + */ + public boolean sendAppStartedEvent() { + EventSource eventSource; + EventSink eventSink; + if (bufferedEvents == null) { + eventSource = this.eventSource; + } else { + log.debug("Sending buffered telemetry events that couldn't have been sent on previous attempt"); + eventSource = bufferedEvents; + } + // use a buffer as a sink, so we can retry on the next attempt in case of a request failure + bufferedEvents = new BufferedEvents(); + eventSink = bufferedEvents; + + log.debug("Preparing app-started request"); + TelemetryRequest request = + new TelemetryRequest(eventSource, eventSink, messageBytesSoftLimit, RequestType.APP_STARTED, debug); + + request.writeProducts(); + request.writeConfigurations(); + request.writeInstallSignature(); + if (telemetryRouter.sendRequest(request) == TelemetryClient.Result.SUCCESS) { + // discard already sent buffered event on the successful attempt + bufferedEvents = null; + return true; + } + return false; } - TelemetryClient.Result result = telemetryRouter.sendRequest(request); - if (result == TelemetryClient.Result.SUCCESS) { - log.debug("Telemetry request has been sent successfully."); - bufferedEvents = null; - return isMoreDataAvailable; - } else { - log.debug("Telemetry request has failed: {}", result); - if (eventSource == bufferedEvents) { - // TODO report metrics for discarded events - bufferedEvents = null; - } + /** + * @return true - only part of data has been sent because of the request size limit false - all + * data has been sent, or it has failed sending a request + */ + public boolean sendTelemetryEvents() { + EventSource eventSource; + EventSink eventSink; + if (bufferedEvents == null) { + log.debug("Sending telemetry events"); + eventSource = this.eventSource; + // use a buffer as a sink, so we can retry on the next attempt in case of a request failure + bufferedEvents = new BufferedEvents(); + eventSink = bufferedEvents; + } else { + log.debug("Sending buffered telemetry events that couldn't have been sent on previous attempt"); + eventSource = bufferedEvents; + eventSink = EventSink.NOOP; // TODO collect metrics for unsent events + } + TelemetryRequest request; + boolean isMoreDataAvailable = false; + if (eventSource.isEmpty()) { + log.debug("Preparing app-heartbeat request"); + request = new TelemetryRequest( + eventSource, eventSink, messageBytesSoftLimit, RequestType.APP_HEARTBEAT, debug); + } else { + log.debug("Preparing message-batch request"); + request = new TelemetryRequest( + eventSource, eventSink, messageBytesSoftLimit, RequestType.MESSAGE_BATCH, debug); + request.writeHeartbeat(); + request.writeConfigurations(); + request.writeIntegrations(); + request.writeDependencies(); + request.writeMetrics(); + request.writeDistributions(); + request.writeLogs(); + request.writeChangedProducts(); + request.writeEndpoints(); + isMoreDataAvailable = !this.eventSource.isEmpty(); + } + + TelemetryClient.Result result = telemetryRouter.sendRequest(request); + if (result == TelemetryClient.Result.SUCCESS) { + log.debug("Telemetry request has been sent successfully."); + bufferedEvents = null; + return isMoreDataAvailable; + } else { + log.debug("Telemetry request has failed: {}", result); + if (eventSource == bufferedEvents) { + // TODO report metrics for discarded events + bufferedEvents = null; + } + } + return false; } - return false; - } - - /** - * @return true - if extended heartbeat request sent successfully, otherwise false - */ - public boolean sendExtendedHeartbeat() { - log.debug("Preparing message-batch request"); - EventSource extendedHeartbeatDataSnapshot = extendedHeartbeatData.snapshot(); - TelemetryRequest request = - new TelemetryRequest( - extendedHeartbeatDataSnapshot, - EventSink.NOOP, - messageBytesSoftLimit, - RequestType.APP_EXTENDED_HEARTBEAT, - debug); - request.writeConfigurations(); - request.writeDependencies(); - request.writeIntegrations(); - - TelemetryClient.Result result = telemetryRouter.sendRequest(request); - if (!extendedHeartbeatDataSnapshot.isEmpty()) { - log.warn("Telemetry Extended Heartbeat data does NOT fit in one request."); + + /** + * @return true - if extended heartbeat request sent successfully, otherwise false + */ + public boolean sendExtendedHeartbeat() { + log.debug("Preparing message-batch request"); + EventSource extendedHeartbeatDataSnapshot = extendedHeartbeatData.snapshot(); + TelemetryRequest request = new TelemetryRequest( + extendedHeartbeatDataSnapshot, + EventSink.NOOP, + messageBytesSoftLimit, + RequestType.APP_EXTENDED_HEARTBEAT, + debug); + request.writeConfigurations(); + request.writeDependencies(); + request.writeIntegrations(); + + TelemetryClient.Result result = telemetryRouter.sendRequest(request); + if (!extendedHeartbeatDataSnapshot.isEmpty()) { + log.warn("Telemetry Extended Heartbeat data does NOT fit in one request."); + } + return result == TelemetryClient.Result.SUCCESS; } - return result == TelemetryClient.Result.SUCCESS; - } - void warnAboutExclusiveIntegrations() { - log.warn( - "Both OpenTracing and OpenTelemetry integrations are enabled but mutually exclusive. Tracing performance can be degraded."); - } + void warnAboutExclusiveIntegrations() { + log.warn( + "Both OpenTracing and OpenTelemetry integrations are enabled but mutually exclusive. Tracing performance can be degraded."); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java index 4b233aa3037..e8ab8097e7c 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java @@ -37,144 +37,135 @@ public class TelemetrySystem { - private static final long TELEMETRY_STOP_WAIT_MILLIS = 5000L; - private static final Logger log = LoggerFactory.getLogger(TelemetrySystem.class); - - private static volatile Thread TELEMETRY_THREAD; - private static volatile DependencyService DEPENDENCY_SERVICE; - - static DependencyService createDependencyService(Instrumentation instrumentation) { - if (instrumentation != null && Config.get().isTelemetryDependencyServiceEnabled()) { - DependencyService dependencyService = new DependencyService(); - dependencyService.installOn(instrumentation); - dependencyService.schedulePeriodicResolution(); - return dependencyService; - } - return null; - } - - static Thread createTelemetryRunnable( - TelemetryService telemetryService, - DependencyService dependencyService, - boolean telemetryMetricsEnabled) { - DEPENDENCY_SERVICE = dependencyService; - List actions = new ArrayList<>(); - if (telemetryMetricsEnabled) { - actions.add(new CoreMetricsPeriodicAction()); - actions.add(new OtelEnvMetricPeriodicAction()); - if (InstrumenterConfig.get().getTraceExtensionsPath() != null) { - actions.add(new OtelSpiMetricPeriodicAction()); - } - actions.add(new ConfigInversionMetricPeriodicAction()); - actions.add(new IntegrationPeriodicAction()); - actions.add(new WafMetricPeriodicAction()); - actions.add(new OtlpTelemetryPeriodicAction()); - actions.add(new DebuggerMetricPeriodicAction()); - if (Verbosity.OFF != Config.get().getIastTelemetryVerbosity()) { - actions.add(new IastMetricPeriodicAction()); - } - if (Config.get().isCiVisibilityEnabled() && Config.get().isCiVisibilityTelemetryEnabled()) { - actions.add(new CiVisibilityMetricPeriodicAction()); - } - if (Config.get().isLlmObsEnabled()) { - actions.add(new LLMObsMetricPeriodicAction()); - } - } - if (null != dependencyService) { - if (Config.get().isAppSecScaEnabled()) { - // ScaReachabilityPeriodicAction takes over all dep reporting when SCA is enabled: - // it merges DependencyService drains with CVE registry state into one entry per dep. - // DependencyPeriodicAction is skipped to avoid duplicate app-dependencies-loaded entries. - actions.add(new ScaReachabilityPeriodicAction(dependencyService)); - } else { - actions.add(new DependencyPeriodicAction(dependencyService)); - } - } - if (Config.get().isTelemetryLogCollectionEnabled()) { - actions.add(new LogPeriodicAction()); - log.debug("Telemetry log collection enabled"); - } - if (InstrumenterConfig.get().isRumEnabled()) { - RumInjector.enableTelemetry(); - actions.add(new RumPeriodicAction(RumInjector.getTelemetryCollector())); - } - actions.add(new ProductChangeAction()); - if (Config.get().isApiSecurityEndpointCollectionEnabled()) { - actions.add(new EndpointPeriodicAction()); + private static final long TELEMETRY_STOP_WAIT_MILLIS = 5000L; + private static final Logger log = LoggerFactory.getLogger(TelemetrySystem.class); + + private static volatile Thread TELEMETRY_THREAD; + private static volatile DependencyService DEPENDENCY_SERVICE; + + static DependencyService createDependencyService(Instrumentation instrumentation) { + if (instrumentation != null && Config.get().isTelemetryDependencyServiceEnabled()) { + DependencyService dependencyService = new DependencyService(); + dependencyService.installOn(instrumentation); + dependencyService.schedulePeriodicResolution(); + return dependencyService; + } + return null; } - TelemetryRunnable telemetryRunnable = new TelemetryRunnable(telemetryService, actions); - return AgentThreadFactory.newAgentThread( - AgentThreadFactory.AgentThread.TELEMETRY, telemetryRunnable); - } - - /** Called by reflection (see Agent.startTelemetry) */ - public static void startTelemetry( - Instrumentation instrumentation, SharedCommunicationObjects sco) { - Config config = Config.get(); - boolean debug = config.isTelemetryDebugRequestsEnabled(); - boolean telemetryMetricsEnabled = config.isTelemetryMetricsEnabled(); - - // CI Visibility bazel mode writes telemetry to files instead of the network - if (config.isCiVisibilityEnabled() && BazelMode.get().isPayloadFilesEnabled()) { - String telemetryDir = BazelMode.get().getTelemetryPayloadsDir(); - log.info("[bazel mode] Writing telemetry payloads to {}", telemetryDir); - DependencyService dependencyService = createDependencyService(instrumentation); - TelemetryService telemetryService = - TelemetryService.buildFileBased(new FileBasedTelemetryClient(telemetryDir), debug); - TELEMETRY_THREAD = - createTelemetryRunnable(telemetryService, dependencyService, telemetryMetricsEnabled); - TELEMETRY_THREAD.start(); - return; + static Thread createTelemetryRunnable( + TelemetryService telemetryService, DependencyService dependencyService, boolean telemetryMetricsEnabled) { + DEPENDENCY_SERVICE = dependencyService; + List actions = new ArrayList<>(); + if (telemetryMetricsEnabled) { + actions.add(new CoreMetricsPeriodicAction()); + actions.add(new OtelEnvMetricPeriodicAction()); + if (InstrumenterConfig.get().getTraceExtensionsPath() != null) { + actions.add(new OtelSpiMetricPeriodicAction()); + } + actions.add(new ConfigInversionMetricPeriodicAction()); + actions.add(new IntegrationPeriodicAction()); + actions.add(new WafMetricPeriodicAction()); + actions.add(new OtlpTelemetryPeriodicAction()); + actions.add(new DebuggerMetricPeriodicAction()); + if (Verbosity.OFF != Config.get().getIastTelemetryVerbosity()) { + actions.add(new IastMetricPeriodicAction()); + } + if (Config.get().isCiVisibilityEnabled() && Config.get().isCiVisibilityTelemetryEnabled()) { + actions.add(new CiVisibilityMetricPeriodicAction()); + } + if (Config.get().isLlmObsEnabled()) { + actions.add(new LLMObsMetricPeriodicAction()); + } + } + if (null != dependencyService) { + if (Config.get().isAppSecScaEnabled()) { + // ScaReachabilityPeriodicAction takes over all dep reporting when SCA is enabled: + // it merges DependencyService drains with CVE registry state into one entry per dep. + // DependencyPeriodicAction is skipped to avoid duplicate app-dependencies-loaded entries. + actions.add(new ScaReachabilityPeriodicAction(dependencyService)); + } else { + actions.add(new DependencyPeriodicAction(dependencyService)); + } + } + if (Config.get().isTelemetryLogCollectionEnabled()) { + actions.add(new LogPeriodicAction()); + log.debug("Telemetry log collection enabled"); + } + if (InstrumenterConfig.get().isRumEnabled()) { + RumInjector.enableTelemetry(); + actions.add(new RumPeriodicAction(RumInjector.getTelemetryCollector())); + } + actions.add(new ProductChangeAction()); + if (Config.get().isApiSecurityEndpointCollectionEnabled()) { + actions.add(new EndpointPeriodicAction()); + } + + TelemetryRunnable telemetryRunnable = new TelemetryRunnable(telemetryService, actions); + return AgentThreadFactory.newAgentThread(AgentThreadFactory.AgentThread.TELEMETRY, telemetryRunnable); } - sco.createRemaining(config); - DependencyService dependencyService = createDependencyService(instrumentation); - DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery = sco.featuresDiscovery(config); - - HttpRetryPolicy.Factory httpRetryPolicy = - config.isCiVisibilityEnabled() - ? new HttpRetryPolicy.Factory(2, 100, 2.0, true) - : HttpRetryPolicy.Factory.NEVER_RETRY; - - TelemetryClient agentClient = - TelemetryClient.buildAgentClient(sco.agentHttpClient, sco.agentUrl, httpRetryPolicy); - TelemetryClient intakeClient = TelemetryClient.buildIntakeClient(config, httpRetryPolicy); - - boolean useIntakeClientByDefault = - config.isCiVisibilityEnabled() && config.isCiVisibilityAgentlessEnabled(); - TelemetryService telemetryService = - TelemetryService.build( - ddAgentFeaturesDiscovery, agentClient, intakeClient, useIntakeClientByDefault, debug); - - TELEMETRY_THREAD = - createTelemetryRunnable(telemetryService, dependencyService, telemetryMetricsEnabled); - TELEMETRY_THREAD.start(); - } - - /** Called by reflection (see Agent.stopTelemetry) */ - public static void stop() { - DependencyService dependencyService = DEPENDENCY_SERVICE; - if (dependencyService != null) { - dependencyService.stop(); + /** Called by reflection (see Agent.startTelemetry) */ + public static void startTelemetry(Instrumentation instrumentation, SharedCommunicationObjects sco) { + Config config = Config.get(); + boolean debug = config.isTelemetryDebugRequestsEnabled(); + boolean telemetryMetricsEnabled = config.isTelemetryMetricsEnabled(); + + // CI Visibility bazel mode writes telemetry to files instead of the network + if (config.isCiVisibilityEnabled() && BazelMode.get().isPayloadFilesEnabled()) { + String telemetryDir = BazelMode.get().getTelemetryPayloadsDir(); + log.info("[bazel mode] Writing telemetry payloads to {}", telemetryDir); + DependencyService dependencyService = createDependencyService(instrumentation); + TelemetryService telemetryService = + TelemetryService.buildFileBased(new FileBasedTelemetryClient(telemetryDir), debug); + TELEMETRY_THREAD = createTelemetryRunnable(telemetryService, dependencyService, telemetryMetricsEnabled); + TELEMETRY_THREAD.start(); + return; + } + + sco.createRemaining(config); + DependencyService dependencyService = createDependencyService(instrumentation); + DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery = sco.featuresDiscovery(config); + + HttpRetryPolicy.Factory httpRetryPolicy = config.isCiVisibilityEnabled() + ? new HttpRetryPolicy.Factory(2, 100, 2.0, true) + : HttpRetryPolicy.Factory.NEVER_RETRY; + + TelemetryClient agentClient = + TelemetryClient.buildAgentClient(sco.agentHttpClient, sco.agentUrl, httpRetryPolicy); + TelemetryClient intakeClient = TelemetryClient.buildIntakeClient(config, httpRetryPolicy); + + boolean useIntakeClientByDefault = config.isCiVisibilityEnabled() && config.isCiVisibilityAgentlessEnabled(); + TelemetryService telemetryService = TelemetryService.build( + ddAgentFeaturesDiscovery, agentClient, intakeClient, useIntakeClientByDefault, debug); + + TELEMETRY_THREAD = createTelemetryRunnable(telemetryService, dependencyService, telemetryMetricsEnabled); + TELEMETRY_THREAD.start(); } - Thread telemetryThread = TELEMETRY_THREAD; - if (telemetryThread != null) { - telemetryThread.interrupt(); - try { - telemetryThread.join(TELEMETRY_STOP_WAIT_MILLIS); - } catch (InterruptedException e) { - log.warn("Telemetry thread join was interrupted"); - } - if (telemetryThread.isAlive()) { - log.warn("Telemetry thread join was not completed"); - } + /** Called by reflection (see Agent.stopTelemetry) */ + public static void stop() { + DependencyService dependencyService = DEPENDENCY_SERVICE; + if (dependencyService != null) { + dependencyService.stop(); + } + + Thread telemetryThread = TELEMETRY_THREAD; + if (telemetryThread != null) { + telemetryThread.interrupt(); + try { + telemetryThread.join(TELEMETRY_STOP_WAIT_MILLIS); + } catch (InterruptedException e) { + log.warn("Telemetry thread join was interrupted"); + } + if (telemetryThread.isAlive()) { + log.warn("Telemetry thread join was not completed"); + } + } } - } - @VisibleForTesting - static Thread getTelemetryThread() { - return TELEMETRY_THREAD; - } + @VisibleForTesting + static Thread getTelemetryThread() { + return TELEMETRY_THREAD; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/api/DistributionSeries.java b/telemetry/src/main/java/datadog/telemetry/api/DistributionSeries.java index fb13421b773..330c0289ac6 100644 --- a/telemetry/src/main/java/datadog/telemetry/api/DistributionSeries.java +++ b/telemetry/src/main/java/datadog/telemetry/api/DistributionSeries.java @@ -4,58 +4,58 @@ import java.util.List; public class DistributionSeries { - private String metric; - private List points = new ArrayList<>(); - private List tags = new ArrayList<>(); - private Boolean common; - private String namespace; - - public String getMetric() { - return metric; - } - - public DistributionSeries metric(String metric) { - this.metric = metric; - return this; - } - - public List getPoints() { - return points; - } - - public DistributionSeries points(List points) { - this.points = points; - return this; - } - - public void addPoint(int point) { - points.add(point); - } - - public List getTags() { - return tags; - } - - public DistributionSeries tags(List tags) { - this.tags = tags; - return this; - } - - public Boolean getCommon() { - return common; - } - - public DistributionSeries common(Boolean common) { - this.common = common; - return this; - } - - public String getNamespace() { - return namespace; - } - - public DistributionSeries namespace(String namespace) { - this.namespace = namespace; - return this; - } + private String metric; + private List points = new ArrayList<>(); + private List tags = new ArrayList<>(); + private Boolean common; + private String namespace; + + public String getMetric() { + return metric; + } + + public DistributionSeries metric(String metric) { + this.metric = metric; + return this; + } + + public List getPoints() { + return points; + } + + public DistributionSeries points(List points) { + this.points = points; + return this; + } + + public void addPoint(int point) { + points.add(point); + } + + public List getTags() { + return tags; + } + + public DistributionSeries tags(List tags) { + this.tags = tags; + return this; + } + + public Boolean getCommon() { + return common; + } + + public DistributionSeries common(Boolean common) { + this.common = common; + return this; + } + + public String getNamespace() { + return namespace; + } + + public DistributionSeries namespace(String namespace) { + this.namespace = namespace; + return this; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/api/Integration.java b/telemetry/src/main/java/datadog/telemetry/api/Integration.java index 4b550e8b998..adfa13a386f 100644 --- a/telemetry/src/main/java/datadog/telemetry/api/Integration.java +++ b/telemetry/src/main/java/datadog/telemetry/api/Integration.java @@ -1,11 +1,11 @@ package datadog.telemetry.api; public final class Integration { - public final String name; - public final boolean enabled; + public final String name; + public final boolean enabled; - public Integration(String name, boolean enabled) { - this.name = name; - this.enabled = enabled; - } + public Integration(String name, boolean enabled) { + this.name = name; + this.enabled = enabled; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/api/LogMessage.java b/telemetry/src/main/java/datadog/telemetry/api/LogMessage.java index 67e63bdf6d1..7a15817d8f9 100644 --- a/telemetry/src/main/java/datadog/telemetry/api/LogMessage.java +++ b/telemetry/src/main/java/datadog/telemetry/api/LogMessage.java @@ -1,64 +1,64 @@ package datadog.telemetry.api; public class LogMessage { - private String message; - private LogMessageLevel level; - private String tags; - private String stackTrace; - private Long tracerTime; - private int count; + private String message; + private LogMessageLevel level; + private String tags; + private String stackTrace; + private Long tracerTime; + private int count; - public String getMessage() { - return message; - } + public String getMessage() { + return message; + } - public LogMessage message(String message) { - this.message = message; - return this; - } + public LogMessage message(String message) { + this.message = message; + return this; + } - public LogMessageLevel getLevel() { - return level; - } + public LogMessageLevel getLevel() { + return level; + } - public LogMessage level(LogMessageLevel level) { - this.level = level; - return this; - } + public LogMessage level(LogMessageLevel level) { + this.level = level; + return this; + } - public String getTags() { - return tags; - } + public String getTags() { + return tags; + } - public LogMessage tags(String tags) { - this.tags = tags; - return this; - } + public LogMessage tags(String tags) { + this.tags = tags; + return this; + } - public String getStackTrace() { - return stackTrace; - } + public String getStackTrace() { + return stackTrace; + } - public LogMessage stackTrace(String stackTrace) { - this.stackTrace = stackTrace; - return this; - } + public LogMessage stackTrace(String stackTrace) { + this.stackTrace = stackTrace; + return this; + } - public Long getTracerTime() { - return tracerTime; - } + public Long getTracerTime() { + return tracerTime; + } - public LogMessage tracerTime(Long tracerTime) { - this.tracerTime = tracerTime; - return this; - } + public LogMessage tracerTime(Long tracerTime) { + this.tracerTime = tracerTime; + return this; + } - public LogMessage count(int count) { - this.count = count; - return this; - } + public LogMessage count(int count) { + this.count = count; + return this; + } - public int getCount() { - return count; - } + public int getCount() { + return count; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/api/LogMessageLevel.java b/telemetry/src/main/java/datadog/telemetry/api/LogMessageLevel.java index 5cb9ae30c27..5a4a8bd062e 100644 --- a/telemetry/src/main/java/datadog/telemetry/api/LogMessageLevel.java +++ b/telemetry/src/main/java/datadog/telemetry/api/LogMessageLevel.java @@ -3,21 +3,21 @@ import javax.annotation.Nullable; public enum LogMessageLevel { - ERROR, - WARN, - DEBUG; + ERROR, + WARN, + DEBUG; - @Nullable - public static LogMessageLevel fromString(String value) { - switch (value) { - case "ERROR": - return ERROR; - case "WARN": - return WARN; - case "DEBUG": - return DEBUG; - default: - return null; + @Nullable + public static LogMessageLevel fromString(String value) { + switch (value) { + case "ERROR": + return ERROR; + case "WARN": + return WARN; + case "DEBUG": + return DEBUG; + default: + return null; + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/api/Metric.java b/telemetry/src/main/java/datadog/telemetry/api/Metric.java index 03097bda3d8..eba024bb7ca 100644 --- a/telemetry/src/main/java/datadog/telemetry/api/Metric.java +++ b/telemetry/src/main/java/datadog/telemetry/api/Metric.java @@ -4,119 +4,119 @@ import java.util.List; public class Metric { - private String namespace; - private Boolean common; - private String metric; - private List> points = new ArrayList<>(); - private List tags = new ArrayList<>(); + private String namespace; + private Boolean common; + private String metric; + private List> points = new ArrayList<>(); + private List tags = new ArrayList<>(); - public enum TypeEnum { - GAUGE("gauge"), + public enum TypeEnum { + GAUGE("gauge"), - @com.squareup.moshi.Json(name = "rate") - RATE("rate"), + @com.squareup.moshi.Json(name = "rate") + RATE("rate"), - @com.squareup.moshi.Json(name = "count") - COUNT("count"); + @com.squareup.moshi.Json(name = "count") + COUNT("count"); - final String value; + final String value; - TypeEnum(String v) { - value = v; - } + TypeEnum(String v) { + value = v; + } - public String value() { - return value; - } + public String value() { + return value; + } - @Override - public String toString() { - return value; + @Override + public String toString() { + return value; + } } - } - private TypeEnum type; + private TypeEnum type; - public Boolean getCommon() { - return common; - } + public Boolean getCommon() { + return common; + } - public void setCommon(boolean common) { - common(common); - } + public void setCommon(boolean common) { + common(common); + } - public Metric common(Boolean common) { - this.common = common; - return this; - } + public Metric common(Boolean common) { + this.common = common; + return this; + } - public String getMetric() { - return metric; - } + public String getMetric() { + return metric; + } - public void setMetric(String metric) { - this.metric = metric; - } + public void setMetric(String metric) { + this.metric = metric; + } - public Metric metric(String metric) { - this.metric = metric; - return this; - } + public Metric metric(String metric) { + this.metric = metric; + return this; + } - public List> getPoints() { - return points; - } + public List> getPoints() { + return points; + } - public void setPoints(List> points) { - this.points = points; - } + public void setPoints(List> points) { + this.points = points; + } - public Metric points(List> points) { - this.points = points; - return this; - } + public Metric points(List> points) { + this.points = points; + return this; + } - public Metric addPointsItem(List pointsItem) { - this.points.add(pointsItem); - return this; - } + public Metric addPointsItem(List pointsItem) { + this.points.add(pointsItem); + return this; + } - public List getTags() { - return tags; - } + public List getTags() { + return tags; + } - public void setTags(List tags) { - tags(tags); - } + public void setTags(List tags) { + tags(tags); + } - public Metric tags(List tags) { - this.tags = tags; - return this; - } + public Metric tags(List tags) { + this.tags = tags; + return this; + } - public TypeEnum getType() { - return type; - } + public TypeEnum getType() { + return type; + } - public void setType(TypeEnum type) { - type(type); - } + public void setType(TypeEnum type) { + type(type); + } - public Metric type(TypeEnum type) { - this.type = type; - return this; - } + public Metric type(TypeEnum type) { + this.type = type; + return this; + } - public Metric namespace(final String namespace) { - setNamespace(namespace); - return this; - } + public Metric namespace(final String namespace) { + setNamespace(namespace); + return this; + } - public void setNamespace(final String namespace) { - this.namespace = namespace; - } + public void setNamespace(final String namespace) { + this.namespace = namespace; + } - public String getNamespace() { - return namespace; - } + public String getNamespace() { + return namespace; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/api/RequestType.java b/telemetry/src/main/java/datadog/telemetry/api/RequestType.java index 1ea613d9e90..a27ff63db26 100644 --- a/telemetry/src/main/java/datadog/telemetry/api/RequestType.java +++ b/telemetry/src/main/java/datadog/telemetry/api/RequestType.java @@ -1,28 +1,28 @@ package datadog.telemetry.api; public enum RequestType { - APP_STARTED("app-started"), - APP_DEPENDENCIES_LOADED("app-dependencies-loaded"), - APP_INTEGRATIONS_CHANGE("app-integrations-change"), - APP_CLIENT_CONFIGURATION_CHANGE("app-client-configuration-change"), - APP_HEARTBEAT("app-heartbeat"), - APP_EXTENDED_HEARTBEAT("app-extended-heartbeat"), - APP_CLOSING("app-closing"), - APP_PRODUCT_CHANGE("app-product-change"), - GENERATE_METRICS("generate-metrics"), - LOGS("logs"), - DISTRIBUTIONS("distributions"), - MESSAGE_BATCH("message-batch"), - APP_ENDPOINTS("app-endpoints"); + APP_STARTED("app-started"), + APP_DEPENDENCIES_LOADED("app-dependencies-loaded"), + APP_INTEGRATIONS_CHANGE("app-integrations-change"), + APP_CLIENT_CONFIGURATION_CHANGE("app-client-configuration-change"), + APP_HEARTBEAT("app-heartbeat"), + APP_EXTENDED_HEARTBEAT("app-extended-heartbeat"), + APP_CLOSING("app-closing"), + APP_PRODUCT_CHANGE("app-product-change"), + GENERATE_METRICS("generate-metrics"), + LOGS("logs"), + DISTRIBUTIONS("distributions"), + MESSAGE_BATCH("message-batch"), + APP_ENDPOINTS("app-endpoints"); - private final String value; + private final String value; - RequestType(String value) { - this.value = value; - } + RequestType(String value) { + this.value = value; + } - @Override - public String toString() { - return value; - } + @Override + public String toString() { + return value; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java b/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java index 6765f77f4de..46f2fde7bc3 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java @@ -22,252 +22,250 @@ public final class Dependency { - private static final Logger log = LoggerFactory.getLogger(Dependency.class); + private static final Logger log = LoggerFactory.getLogger(Dependency.class); - private static final Pattern FILE_REGEX = - Pattern.compile("^(.+?)(?:-([0-9][^-]+(?:-\\w+)?))?\\.jar$"); + private static final Pattern FILE_REGEX = Pattern.compile("^(.+?)(?:-([0-9][^-]+(?:-\\w+)?))?\\.jar$"); - private static final byte[] buf = new byte[8192]; + private static final byte[] buf = new byte[8192]; - private static final MessageDigest md; + private static final MessageDigest md; - static { - MessageDigest digest = null; - try { - digest = MessageDigest.getInstance("SHA-1"); - } catch (NoSuchAlgorithmException e) { - // should not happen - log.error("Unable to create cipher", e); + static { + MessageDigest digest = null; + try { + digest = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + // should not happen + log.error("Unable to create cipher", e); + } + md = digest; } - md = digest; - } - - public final String name; - public final String version; - public final String source; - public final String hash; - - /** - * Optional SCA reachability metadata. Each entry is a stringified JSON object conforming to the - * RFC reachability payload: {@code - * {"id":"GHSA-xxx","reached":[{"path":"...","symbol":"","line":1}]}}. Null for regular - * dependencies; non-null only when injected by {@code ScaReachabilityPeriodicAction}. Not - * serialized by the telemetry pipeline unless explicitly written by {@link - * datadog.telemetry.TelemetryRequestBody#writeDependency}. - */ - @Nullable public final List reachabilityMetadata; - - public Dependency(String name, String version, String source, @Nullable String hash) { - this(name, version, source, hash, null); - } - public Dependency( - String name, - String version, - String source, - @Nullable String hash, - @Nullable List reachabilityMetadata) { - this.name = name; - this.version = version; - this.source = source; - this.hash = hash; - this.reachabilityMetadata = - reachabilityMetadata != null ? Collections.unmodifiableList(reachabilityMetadata) : null; - } + public final String name; + public final String version; + public final String source; + public final String hash; - @Override - public String toString() { - return "Dependency{" - + "name='" - + name - + '\'' - + ", version='" - + version - + '\'' - + ", source='" - + source - + '\'' - + ", hash='" - + hash - + '\'' - + '}'; - } + /** + * Optional SCA reachability metadata. Each entry is a stringified JSON object conforming to the + * RFC reachability payload: {@code + * {"id":"GHSA-xxx","reached":[{"path":"...","symbol":"","line":1}]}}. Null for regular + * dependencies; non-null only when injected by {@code ScaReachabilityPeriodicAction}. Not + * serialized by the telemetry pipeline unless explicitly written by {@link + * datadog.telemetry.TelemetryRequestBody#writeDependency}. + */ + @Nullable + public final List reachabilityMetadata; - public static List fromMavenPom( - final String jar, Map pomProperties) { - if (pomProperties == null) { - return Collections.emptyList(); + public Dependency(String name, String version, String source, @Nullable String hash) { + this(name, version, source, hash, null); } - List dependencies = new ArrayList<>(pomProperties.size()); - for (final Map.Entry entry : pomProperties.entrySet()) { - final Properties properties = entry.getValue(); - final String groupId = properties.getProperty("groupId"); - final String artifactId = properties.getProperty("artifactId"); - final String version = properties.getProperty("version"); - final String name = groupId + ":" + artifactId; - if (groupId == null || artifactId == null || version == null) { - log.debug( - "pom.properties does not have all the required properties: " - + "jar={}, entry={}, groupId={}, artifactId={}, version={}", - jar, - entry.getKey(), - groupId, - artifactId, - version); - } else { - log.debug( - "dependency found in pom.properties: " - + "jar={}, entry={}, groupId={}, artifactId={}, version={}", - jar, - entry.getKey(), - groupId, - artifactId, - version); - dependencies.add(new Dependency(name, version, jar, null)); - } + public Dependency( + String name, + String version, + String source, + @Nullable String hash, + @Nullable List reachabilityMetadata) { + this.name = name; + this.version = version; + this.source = source; + this.hash = hash; + this.reachabilityMetadata = + reachabilityMetadata != null ? Collections.unmodifiableList(reachabilityMetadata) : null; } - return dependencies; - } - @SuppressFBWarnings( - value = {"USO_UNSAFE_METHOD_SYNCHRONIZATION", "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION"}, - justification = - "Static synchronized method guards the shared static MessageDigest and byte[] buffer; the" - + " Dependency.class lock is internal and not exposed to application code.") - public static synchronized Dependency guessFallbackNoPom( - Attributes manifest, String source, InputStream is) throws IOException { - final int slashIndex = source.lastIndexOf('/'); - if (slashIndex >= 0) { - source = source.substring(slashIndex + 1); + @Override + public String toString() { + return "Dependency{" + + "name='" + + name + + '\'' + + ", version='" + + version + + '\'' + + ", source='" + + source + + '\'' + + ", hash='" + + hash + + '\'' + + '}'; } - String artifactId; - String groupId = null; - String version; - String hash = null; + public static List fromMavenPom(final String jar, Map pomProperties) { + if (pomProperties == null) { + return Collections.emptyList(); + } + List dependencies = new ArrayList<>(pomProperties.size()); + for (final Map.Entry entry : pomProperties.entrySet()) { + final Properties properties = entry.getValue(); + final String groupId = properties.getProperty("groupId"); + final String artifactId = properties.getProperty("artifactId"); + final String version = properties.getProperty("version"); + final String name = groupId + ":" + artifactId; - // Guess from manifest - String bundleSymbolicName = null; - String implementationTitle = null; - String bundleName = null; - String bundleVersion = null; - String implementationVersion = null; - if (manifest != null) { - bundleSymbolicName = manifest.getValue("bundle-symbolicname"); - bundleName = manifest.getValue("bundle-name"); - bundleVersion = manifest.getValue("bundle-version"); - implementationTitle = manifest.getValue("implementation-title"); - implementationVersion = manifest.getValue("implementation-version"); + if (groupId == null || artifactId == null || version == null) { + log.debug( + "pom.properties does not have all the required properties: " + + "jar={}, entry={}, groupId={}, artifactId={}, version={}", + jar, + entry.getKey(), + groupId, + artifactId, + version); + } else { + log.debug( + "dependency found in pom.properties: " + + "jar={}, entry={}, groupId={}, artifactId={}, version={}", + jar, + entry.getKey(), + groupId, + artifactId, + version); + dependencies.add(new Dependency(name, version, jar, null)); + } + } + return dependencies; } - // Guess from file name - String fileNameArtifact = null; - String fileNameVersion = null; - Matcher m = FILE_REGEX.matcher(source); - if (m.matches()) { - fileNameArtifact = m.group(1); - fileNameVersion = m.group(2); - } else { - // This code path should not be exercised right now, although it might in the future (e.g. - // Knopflerfish uses - // a jar storage without .jar extension). We used to strip the extension here, but if we - // unexpectedly get here, - // it would be better to get something informative even if weird. So just fallback to the full - // name. - fileNameArtifact = source; - } + @SuppressFBWarnings( + value = {"USO_UNSAFE_METHOD_SYNCHRONIZATION", "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION"}, + justification = "Static synchronized method guards the shared static MessageDigest and byte[] buffer; the" + + " Dependency.class lock is internal and not exposed to application code.") + public static synchronized Dependency guessFallbackNoPom(Attributes manifest, String source, InputStream is) + throws IOException { + final int slashIndex = source.lastIndexOf('/'); + if (slashIndex >= 0) { + source = source.substring(slashIndex + 1); + } - // Find for the most suitable name (based on priority) - if (isValidArtifactId(bundleName)) { - artifactId = bundleName; - } else if (isValidArtifactId(implementationTitle)) { - artifactId = implementationTitle; - } else { - artifactId = fileNameArtifact; - } + String artifactId; + String groupId = null; + String version; + String hash = null; - // Bundle-Version and Implementation-Version have precedence only if they are equal. - if (equalsNonNull(bundleVersion, implementationVersion)) { - version = bundleVersion; - } else if (hasText(fileNameVersion)) { - version = fileNameVersion; - } else if (hasText(bundleVersion)) { - version = bundleVersion; - } else if (hasText(implementationVersion)) { - version = implementationVersion; - } else { - version = ""; - } + // Guess from manifest + String bundleSymbolicName = null; + String implementationTitle = null; + String bundleName = null; + String bundleVersion = null; + String implementationVersion = null; + if (manifest != null) { + bundleSymbolicName = manifest.getValue("bundle-symbolicname"); + bundleName = manifest.getValue("bundle-name"); + bundleVersion = manifest.getValue("bundle-version"); + implementationTitle = manifest.getValue("implementation-title"); + implementationVersion = manifest.getValue("implementation-version"); + } - // Try to get groupId from bundleSymbolicName and bundleName (or artifact id) - if (hasText(bundleSymbolicName)) { - groupId = parseGroupId(bundleSymbolicName, fileNameArtifact); - if (!isValidGroupId(groupId)) { - groupId = parseGroupId(bundleSymbolicName, bundleName); + // Guess from file name + String fileNameArtifact = null; + String fileNameVersion = null; + Matcher m = FILE_REGEX.matcher(source); + if (m.matches()) { + fileNameArtifact = m.group(1); + fileNameVersion = m.group(2); + } else { + // This code path should not be exercised right now, although it might in the future (e.g. + // Knopflerfish uses + // a jar storage without .jar extension). We used to strip the extension here, but if we + // unexpectedly get here, + // it would be better to get something informative even if weird. So just fallback to the full + // name. + fileNameArtifact = source; + } - if (!isValidGroupId(groupId)) { - groupId = null; + // Find for the most suitable name (based on priority) + if (isValidArtifactId(bundleName)) { + artifactId = bundleName; + } else if (isValidArtifactId(implementationTitle)) { + artifactId = implementationTitle; + } else { + artifactId = fileNameArtifact; } - } - } - String name = artifactId; - if (groupId != null) { - name = groupId + ":" + artifactId; - } - if (md != null) { - // Compute hash for all dependencies that have no pom - // No reliable version calculate hash and use any version - md.reset(); - is = new DigestInputStream(is, md); - while (is.read(buf, 0, buf.length) > 0) {} - hash = String.format("%040X", new BigInteger(1, md.digest())); - } - log.debug("No maven dependency added {}.{} jar name {} hash {}", name, version, source, hash); - return new Dependency(name, version, source, hash); - } + // Bundle-Version and Implementation-Version have precedence only if they are equal. + if (equalsNonNull(bundleVersion, implementationVersion)) { + version = bundleVersion; + } else if (hasText(fileNameVersion)) { + version = fileNameVersion; + } else if (hasText(bundleVersion)) { + version = bundleVersion; + } else if (hasText(implementationVersion)) { + version = implementationVersion; + } else { + version = ""; + } - private static boolean isValidArtifactId(String artifactId) { - return hasText(artifactId) - && !artifactId.contains(" ") - && !artifactId.contains(".") - && artifactId.toLowerCase(Locale.ROOT).equals(artifactId); - } + // Try to get groupId from bundleSymbolicName and bundleName (or artifact id) + if (hasText(bundleSymbolicName)) { + groupId = parseGroupId(bundleSymbolicName, fileNameArtifact); + if (!isValidGroupId(groupId)) { + groupId = parseGroupId(bundleSymbolicName, bundleName); - private static boolean isValidGroupId(String group) { - return hasText(group) - && !group.contains(" ") - && group.contains(".") - && group.toLowerCase(Locale.ROOT).equals(group); - } + if (!isValidGroupId(groupId)) { + groupId = null; + } + } + } + String name = artifactId; + if (groupId != null) { + name = groupId + ":" + artifactId; + } - private static boolean hasText(final String value) { - return value != null && !value.isEmpty(); - } + if (md != null) { + // Compute hash for all dependencies that have no pom + // No reliable version calculate hash and use any version + md.reset(); + is = new DigestInputStream(is, md); + while (is.read(buf, 0, buf.length) > 0) {} + hash = String.format("%040X", new BigInteger(1, md.digest())); + } + log.debug("No maven dependency added {}.{} jar name {} hash {}", name, version, source, hash); + return new Dependency(name, version, source, hash); + } - private static boolean equalsNonNull(String s1, String s2) { - return s1 != null && s1.equals(s2); - } + private static boolean isValidArtifactId(String artifactId) { + return hasText(artifactId) + && !artifactId.contains(" ") + && !artifactId.contains(".") + && artifactId.toLowerCase(Locale.ROOT).equals(artifactId); + } - private static String parseGroupId(String bundleSymbolicName, String bundleName) { - // Some tools create Bundle-Symbolicname as `groupId.artifactid`. - // We'll try to infer group id based on that. - final String bundleNameWithPrefix = "." + bundleName; - if (!bundleSymbolicName.endsWith(bundleNameWithPrefix)) { - return null; + private static boolean isValidGroupId(String group) { + return hasText(group) + && !group.contains(" ") + && group.contains(".") + && group.toLowerCase(Locale.ROOT).equals(group); } - final int truncateLen = bundleSymbolicName.length() - bundleNameWithPrefix.length(); - final String groupId = bundleSymbolicName.substring(0, truncateLen); + private static boolean hasText(final String value) { + return value != null && !value.isEmpty(); + } - // Sometimes this will lead to an incorrect group id, in cases like com.opencsv / opencsv, which - // are frequent. - // This will trim both single word group ids, as well as prefixes suck as `uk.ac`. - if (groupId.contains(".") && groupId.length() > 5) { - return groupId; + private static boolean equalsNonNull(String s1, String s2) { + return s1 != null && s1.equals(s2); } - return null; - } + private static String parseGroupId(String bundleSymbolicName, String bundleName) { + // Some tools create Bundle-Symbolicname as `groupId.artifactid`. + // We'll try to infer group id based on that. + final String bundleNameWithPrefix = "." + bundleName; + if (!bundleSymbolicName.endsWith(bundleNameWithPrefix)) { + return null; + } + + final int truncateLen = bundleSymbolicName.length() - bundleNameWithPrefix.length(); + final String groupId = bundleSymbolicName.substring(0, truncateLen); + + // Sometimes this will lead to an incorrect group id, in cases like com.opencsv / opencsv, which + // are frequent. + // This will trim both single word group ids, as well as prefixes suck as `uk.ac`. + if (groupId.contains(".") && groupId.length() > 5) { + return groupId; + } + + return null; + } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyPeriodicAction.java index d25716c1f15..f6346e74f1d 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyPeriodicAction.java @@ -4,17 +4,17 @@ import datadog.telemetry.TelemetryService; public class DependencyPeriodicAction implements TelemetryRunnable.TelemetryPeriodicAction { - private final DependencyService dependencyService; + private final DependencyService dependencyService; - public DependencyPeriodicAction(DependencyService dependencyService) { - this.dependencyService = dependencyService; - } + public DependencyPeriodicAction(DependencyService dependencyService) { + this.dependencyService = dependencyService; + } - @Override - public void doIteration(TelemetryService telService) { - // TODO Could just consume from the original queue instead? - for (Dependency dep : dependencyService.drainDeterminedDependencies()) { - telService.addDependency(dep); + @Override + public void doIteration(TelemetryService telService) { + // TODO Could just consume from the original queue instead? + for (Dependency dep : dependencyService.drainDeterminedDependencies()) { + telService.addDependency(dep); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java index 91ec8c52162..5dbd9e170b1 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java @@ -11,87 +11,85 @@ public class DependencyResolver { - private static final Logger log = LoggerFactory.getLogger(DependencyResolver.class); + private static final Logger log = LoggerFactory.getLogger(DependencyResolver.class); - public static List resolve(URI uri) { - try { - return internalResolve(uri); - } catch (Throwable t) { - log.debug("Failed to determine dependency for uri {}", uri, t); + public static List resolve(URI uri) { + try { + return internalResolve(uri); + } catch (Throwable t) { + log.debug("Failed to determine dependency for uri {}", uri, t); + } + return Collections.emptyList(); } - return Collections.emptyList(); - } - static List internalResolve(final URI uri) throws IOException { - final String scheme = uri.getScheme(); - JarReader.Extracted metadata = null; - switch (scheme) { - case "file": - final File f = uri.isOpaque() ? new File(uri.getSchemeSpecificPart()) : new File(uri); - final String path = f.getAbsolutePath(); - metadata = JarReader.readJarFile(path); - break; - case "jar": - metadata = resolveNestedJar(uri); - break; - default: + static List internalResolve(final URI uri) throws IOException { + final String scheme = uri.getScheme(); + JarReader.Extracted metadata = null; + switch (scheme) { + case "file": + final File f = uri.isOpaque() ? new File(uri.getSchemeSpecificPart()) : new File(uri); + final String path = f.getAbsolutePath(); + metadata = JarReader.readJarFile(path); + break; + case "jar": + metadata = resolveNestedJar(uri); + break; + default: + } + if (metadata == null) { + log.debug("unsupported dependency type: {}", uri); + return Collections.emptyList(); + } + if (metadata.isDirectory) { + log.debug("Extracting dependencies from directories is not supported: {}", uri); + return Collections.emptyList(); + } + final List dependencies = Dependency.fromMavenPom(metadata.jarName, metadata.pomProperties); + if (!dependencies.isEmpty()) { + return dependencies; + } + try (final InputStream is = metadata.inputStreamSupplier.get()) { + return Collections.singletonList(Dependency.guessFallbackNoPom(metadata.manifest, metadata.jarName, is)); + } } - if (metadata == null) { - log.debug("unsupported dependency type: {}", uri); - return Collections.emptyList(); - } - if (metadata.isDirectory) { - log.debug("Extracting dependencies from directories is not supported: {}", uri); - return Collections.emptyList(); - } - final List dependencies = - Dependency.fromMavenPom(metadata.jarName, metadata.pomProperties); - if (!dependencies.isEmpty()) { - return dependencies; - } - try (final InputStream is = metadata.inputStreamSupplier.get()) { - return Collections.singletonList( - Dependency.guessFallbackNoPom(metadata.manifest, metadata.jarName, is)); - } - } - private static JarReader.Extracted resolveNestedJar(final URI uri) throws IOException { - String path = uri.getSchemeSpecificPart(); + private static JarReader.Extracted resolveNestedJar(final URI uri) throws IOException { + String path = uri.getSchemeSpecificPart(); - // Strip optional trailing '!' or '!/'. - if (path.endsWith("!")) { - path = path.substring(0, path.length() - 1); - } else if (path.endsWith("!/")) { - path = path.substring(0, path.length() - 2); - } + // Strip optional trailing '!' or '!/'. + if (path.endsWith("!")) { + path = path.substring(0, path.length() - 1); + } else if (path.endsWith("!/")) { + path = path.substring(0, path.length() - 2); + } - if (path.startsWith("file:")) { - // Old style nested dependencies, as seen in Spring Boot 2 and others. - // These look like jar:file:/path/to.jar!/path/to/nested.jar!/ - path = path.substring("file:".length()); - final int sepIdx = path.indexOf("!/"); - if (sepIdx == -1) { - // JBoss may use the "jar:file" format to reference jar files instead of nested jars. - // These look like: jar:file:/path/to.jar!/ - return JarReader.readJarFile(path); - } - final String outerPath = path.substring(0, sepIdx); - final String innerPath = path.substring(sepIdx + 2); - return JarReader.readNestedJarFile(outerPath, innerPath); - } else if (path.startsWith("nested:")) { - // New style nested dependencies, for Spring 3.2+. - // These look like jar:nested:/path/to.jar/!path/to/nested.jar!/ (yes, /!, not !/). - // See https://docs.spring.io/spring-boot/specification/executable-jar/jarfile-class.html - path = path.substring("nested:".length()); - final int sepIdx = path.indexOf("/!"); - if (sepIdx == -1) { - throw new IllegalArgumentException("Invalid nested jar path: " + path); - } - final String outerPath = path.substring(0, sepIdx); - final String innerPath = path.substring(sepIdx + 2); - return JarReader.readNestedJarFile(outerPath, innerPath); - } else { - return null; + if (path.startsWith("file:")) { + // Old style nested dependencies, as seen in Spring Boot 2 and others. + // These look like jar:file:/path/to.jar!/path/to/nested.jar!/ + path = path.substring("file:".length()); + final int sepIdx = path.indexOf("!/"); + if (sepIdx == -1) { + // JBoss may use the "jar:file" format to reference jar files instead of nested jars. + // These look like: jar:file:/path/to.jar!/ + return JarReader.readJarFile(path); + } + final String outerPath = path.substring(0, sepIdx); + final String innerPath = path.substring(sepIdx + 2); + return JarReader.readNestedJarFile(outerPath, innerPath); + } else if (path.startsWith("nested:")) { + // New style nested dependencies, for Spring 3.2+. + // These look like jar:nested:/path/to.jar/!path/to/nested.jar!/ (yes, /!, not !/). + // See https://docs.spring.io/spring-boot/specification/executable-jar/jarfile-class.html + path = path.substring("nested:".length()); + final int sepIdx = path.indexOf("/!"); + if (sepIdx == -1) { + throw new IllegalArgumentException("Invalid nested jar path: " + path); + } + final String outerPath = path.substring(0, sepIdx); + final String innerPath = path.substring(sepIdx + 2); + return JarReader.readNestedJarFile(outerPath, innerPath); + } else { + return null; + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolverQueue.java b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolverQueue.java index 875a2d57c29..1b1edbd1809 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolverQueue.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolverQueue.java @@ -13,81 +13,80 @@ public class DependencyResolverQueue { - private static final Logger log = LoggerFactory.getLogger(DependencyResolverQueue.class); + private static final Logger log = LoggerFactory.getLogger(DependencyResolverQueue.class); - private final Queue newUrlsQueue; - private final Set processedUrlsSet; // guarded by this - private static int MAX_QUEUE_SIZE = Config.get().getTelemetryDependencyResolutionQueueSize(); + private final Queue newUrlsQueue; + private final Set processedUrlsSet; // guarded by this + private static int MAX_QUEUE_SIZE = Config.get().getTelemetryDependencyResolutionQueueSize(); - private boolean resolverQueueDisabled = false; + private boolean resolverQueueDisabled = false; - public DependencyResolverQueue() { - newUrlsQueue = new ConcurrentLinkedQueue<>(); - processedUrlsSet = new HashSet<>(); - } - - // This constructor is intended for testing purposes only - public DependencyResolverQueue(int maxQueueSize) { - MAX_QUEUE_SIZE = maxQueueSize; - newUrlsQueue = new ConcurrentLinkedQueue<>(); - processedUrlsSet = new HashSet<>(); - } - - public void queueURI(URI uri) { - if (resolverQueueDisabled || uri == null) { - return; + public DependencyResolverQueue() { + newUrlsQueue = new ConcurrentLinkedQueue<>(); + processedUrlsSet = new HashSet<>(); } - // once the queue reaches MAX_QUEUE_SIZE, we have reached the user-defined limit of - // unique dependencies to queue and we will disable the resolver queue from adding any more - // URI's. - if (processedUrlsSet.size() >= MAX_QUEUE_SIZE || newUrlsQueue.size() >= MAX_QUEUE_SIZE) { - resolverQueueDisabled = true; - - log.warn( - "DependencyResolverQueue limit has been reached, additional dependencies will not be queued"); - - // this resolver will be disabled so we can clear the stored URIs from processedUrlsSet - // since they will no longer be checked against for duplicates - synchronized (this) { - processedUrlsSet.clear(); - } - return; + // This constructor is intended for testing purposes only + public DependencyResolverQueue(int maxQueueSize) { + MAX_QUEUE_SIZE = maxQueueSize; + newUrlsQueue = new ConcurrentLinkedQueue<>(); + processedUrlsSet = new HashSet<>(); } - // we ignore .class files directly within webapp folder (they aren't part of dependencies) - String path = uri.getPath(); - if (path != null && path.endsWith(".class")) { - return; + public void queueURI(URI uri) { + if (resolverQueueDisabled || uri == null) { + return; + } + + // once the queue reaches MAX_QUEUE_SIZE, we have reached the user-defined limit of + // unique dependencies to queue and we will disable the resolver queue from adding any more + // URI's. + if (processedUrlsSet.size() >= MAX_QUEUE_SIZE || newUrlsQueue.size() >= MAX_QUEUE_SIZE) { + resolverQueueDisabled = true; + + log.warn("DependencyResolverQueue limit has been reached, additional dependencies will not be queued"); + + // this resolver will be disabled so we can clear the stored URIs from processedUrlsSet + // since they will no longer be checked against for duplicates + synchronized (this) { + processedUrlsSet.clear(); + } + return; + } + + // we ignore .class files directly within webapp folder (they aren't part of dependencies) + String path = uri.getPath(); + if (path != null && path.endsWith(".class")) { + return; + } + + // ignore already processed url + synchronized (this) { + if (!processedUrlsSet.add(uri)) { + return; + } + } + + newUrlsQueue.add(uri); } - // ignore already processed url - synchronized (this) { - if (!processedUrlsSet.add(uri)) { - return; - } - } + public List pollDependency() { + URI uri = newUrlsQueue.poll(); - newUrlsQueue.add(uri); - } + // no new deps + if (uri == null) { + return Collections.emptyList(); + } - public List pollDependency() { - URI uri = newUrlsQueue.poll(); + List dep = DependencyResolver.resolve(uri); + if (dep.isEmpty()) { + log.debug("unable to detect dependency for URI {}", uri); + return Collections.emptyList(); + } + if (log.isDebugEnabled()) { + log.debug("dependency detected {} for {}", dep, uri); + } - // no new deps - if (uri == null) { - return Collections.emptyList(); + return dep; } - - List dep = DependencyResolver.resolve(uri); - if (dep.isEmpty()) { - log.debug("unable to detect dependency for URI {}", uri); - return Collections.emptyList(); - } - if (log.isDebugEnabled()) { - log.debug("dependency detected {} for {}", dep, uri); - } - - return dep; - } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyService.java b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyService.java index 0e0e95423f2..e07bfc02878 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyService.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyService.java @@ -22,92 +22,91 @@ */ public class DependencyService implements Runnable { - private static final Logger log = LoggerFactory.getLogger(DependencyService.class); - - private final DependencyResolverQueue resolverQueue = new DependencyResolverQueue(); - - private final BlockingQueue newDependencies = new LinkedBlockingQueue<>(); - - private AgentTaskScheduler.Scheduled scheduledTask; - - public void schedulePeriodicResolution() { - scheduledTask = - AgentTaskScheduler.get() - .scheduleAtFixedRate( - AgentTaskScheduler.RunnableTask.INSTANCE, - this, - 0, - Config.get().getDependecyResolutionPeriodMillis(), - TimeUnit.MILLISECONDS); - } - - public void resolveOneDependency() { - List dependencies = resolverQueue.pollDependency(); - if (!dependencies.isEmpty()) { - for (Dependency dependency : dependencies) { - log.debug("Resolved dependency {}", dependency.name); - newDependencies.add(dependency); - } + private static final Logger log = LoggerFactory.getLogger(DependencyService.class); + + private final DependencyResolverQueue resolverQueue = new DependencyResolverQueue(); + + private final BlockingQueue newDependencies = new LinkedBlockingQueue<>(); + + private AgentTaskScheduler.Scheduled scheduledTask; + + public void schedulePeriodicResolution() { + scheduledTask = AgentTaskScheduler.get() + .scheduleAtFixedRate( + AgentTaskScheduler.RunnableTask.INSTANCE, + this, + 0, + Config.get().getDependecyResolutionPeriodMillis(), + TimeUnit.MILLISECONDS); + } + + public void resolveOneDependency() { + List dependencies = resolverQueue.pollDependency(); + if (!dependencies.isEmpty()) { + for (Dependency dependency : dependencies) { + log.debug("Resolved dependency {}", dependency.name); + newDependencies.add(dependency); + } + } } - } - - /** - * Registers this service as a no-op class file transformer. - * - * @param instrumentation instrumentation instance to register on - */ - public void installOn(Instrumentation instrumentation) { - instrumentation.addTransformer(new LocationsCollectingTransformer(this)); - } - - public Collection drainDeterminedDependencies() { - List list = new LinkedList<>(); - int drained = newDependencies.drainTo(list); - if (drained > 0) { - return list; + + /** + * Registers this service as a no-op class file transformer. + * + * @param instrumentation instrumentation instance to register on + */ + public void installOn(Instrumentation instrumentation) { + instrumentation.addTransformer(new LocationsCollectingTransformer(this)); } - return Collections.emptyList(); - } - - public void addURL(URL url) { - resolverQueue.queueURI(convertToURI(url)); - } - - @VisibleForTesting - URI convertToURI(URL location) { - URI uri = null; - - if (location.getProtocol().equals("vfs")) { - // resolve jboss virtual file system - try { - uri = JbossVirtualFileHelper.getJbossVfsPath(location); - } catch (RuntimeException rte) { - log.debug("Error in call to getJbossVfsPath", rte); - return null; - } + + public Collection drainDeterminedDependencies() { + List list = new LinkedList<>(); + int drained = newDependencies.drainTo(list); + if (drained > 0) { + return list; + } + return Collections.emptyList(); } - if (uri == null) { - try { - uri = new URI(location.toString().replace(" ", "%20")); - } catch (URISyntaxException e) { - log.warn("Error converting URL to URI", e); - // silently ignored - } + public void addURL(URL url) { + resolverQueue.queueURI(convertToURI(url)); } - return uri; - } + @VisibleForTesting + URI convertToURI(URL location) { + URI uri = null; + + if (location.getProtocol().equals("vfs")) { + // resolve jboss virtual file system + try { + uri = JbossVirtualFileHelper.getJbossVfsPath(location); + } catch (RuntimeException rte) { + log.debug("Error in call to getJbossVfsPath", rte); + return null; + } + } + + if (uri == null) { + try { + uri = new URI(location.toString().replace(" ", "%20")); + } catch (URISyntaxException e) { + log.warn("Error converting URL to URI", e); + // silently ignored + } + } + + return uri; + } - @Override - public void run() { - resolveOneDependency(); - } + @Override + public void run() { + resolveOneDependency(); + } - public void stop() { - if (scheduledTask != null) { - scheduledTask.cancel(); - scheduledTask = null; + public void stop() { + if (scheduledTask != null) { + scheduledTask.cancel(); + scheduledTask = null; + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/JarReader.java b/telemetry/src/main/java/datadog/telemetry/dependency/JarReader.java index ebbedc96a49..ab175c63d8b 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/JarReader.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/JarReader.java @@ -17,121 +17,118 @@ import java.util.zip.ZipEntry; class JarReader { - static class Extracted { - final String jarName; - final Map pomProperties; - final Attributes manifest; - final boolean isDirectory; - final InputStreamSupplier inputStreamSupplier; + static class Extracted { + final String jarName; + final Map pomProperties; + final Attributes manifest; + final boolean isDirectory; + final InputStreamSupplier inputStreamSupplier; - public Extracted( - final String jarName, - final Map pomProperties, - final Attributes manifest, - final boolean isDirectory, - final InputStreamSupplier inputStreamSupplier) { - this.jarName = jarName; - this.pomProperties = pomProperties; - this.manifest = manifest; - this.isDirectory = isDirectory; - this.inputStreamSupplier = inputStreamSupplier; - } + public Extracted( + final String jarName, + final Map pomProperties, + final Attributes manifest, + final boolean isDirectory, + final InputStreamSupplier inputStreamSupplier) { + this.jarName = jarName; + this.pomProperties = pomProperties; + this.manifest = manifest; + this.isDirectory = isDirectory; + this.inputStreamSupplier = inputStreamSupplier; + } - public interface InputStreamSupplier { - InputStream get() throws IOException; + public interface InputStreamSupplier { + InputStream get() throws IOException; + } } - } - public static Extracted readJarFile(String jarPath) throws IOException { - final File jarFile = new File(jarPath); - if (jarFile.isDirectory()) { - return new Extracted(jarFile.getName(), new HashMap<>(), new Attributes(), true, () -> null); - } - try (final JarFile jar = new JarFile(jarPath, false /* no verify */)) { - final Map pomProperties = new HashMap<>(); - final Enumeration entries = jar.entries(); - while (entries.hasMoreElements()) { - final ZipEntry entry = entries.nextElement(); - if (entry.getName().endsWith("pom.properties")) { - try (final InputStream is = jar.getInputStream(entry)) { - final Properties properties = new Properties(); - properties.load(is); - pomProperties.put(entry.getName(), properties); - } + public static Extracted readJarFile(String jarPath) throws IOException { + final File jarFile = new File(jarPath); + if (jarFile.isDirectory()) { + return new Extracted(jarFile.getName(), new HashMap<>(), new Attributes(), true, () -> null); + } + try (final JarFile jar = new JarFile(jarPath, false /* no verify */)) { + final Map pomProperties = new HashMap<>(); + final Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + final ZipEntry entry = entries.nextElement(); + if (entry.getName().endsWith("pom.properties")) { + try (final InputStream is = jar.getInputStream(entry)) { + final Properties properties = new Properties(); + properties.load(is); + pomProperties.put(entry.getName(), properties); + } + } + } + final Manifest manifest = jar.getManifest(); + final Attributes attributes = (manifest == null) ? new Attributes() : manifest.getMainAttributes(); + return new Extracted( + new File(jar.getName()).getName(), + pomProperties, + attributes, + false, + () -> Files.newInputStream(Paths.get(jarPath))); } - } - final Manifest manifest = jar.getManifest(); - final Attributes attributes = - (manifest == null) ? new Attributes() : manifest.getMainAttributes(); - return new Extracted( - new File(jar.getName()).getName(), - pomProperties, - attributes, - false, - () -> Files.newInputStream(Paths.get(jarPath))); } - } - public static Extracted readNestedJarFile(final String outerJarPath, final String innerJarPath) - throws IOException { - try (final JarFile outerJar = new JarFile(outerJarPath, false /* no verify */)) { - final ZipEntry entry = outerJar.getEntry(innerJarPath); - if (entry == null) { - throw new NoSuchFileException("Nested jar not found: " + innerJarPath); - } - if (entry.isDirectory()) { - return new Extracted( - new File(innerJarPath).getName(), new HashMap<>(), new Attributes(), true, () -> null); - } - try (final InputStream is = outerJar.getInputStream(entry); - final JarInputStream innerJar = new JarInputStream(is, false /* no verify */)) { - final Map pomProperties = new HashMap<>(); - ZipEntry innerEntry; - while ((innerEntry = innerJar.getNextEntry()) != null) { - if (innerEntry.getName().endsWith("pom.properties")) { - final Properties properties = new Properties(); - properties.load(innerJar); - pomProperties.put(innerEntry.getName(), properties); - } + public static Extracted readNestedJarFile(final String outerJarPath, final String innerJarPath) throws IOException { + try (final JarFile outerJar = new JarFile(outerJarPath, false /* no verify */)) { + final ZipEntry entry = outerJar.getEntry(innerJarPath); + if (entry == null) { + throw new NoSuchFileException("Nested jar not found: " + innerJarPath); + } + if (entry.isDirectory()) { + return new Extracted( + new File(innerJarPath).getName(), new HashMap<>(), new Attributes(), true, () -> null); + } + try (final InputStream is = outerJar.getInputStream(entry); + final JarInputStream innerJar = new JarInputStream(is, false /* no verify */)) { + final Map pomProperties = new HashMap<>(); + ZipEntry innerEntry; + while ((innerEntry = innerJar.getNextEntry()) != null) { + if (innerEntry.getName().endsWith("pom.properties")) { + final Properties properties = new Properties(); + properties.load(innerJar); + pomProperties.put(innerEntry.getName(), properties); + } + } + final Manifest manifest = innerJar.getManifest(); + final Attributes attributes = (manifest == null) ? new Attributes() : manifest.getMainAttributes(); + return new Extracted( + new File(innerJarPath).getName(), + pomProperties, + attributes, + false, + () -> new NestedJarInputStream(outerJarPath, innerJarPath)); + } } - final Manifest manifest = innerJar.getManifest(); - final Attributes attributes = - (manifest == null) ? new Attributes() : manifest.getMainAttributes(); - return new Extracted( - new File(innerJarPath).getName(), - pomProperties, - attributes, - false, - () -> new NestedJarInputStream(outerJarPath, innerJarPath)); - } } - } - static class NestedJarInputStream extends InputStream implements AutoCloseable { - private final JarFile outerJar; - private final InputStream innerInputStream; + static class NestedJarInputStream extends InputStream implements AutoCloseable { + private final JarFile outerJar; + private final InputStream innerInputStream; - public NestedJarInputStream(final String outerPath, final String innerPath) throws IOException { - super(); - this.outerJar = new JarFile(outerPath, false /* no verify */); - final ZipEntry entry = outerJar.getEntry(innerPath); - this.innerInputStream = outerJar.getInputStream(entry); - } + public NestedJarInputStream(final String outerPath, final String innerPath) throws IOException { + super(); + this.outerJar = new JarFile(outerPath, false /* no verify */); + final ZipEntry entry = outerJar.getEntry(innerPath); + this.innerInputStream = outerJar.getInputStream(entry); + } - @Override - public int read() throws IOException { - return this.innerInputStream.read(); - } + @Override + public int read() throws IOException { + return this.innerInputStream.read(); + } - @Override - public int read(byte[] b, int off, int len) throws IOException { - return this.innerInputStream.read(b, off, len); - } + @Override + public int read(byte[] b, int off, int len) throws IOException { + return this.innerInputStream.read(b, off, len); + } - @Override - public void close() throws IOException { - this.innerInputStream.close(); - this.outerJar.close(); + @Override + public void close() throws IOException { + this.innerInputStream.close(); + this.outerJar.close(); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/JbossVirtualFileHelper.java b/telemetry/src/main/java/datadog/telemetry/dependency/JbossVirtualFileHelper.java index 80b79d90ddb..ee03bab7793 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/JbossVirtualFileHelper.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/JbossVirtualFileHelper.java @@ -15,109 +15,106 @@ public class JbossVirtualFileHelper { - private final MethodHandle getPhysicalFile; - private final MethodHandle getName; - private final Field fileField; - - private static JbossVirtualFileHelper jbossVirtualFileHelper; - public static final JbossVirtualFileHelper FAILED_HELPER = new JbossVirtualFileHelper(); - - private static final Logger log = LoggerFactory.getLogger(JbossVirtualFileHelper.class); - - public JbossVirtualFileHelper(ClassLoader loader) throws Exception { - MethodHandles.Lookup lookup = MethodHandles.lookup(); - Class virtualFileCls = loader.loadClass("org.jboss.vfs.VirtualFile"); - getPhysicalFile = - lookup.findVirtual(virtualFileCls, "getPhysicalFile", MethodType.methodType(File.class)); - getName = lookup.findVirtual(virtualFileCls, "getName", MethodType.methodType(String.class)); - Class vfsFileUrlConnectionCls = - loader.loadClass("org.jboss.vfs.protocol.VirtualFileURLConnection"); - fileField = vfsFileUrlConnectionCls.getDeclaredField("file"); - fileField.setAccessible(true); - } - - private JbossVirtualFileHelper() { - getPhysicalFile = null; - getName = null; - fileField = null; - } - - public Object getVirtualFile(final URLConnection connection) { - try { - if (connection.getContentType() == null) { - return connection.getContent(); - } else { - return fileField.get(connection); - } - } catch (Throwable e) { - throw new UndeclaredThrowableException(e); + private final MethodHandle getPhysicalFile; + private final MethodHandle getName; + private final Field fileField; + + private static JbossVirtualFileHelper jbossVirtualFileHelper; + public static final JbossVirtualFileHelper FAILED_HELPER = new JbossVirtualFileHelper(); + + private static final Logger log = LoggerFactory.getLogger(JbossVirtualFileHelper.class); + + public JbossVirtualFileHelper(ClassLoader loader) throws Exception { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + Class virtualFileCls = loader.loadClass("org.jboss.vfs.VirtualFile"); + getPhysicalFile = lookup.findVirtual(virtualFileCls, "getPhysicalFile", MethodType.methodType(File.class)); + getName = lookup.findVirtual(virtualFileCls, "getName", MethodType.methodType(String.class)); + Class vfsFileUrlConnectionCls = loader.loadClass("org.jboss.vfs.protocol.VirtualFileURLConnection"); + fileField = vfsFileUrlConnectionCls.getDeclaredField("file"); + fileField.setAccessible(true); } - } - public File getPhysicalFile(Object virtualFile) { - try { - return (File) getPhysicalFile.invoke(virtualFile); - } catch (Throwable e) { - throw new UndeclaredThrowableException(e); + private JbossVirtualFileHelper() { + getPhysicalFile = null; + getName = null; + fileField = null; } - } - public String getName(Object virtualFile) { - try { - return (String) getName.invoke(virtualFile); - } catch (Throwable e) { - throw new UndeclaredThrowableException(e); + public Object getVirtualFile(final URLConnection connection) { + try { + if (connection.getContentType() == null) { + return connection.getContent(); + } else { + return fileField.get(connection); + } + } catch (Throwable e) { + throw new UndeclaredThrowableException(e); + } } - } - public static URI getJbossVfsPath(URL location) { - JbossVirtualFileHelper jbossVirtualFileHelper = JbossVirtualFileHelper.jbossVirtualFileHelper; - if (jbossVirtualFileHelper == JbossVirtualFileHelper.FAILED_HELPER) { - return null; + public File getPhysicalFile(Object virtualFile) { + try { + return (File) getPhysicalFile.invoke(virtualFile); + } catch (Throwable e) { + throw new UndeclaredThrowableException(e); + } } - URLConnection connection; - try { - connection = location.openConnection(); - } catch (IOException e) { - // silently ignored - return null; - } - if (connection == null) { - return null; + public String getName(Object virtualFile) { + try { + return (String) getName.invoke(virtualFile); + } catch (Throwable e) { + throw new UndeclaredThrowableException(e); + } } - if (jbossVirtualFileHelper == null) { - try { - jbossVirtualFileHelper = - JbossVirtualFileHelper.jbossVirtualFileHelper = - new JbossVirtualFileHelper(connection.getClass().getClassLoader()); - } catch (Exception e) { - log.debug("Error preparing for inspection of jboss virtual files", e); + public static URI getJbossVfsPath(URL location) { + JbossVirtualFileHelper jbossVirtualFileHelper = JbossVirtualFileHelper.jbossVirtualFileHelper; + if (jbossVirtualFileHelper == JbossVirtualFileHelper.FAILED_HELPER) { + return null; + } + + URLConnection connection; + try { + connection = location.openConnection(); + } catch (IOException e) { + // silently ignored + return null; + } + if (connection == null) { + return null; + } + + if (jbossVirtualFileHelper == null) { + try { + jbossVirtualFileHelper = JbossVirtualFileHelper.jbossVirtualFileHelper = + new JbossVirtualFileHelper(connection.getClass().getClassLoader()); + } catch (Exception e) { + log.debug("Error preparing for inspection of jboss virtual files", e); + return null; + } + } + + final Object virtualFile = jbossVirtualFileHelper.getVirtualFile(connection); + + // call VirtualFile.getPhysicalFile + File physicalFile = jbossVirtualFileHelper.getPhysicalFile(virtualFile); + if (physicalFile.isFile() && physicalFile.getName().endsWith(".jar")) { + return physicalFile.toURI(); + } else { + log.debug("Physical file {} is not a jar", physicalFile); + } + + // not sure what this is about, but it's what the old code used to do + // this is not correct as a general matter, since getName returns the virtual name, + // which may not match the physical name + // The original comment reads: + // "physical file returns 'content' folder, we manually resolve to the actual jar location" + String fileName = jbossVirtualFileHelper.getName(virtualFile); + physicalFile = new File(physicalFile.getParentFile(), fileName); + if (physicalFile.isFile()) { + return physicalFile.toURI(); + } return null; - } - } - - final Object virtualFile = jbossVirtualFileHelper.getVirtualFile(connection); - - // call VirtualFile.getPhysicalFile - File physicalFile = jbossVirtualFileHelper.getPhysicalFile(virtualFile); - if (physicalFile.isFile() && physicalFile.getName().endsWith(".jar")) { - return physicalFile.toURI(); - } else { - log.debug("Physical file {} is not a jar", physicalFile); - } - - // not sure what this is about, but it's what the old code used to do - // this is not correct as a general matter, since getName returns the virtual name, - // which may not match the physical name - // The original comment reads: - // "physical file returns 'content' folder, we manually resolve to the actual jar location" - String fileName = jbossVirtualFileHelper.getName(virtualFile); - physicalFile = new File(physicalFile.getParentFile(), fileName); - if (physicalFile.isFile()) { - return physicalFile.toURI(); } - return null; - } } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/LocationsCollectingTransformer.java b/telemetry/src/main/java/datadog/telemetry/dependency/LocationsCollectingTransformer.java index a001917dab6..4e1cb81cea0 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/LocationsCollectingTransformer.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/LocationsCollectingTransformer.java @@ -10,39 +10,38 @@ import org.slf4j.LoggerFactory; class LocationsCollectingTransformer implements ClassFileTransformer { - private static final Logger log = LoggerFactory.getLogger(LocationsCollectingTransformer.class); + private static final Logger log = LoggerFactory.getLogger(LocationsCollectingTransformer.class); - private static final int MAX_CACHED_JARS = 1024; - private final DependencyService dependencyService; - private final DDCache seenDomains = - DDCaches.newFixedSizeWeakKeyCache(MAX_CACHED_JARS); + private static final int MAX_CACHED_JARS = 1024; + private final DependencyService dependencyService; + private final DDCache seenDomains = DDCaches.newFixedSizeWeakKeyCache(MAX_CACHED_JARS); - public LocationsCollectingTransformer(DependencyService dependencyService) { - this.dependencyService = dependencyService; - } + public LocationsCollectingTransformer(DependencyService dependencyService) { + this.dependencyService = dependencyService; + } - @Override - public byte[] transform( - ClassLoader loader, - String className, - Class classBeingRedefined, - ProtectionDomain protectionDomain, - byte[] classfileBuffer) { - if (protectionDomain != null) { - seenDomains.computeIfAbsent(protectionDomain, this::addDependency); + @Override + public byte[] transform( + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer) { + if (protectionDomain != null) { + seenDomains.computeIfAbsent(protectionDomain, this::addDependency); + } + // returning 'null' is the best way to indicate that no transformation has been done. + return null; } - // returning 'null' is the best way to indicate that no transformation has been done. - return null; - } - private boolean addDependency(final ProtectionDomain domain) { - final CodeSource codeSource = domain.getCodeSource(); - final URL location = codeSource != null ? codeSource.getLocation() : null; - final ClassLoader classLoader = domain.getClassLoader(); - log.debug("New protection domain with location {} and class loader {}", location, classLoader); - if (location != null) { - dependencyService.addURL(location); + private boolean addDependency(final ProtectionDomain domain) { + final CodeSource codeSource = domain.getCodeSource(); + final URL location = codeSource != null ? codeSource.getLocation() : null; + final ClassLoader classLoader = domain.getClassLoader(); + log.debug("New protection domain with location {} and class loader {}", location, classLoader); + if (location != null) { + dependencyService.addURL(location); + } + return true; } - return true; - } } diff --git a/telemetry/src/main/java/datadog/telemetry/endpoint/EndpointPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/endpoint/EndpointPeriodicAction.java index f555dfefb40..acd340d9608 100644 --- a/telemetry/src/main/java/datadog/telemetry/endpoint/EndpointPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/endpoint/EndpointPeriodicAction.java @@ -8,50 +8,50 @@ public class EndpointPeriodicAction implements TelemetryRunnable.TelemetryPeriodicAction { - private final EndpointCollector collector; - - public EndpointPeriodicAction() { - this(EndpointCollector.get()); - } - - public EndpointPeriodicAction(EndpointCollector collector) { - this.collector = collector; - } - - @Override - public void doIteration(final TelemetryService service) { - for (final Iterator it = collector.drain(); it.hasNext(); ) { - final Endpoint endpoint = it.next(); - if (!service.addEndpoint(endpoint)) { - collector.supplier(new HeadAndTailIterator(endpoint, it)); // try again latter - break; - } - } - } + private final EndpointCollector collector; - private static final class HeadAndTailIterator implements Iterator { - private final Endpoint head; - private final Iterator tail; - private boolean first; + public EndpointPeriodicAction() { + this(EndpointCollector.get()); + } - private HeadAndTailIterator(final Endpoint head, final Iterator tail) { - this.head = head; - this.tail = tail; - first = true; + public EndpointPeriodicAction(EndpointCollector collector) { + this.collector = collector; } @Override - public boolean hasNext() { - return first || tail.hasNext(); + public void doIteration(final TelemetryService service) { + for (final Iterator it = collector.drain(); it.hasNext(); ) { + final Endpoint endpoint = it.next(); + if (!service.addEndpoint(endpoint)) { + collector.supplier(new HeadAndTailIterator(endpoint, it)); // try again latter + break; + } + } } - @Override - public Endpoint next() { - if (first) { - first = false; - return head; - } - return tail.next(); + private static final class HeadAndTailIterator implements Iterator { + private final Endpoint head; + private final Iterator tail; + private boolean first; + + private HeadAndTailIterator(final Endpoint head, final Iterator tail) { + this.head = head; + this.tail = tail; + first = true; + } + + @Override + public boolean hasNext() { + return first || tail.hasNext(); + } + + @Override + public Endpoint next() { + if (first) { + first = false; + return head; + } + return tail.next(); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/integration/IntegrationPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/integration/IntegrationPeriodicAction.java index 2e5b8b2b25f..4d630f8f8ce 100644 --- a/telemetry/src/main/java/datadog/telemetry/integration/IntegrationPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/integration/IntegrationPeriodicAction.java @@ -8,14 +8,14 @@ public class IntegrationPeriodicAction implements TelemetryRunnable.TelemetryPeriodicAction { - @Override - public void doIteration(TelemetryService service) { - Map integrations = IntegrationsCollector.get().drain(); + @Override + public void doIteration(TelemetryService service) { + Map integrations = IntegrationsCollector.get().drain(); - for (Map.Entry entry : integrations.entrySet()) { - String name = entry.getKey(); - Boolean enabled = entry.getValue(); - service.addIntegration(new Integration(name, Boolean.TRUE.equals(enabled))); + for (Map.Entry entry : integrations.entrySet()) { + String name = entry.getKey(); + Boolean enabled = entry.getValue(); + service.addIntegration(new Integration(name, Boolean.TRUE.equals(enabled))); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/log/LogPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/log/LogPeriodicAction.java index e949782b4ba..67ac846769e 100644 --- a/telemetry/src/main/java/datadog/telemetry/log/LogPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/log/LogPeriodicAction.java @@ -8,129 +8,120 @@ public class LogPeriodicAction implements TelemetryRunnable.TelemetryPeriodicAction { - /** - * The current list of packages passed in is small, but if it kept growing and this did become a - * performance issue then we could consider using ClassNameTrie instead (ie. use the builder to - * create the trie and store it as a constant in LogPeriodicAction to be passed in here and used - * as a filter) - */ - static final String[] PACKAGE_ALLOW_LIST = { - "datadog.", - "com.datadog.", - "java.", - "javax.", - "jakarta.", - "jdk.", - "sun.", - "com.sun.", - "com.datadog.ddwaf." - }; - - private static final String UNKNOWN = ""; - - @Override - public void doIteration(TelemetryService service) { - for (LogCollector.RawLogMessage rawLogMsg : LogCollector.get().drain()) { - - LogMessage logMessage = - new LogMessage() - .message(rawLogMsg.message) - .tracerTime(rawLogMsg.timestamp) - .tags(rawLogMsg.tags) - .count(rawLogMsg.count); - - if (rawLogMsg.logLevel != null) { - logMessage.level(LogMessageLevel.fromString(rawLogMsg.logLevel)); - } - - if (rawLogMsg.throwable != null) { - logMessage.stackTrace(renderStackTrace(rawLogMsg.throwable)); - } - - service.addLogMessage(logMessage); - } - } - - private static String renderStackTrace(Throwable t) { - StringBuilder result = new StringBuilder(); - - StackTraceElement[] previousStackTrace = null; - - while (t != null) { - String name = t.getClass().getCanonicalName(); - if (name == null || name.isEmpty()) { - result.append(UNKNOWN); - } else { - result.append(name); - } - result.append('\n'); - - final StackTraceElement[] stacktrace = t.getStackTrace(); - int pendingRedacted = 0; - if (stacktrace != null) { - int commonFrames = 0; - if (previousStackTrace != null) { - commonFrames = countCommonFrames(previousStackTrace, stacktrace); - } - int maxIndex = stacktrace.length - commonFrames; - - for (int i = 0; i < maxIndex; i++) { - final StackTraceElement frame = stacktrace[i]; - final String className = frame.getClassName(); - if (shouldRedactClass(className)) { - pendingRedacted++; - } else { - writePendingRedacted(result, pendingRedacted); - pendingRedacted = 0; - result.append(" at ").append(frame).append('\n'); - } + /** + * The current list of packages passed in is small, but if it kept growing and this did become a + * performance issue then we could consider using ClassNameTrie instead (ie. use the builder to + * create the trie and store it as a constant in LogPeriodicAction to be passed in here and used + * as a filter) + */ + static final String[] PACKAGE_ALLOW_LIST = { + "datadog.", "com.datadog.", "java.", "javax.", "jakarta.", "jdk.", "sun.", "com.sun.", "com.datadog.ddwaf." + }; + + private static final String UNKNOWN = ""; + + @Override + public void doIteration(TelemetryService service) { + for (LogCollector.RawLogMessage rawLogMsg : LogCollector.get().drain()) { + + LogMessage logMessage = new LogMessage() + .message(rawLogMsg.message) + .tracerTime(rawLogMsg.timestamp) + .tags(rawLogMsg.tags) + .count(rawLogMsg.count); + + if (rawLogMsg.logLevel != null) { + logMessage.level(LogMessageLevel.fromString(rawLogMsg.logLevel)); + } + + if (rawLogMsg.throwable != null) { + logMessage.stackTrace(renderStackTrace(rawLogMsg.throwable)); + } + + service.addLogMessage(logMessage); } - writePendingRedacted(result, pendingRedacted); + } - if (commonFrames > 0) { - result.append(" ... ").append(commonFrames).append(" more\n"); + private static String renderStackTrace(Throwable t) { + StringBuilder result = new StringBuilder(); + + StackTraceElement[] previousStackTrace = null; + + while (t != null) { + String name = t.getClass().getCanonicalName(); + if (name == null || name.isEmpty()) { + result.append(UNKNOWN); + } else { + result.append(name); + } + result.append('\n'); + + final StackTraceElement[] stacktrace = t.getStackTrace(); + int pendingRedacted = 0; + if (stacktrace != null) { + int commonFrames = 0; + if (previousStackTrace != null) { + commonFrames = countCommonFrames(previousStackTrace, stacktrace); + } + int maxIndex = stacktrace.length - commonFrames; + + for (int i = 0; i < maxIndex; i++) { + final StackTraceElement frame = stacktrace[i]; + final String className = frame.getClassName(); + if (shouldRedactClass(className)) { + pendingRedacted++; + } else { + writePendingRedacted(result, pendingRedacted); + pendingRedacted = 0; + result.append(" at ").append(frame).append('\n'); + } + } + writePendingRedacted(result, pendingRedacted); + + if (commonFrames > 0) { + result.append(" ... ").append(commonFrames).append(" more\n"); + } + } + + previousStackTrace = stacktrace; + t = t.getCause(); + if (t != null) { + result.append("Caused by: "); + } } - } - previousStackTrace = stacktrace; - t = t.getCause(); - if (t != null) { - result.append("Caused by: "); - } + return result.toString(); } - return result.toString(); - } - - private static int countCommonFrames( - StackTraceElement[] previousStackTrace, StackTraceElement[] currentStackTrace) { - int previousIndex = previousStackTrace.length - 1; - int currentIndex = currentStackTrace.length - 1; - int count = 0; - while (previousIndex >= 0 - && currentIndex >= 0 - && previousStackTrace[previousIndex].equals(currentStackTrace[currentIndex])) { - count++; - previousIndex--; - currentIndex--; + private static int countCommonFrames( + StackTraceElement[] previousStackTrace, StackTraceElement[] currentStackTrace) { + int previousIndex = previousStackTrace.length - 1; + int currentIndex = currentStackTrace.length - 1; + int count = 0; + while (previousIndex >= 0 + && currentIndex >= 0 + && previousStackTrace[previousIndex].equals(currentStackTrace[currentIndex])) { + count++; + previousIndex--; + currentIndex--; + } + return count; } - return count; - } - - private static boolean shouldRedactClass(final String className) { - for (final String prefix : PACKAGE_ALLOW_LIST) { - if (className.startsWith(prefix)) { - return false; - } + + private static boolean shouldRedactClass(final String className) { + for (final String prefix : PACKAGE_ALLOW_LIST) { + if (className.startsWith(prefix)) { + return false; + } + } + return true; } - return true; - } - - private static void writePendingRedacted(final StringBuilder result, final int pendingRedacted) { - if (pendingRedacted == 1) { - result.append(" at ").append("(redacted)\n"); - } else if (pendingRedacted > 1) { - result.append(" at (redacted: ").append(pendingRedacted).append(" frames)\n"); + + private static void writePendingRedacted(final StringBuilder result, final int pendingRedacted) { + if (pendingRedacted == 1) { + result.append(" at ").append("(redacted)\n"); + } else if (pendingRedacted > 1) { + result.append(" at (redacted: ").append(pendingRedacted).append(" frames)\n"); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/CiVisibilityMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/CiVisibilityMetricPeriodicAction.java index 15d40436a89..845ef61e076 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/CiVisibilityMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/CiVisibilityMetricPeriodicAction.java @@ -5,9 +5,9 @@ import javax.annotation.Nonnull; public class CiVisibilityMetricPeriodicAction extends MetricPeriodicAction { - @Nonnull - @Override - public MetricCollector collector() { - return InstrumentationBridge.getMetricCollector(); - } + @Nonnull + @Override + public MetricCollector collector() { + return InstrumentationBridge.getMetricCollector(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicAction.java index d115aa9abeb..9a58f68986f 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicAction.java @@ -5,9 +5,9 @@ import javax.annotation.Nonnull; public class ConfigInversionMetricPeriodicAction extends MetricPeriodicAction { - @Override - @Nonnull - public MetricCollector collector() { - return ConfigInversionMetricCollectorImpl.getInstance(); - } + @Override + @Nonnull + public MetricCollector collector() { + return ConfigInversionMetricCollectorImpl.getInstance(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/CoreMetricsPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/CoreMetricsPeriodicAction.java index f7475929ba7..9d306d9f03e 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/CoreMetricsPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/CoreMetricsPeriodicAction.java @@ -5,9 +5,9 @@ import javax.annotation.Nonnull; public class CoreMetricsPeriodicAction extends MetricPeriodicAction { - @Nonnull - @Override - public MetricCollector collector() { - return CoreMetricCollector.getInstance(); - } + @Nonnull + @Override + public MetricCollector collector() { + return CoreMetricCollector.getInstance(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/DebuggerMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/DebuggerMetricPeriodicAction.java index 00ddf9a8554..b883b06208f 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/DebuggerMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/DebuggerMetricPeriodicAction.java @@ -5,8 +5,8 @@ public class DebuggerMetricPeriodicAction extends MetricPeriodicAction { - @Override - public MetricCollector collector() { - return DebuggerMetricCollector.get(); - } + @Override + public MetricCollector collector() { + return DebuggerMetricCollector.get(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/IastMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/IastMetricPeriodicAction.java index a4f434f5b82..ca32d893317 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/IastMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/IastMetricPeriodicAction.java @@ -6,9 +6,9 @@ public class IastMetricPeriodicAction extends MetricPeriodicAction { - @Override - @Nonnull - public MetricCollector collector() { - return IastMetricCollector.get(); - } + @Override + @Nonnull + public MetricCollector collector() { + return IastMetricCollector.get(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/LLMObsMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/LLMObsMetricPeriodicAction.java index 91e3cb69eec..0150ba3e5c9 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/LLMObsMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/LLMObsMetricPeriodicAction.java @@ -5,9 +5,9 @@ import javax.annotation.Nonnull; public class LLMObsMetricPeriodicAction extends MetricPeriodicAction { - @Nonnull - @Override - public MetricCollector collector() { - return LLMObsMetricCollector.get(); - } + @Nonnull + @Override + public MetricCollector collector() { + return LLMObsMetricCollector.get(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/MetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/MetricPeriodicAction.java index 579eb6c761d..c9776d0364b 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/MetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/MetricPeriodicAction.java @@ -12,71 +12,68 @@ import javax.annotation.Nonnull; public abstract class MetricPeriodicAction implements TelemetryRunnable.TelemetryPeriodicAction { - @Override - public final void doIteration(final TelemetryService service) { - final Collection rawMetrics = collector().drain(); - // Convert raw metrics to telemetry metrics and join points of common metrics - toTelemetryMetrics(rawMetrics).forEach(service::addMetric); + @Override + public final void doIteration(final TelemetryService service) { + final Collection rawMetrics = collector().drain(); + // Convert raw metrics to telemetry metrics and join points of common metrics + toTelemetryMetrics(rawMetrics).forEach(service::addMetric); - Collection rawDistributionSeriesPoints = - collector().drainDistributionSeries(); - toDistributionSeries(rawDistributionSeriesPoints).forEach(service::addDistributionSeries); - } + Collection rawDistributionSeriesPoints = + collector().drainDistributionSeries(); + toDistributionSeries(rawDistributionSeriesPoints).forEach(service::addDistributionSeries); + } - @Nonnull - public abstract MetricCollector collector(); + @Nonnull + public abstract MetricCollector collector(); - private Collection toTelemetryMetrics(final Collection metrics) { - // Use an intermediate map to quickly find telemetry metric related to a metric - Map telemetryMetrics = new HashMap<>(); - // Aggregate equal metrics into a single telemetry metric with multiple points - for (MetricCollector.Metric metric : metrics) { - Metric telemetryMetric = - telemetryMetrics.computeIfAbsent(metric, this::convertToTelemetryMetric); - ArrayList point = new ArrayList<>(2); - point.add(metric.timestamp); - point.add(metric.value); - telemetryMetric.addPointsItem(point); + private Collection toTelemetryMetrics(final Collection metrics) { + // Use an intermediate map to quickly find telemetry metric related to a metric + Map telemetryMetrics = new HashMap<>(); + // Aggregate equal metrics into a single telemetry metric with multiple points + for (MetricCollector.Metric metric : metrics) { + Metric telemetryMetric = telemetryMetrics.computeIfAbsent(metric, this::convertToTelemetryMetric); + ArrayList point = new ArrayList<>(2); + point.add(metric.timestamp); + point.add(metric.value); + telemetryMetric.addPointsItem(point); + } + return telemetryMetrics.values(); } - return telemetryMetrics.values(); - } - private Metric convertToTelemetryMetric(MetricCollector.Metric raw) { - return new Metric() - .namespace(raw.namespace) - .metric(raw.metricName) - .type(typeFromValue(raw.type)) - .common(raw.common) - .tags(raw.tags); - } + private Metric convertToTelemetryMetric(MetricCollector.Metric raw) { + return new Metric() + .namespace(raw.namespace) + .metric(raw.metricName) + .type(typeFromValue(raw.type)) + .common(raw.common) + .tags(raw.tags); + } - private static Metric.TypeEnum typeFromValue(String value) { - for (Metric.TypeEnum typeEnum : Metric.TypeEnum.values()) { - if (typeEnum.value().equals(value)) { - return typeEnum; - } + private static Metric.TypeEnum typeFromValue(String value) { + for (Metric.TypeEnum typeEnum : Metric.TypeEnum.values()) { + if (typeEnum.value().equals(value)) { + return typeEnum; + } + } + throw new IllegalArgumentException("Invalid metric type value: " + value); } - throw new IllegalArgumentException("Invalid metric type value: " + value); - } - private Collection toDistributionSeries( - Collection rawDistributionSeriesPoints) { - Map distributionSeries = - new HashMap<>(); - for (MetricCollector.DistributionSeriesPoint point : rawDistributionSeriesPoints) { - distributionSeries - .computeIfAbsent(point, this::convertToDistributionSeries) - .addPoint(point.value); + private Collection toDistributionSeries( + Collection rawDistributionSeriesPoints) { + Map distributionSeries = new HashMap<>(); + for (MetricCollector.DistributionSeriesPoint point : rawDistributionSeriesPoints) { + distributionSeries + .computeIfAbsent(point, this::convertToDistributionSeries) + .addPoint(point.value); + } + return distributionSeries.values(); } - return distributionSeries.values(); - } - private DistributionSeries convertToDistributionSeries( - MetricCollector.DistributionSeriesPoint point) { - return new DistributionSeries() - .namespace(point.namespace) - .metric(point.metricName) - .common(point.common) - .tags(point.tags); - } + private DistributionSeries convertToDistributionSeries(MetricCollector.DistributionSeriesPoint point) { + return new DistributionSeries() + .namespace(point.namespace) + .metric(point.metricName) + .common(point.common) + .tags(point.tags); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/OtelEnvMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/OtelEnvMetricPeriodicAction.java index 2729d48ff2d..cef60fd7444 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/OtelEnvMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/OtelEnvMetricPeriodicAction.java @@ -5,9 +5,9 @@ import javax.annotation.Nonnull; public class OtelEnvMetricPeriodicAction extends MetricPeriodicAction { - @Override - @Nonnull - public MetricCollector collector() { - return OtelEnvMetricCollectorImpl.getInstance(); - } + @Override + @Nonnull + public MetricCollector collector() { + return OtelEnvMetricCollectorImpl.getInstance(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/OtelSpiMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/OtelSpiMetricPeriodicAction.java index 6016f71d195..180291ae4c2 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/OtelSpiMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/OtelSpiMetricPeriodicAction.java @@ -5,9 +5,9 @@ import javax.annotation.Nonnull; public class OtelSpiMetricPeriodicAction extends MetricPeriodicAction { - @Override - @Nonnull - public MetricCollector collector() { - return OtelSpiCollector.getInstance(); - } + @Override + @Nonnull + public MetricCollector collector() { + return OtelSpiCollector.getInstance(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/OtlpTelemetryPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/OtlpTelemetryPeriodicAction.java index 0464a353541..c84f0c703bb 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/OtlpTelemetryPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/OtlpTelemetryPeriodicAction.java @@ -6,9 +6,9 @@ public class OtlpTelemetryPeriodicAction extends MetricPeriodicAction { - @Override - @Nonnull - public MetricCollector collector() { - return OtlpTelemetry.getInstance(); - } + @Override + @Nonnull + public MetricCollector collector() { + return OtlpTelemetry.getInstance(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/metric/WafMetricPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/metric/WafMetricPeriodicAction.java index 90dcedc9f97..d9da44920a3 100644 --- a/telemetry/src/main/java/datadog/telemetry/metric/WafMetricPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/metric/WafMetricPeriodicAction.java @@ -6,9 +6,9 @@ public class WafMetricPeriodicAction extends MetricPeriodicAction { - @Override - @Nonnull - public MetricCollector collector() { - return WafMetricCollector.get(); - } + @Override + @Nonnull + public MetricCollector collector() { + return WafMetricCollector.get(); + } } diff --git a/telemetry/src/main/java/datadog/telemetry/products/ProductChangeAction.java b/telemetry/src/main/java/datadog/telemetry/products/ProductChangeAction.java index 534044539e4..80910383a7a 100644 --- a/telemetry/src/main/java/datadog/telemetry/products/ProductChangeAction.java +++ b/telemetry/src/main/java/datadog/telemetry/products/ProductChangeAction.java @@ -8,11 +8,11 @@ public class ProductChangeAction implements TelemetryRunnable.TelemetryPeriodicAction { - @Override - public void doIteration(TelemetryService service) { - List productChanges = ProductChangeCollector.get().drain(); - for (ProductChange productChange : productChanges) { - service.addProductChange(productChange); + @Override + public void doIteration(TelemetryService service) { + List productChanges = ProductChangeCollector.get().drain(); + for (ProductChange productChange : productChanges) { + service.addProductChange(productChange); + } } - } } diff --git a/telemetry/src/main/java/datadog/telemetry/rum/RumPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/rum/RumPeriodicAction.java index afcd8872933..f4fea0db5c7 100644 --- a/telemetry/src/main/java/datadog/telemetry/rum/RumPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/rum/RumPeriodicAction.java @@ -12,47 +12,45 @@ /** RUM version of IntegrationPeriodicAction that sends RUM telemetry metrics. */ public class RumPeriodicAction implements TelemetryRunnable.TelemetryPeriodicAction { - private final RumTelemetryCollector telemetryCollector; - - public RumPeriodicAction(RumTelemetryCollector telemetryCollector) { - this.telemetryCollector = telemetryCollector; - } - - @Override - public void doIteration(TelemetryService service) { - Collection counts = telemetryCollector.drain(); - for (MetricCollector.Metric metric : counts) { - Metric telemetryMetric = convertToTelemetryMetric(metric); - service.addMetric(telemetryMetric); + private final RumTelemetryCollector telemetryCollector; + + public RumPeriodicAction(RumTelemetryCollector telemetryCollector) { + this.telemetryCollector = telemetryCollector; + } + + @Override + public void doIteration(TelemetryService service) { + Collection counts = telemetryCollector.drain(); + for (MetricCollector.Metric metric : counts) { + Metric telemetryMetric = convertToTelemetryMetric(metric); + service.addMetric(telemetryMetric); + } + + Collection distributions = + telemetryCollector.drainDistributionSeries(); + for (MetricCollector.DistributionSeriesPoint distribution : distributions) { + DistributionSeries telemetryDistribution = convertToDistributionSeries(distribution); + service.addDistributionSeries(telemetryDistribution); + } + } + + private Metric convertToTelemetryMetric(MetricCollector.Metric raw) { + return new Metric() + .namespace(raw.namespace) + .metric(raw.metricName) + .type(Metric.TypeEnum.COUNT) + .common(raw.common) + .tags(raw.tags) + .addPointsItem(Arrays.asList(raw.timestamp, raw.value)); } - Collection distributions = - telemetryCollector.drainDistributionSeries(); - for (MetricCollector.DistributionSeriesPoint distribution : distributions) { - DistributionSeries telemetryDistribution = convertToDistributionSeries(distribution); - service.addDistributionSeries(telemetryDistribution); + private DistributionSeries convertToDistributionSeries(MetricCollector.DistributionSeriesPoint point) { + DistributionSeries distribution = new DistributionSeries() + .namespace(point.namespace) + .metric(point.metricName) + .common(point.common) + .tags(point.tags); + distribution.addPoint(point.value); + return distribution; } - } - - private Metric convertToTelemetryMetric(MetricCollector.Metric raw) { - return new Metric() - .namespace(raw.namespace) - .metric(raw.metricName) - .type(Metric.TypeEnum.COUNT) - .common(raw.common) - .tags(raw.tags) - .addPointsItem(Arrays.asList(raw.timestamp, raw.value)); - } - - private DistributionSeries convertToDistributionSeries( - MetricCollector.DistributionSeriesPoint point) { - DistributionSeries distribution = - new DistributionSeries() - .namespace(point.namespace) - .metric(point.metricName) - .common(point.common) - .tags(point.tags); - distribution.addPoint(point.value); - return distribution; - } } diff --git a/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java b/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java index b1dd6b416a9..ae2f6220059 100644 --- a/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java +++ b/telemetry/src/main/java/datadog/telemetry/sca/ScaReachabilityPeriodicAction.java @@ -50,192 +50,186 @@ * it pending again. This prevents emitting {@code metadata:[]} and overwriting the CVE state the * backend already received. */ -public final class ScaReachabilityPeriodicAction - implements TelemetryRunnable.TelemetryPeriodicAction { +public final class ScaReachabilityPeriodicAction implements TelemetryRunnable.TelemetryPeriodicAction { - private final DependencyService dependencyService; + private final DependencyService dependencyService; - /** - * Persistent across heartbeats: accumulates every dep resolved by {@link DependencyService}. - * Keyed by {@link ScaReachabilityDependencyRegistry#depKey(String, String)}. - * - *

This cache allows Step 3 to enrich CVE snapshots with {@code source} and {@code hash} even - * when the dep was drained from {@link DependencyService} in an earlier heartbeat than the CVE - * hit. - */ - private final Map knownDeps = new HashMap<>(); + /** + * Persistent across heartbeats: accumulates every dep resolved by {@link DependencyService}. + * Keyed by {@link ScaReachabilityDependencyRegistry#depKey(String, String)}. + * + *

This cache allows Step 3 to enrich CVE snapshots with {@code source} and {@code hash} even + * when the dep was drained from {@link DependencyService} in an earlier heartbeat than the CVE + * hit. + */ + private final Map knownDeps = new HashMap<>(); - public ScaReachabilityPeriodicAction(DependencyService dependencyService) { - this.dependencyService = dependencyService; - } - - /** - * Pre-populates {@link #knownDeps} with a dep entry. Used in tests to simulate a dep that was - * resolved by {@link DependencyService} in a prior heartbeat without running a full iteration. - */ - @VisibleForTesting - void addKnownDepForTesting(String name, String version) { - knownDeps.put( - ScaReachabilityDependencyRegistry.depKey(name, version), - new Dependency(name, version, null, null)); - } - - @Override - public void doIteration(TelemetryService telService) { - // Trigger pending retransformations (method-level symbols on already-loaded classes, or - // classes where JAR version resolution failed at load time and needs a retry). - Runnable work = ScaReachabilityDependencyRegistry.INSTANCE.getPeriodicWorkCallback(); - if (work != null) { - work.run(); + public ScaReachabilityPeriodicAction(DependencyService dependencyService) { + this.dependencyService = dependencyService; } - // Step 1: drain registry → map keyed by "artifact@version" for O(1) lookup below. - List pending = - ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); - Map snapshotByKey = new HashMap<>(pending.size() * 2); - for (DependencySnapshot snapshot : pending) { - snapshotByKey.put( - ScaReachabilityDependencyRegistry.depKey(snapshot.artifact, snapshot.version), snapshot); + /** + * Pre-populates {@link #knownDeps} with a dep entry. Used in tests to simulate a dep that was + * resolved by {@link DependencyService} in a prior heartbeat without running a full iteration. + */ + @VisibleForTesting + void addKnownDepForTesting(String name, String version) { + knownDeps.put( + ScaReachabilityDependencyRegistry.depKey(name, version), new Dependency(name, version, null, null)); } - // Step 2: drain DependencyService (newly detected JARs this heartbeat). - // Store each dep in knownDeps regardless of whether it has a CVE match — future heartbeats - // with CVE hits will look it up in Step 3. - if (dependencyService != null) { - for (Dependency dep : dependencyService.drainDeterminedDependencies()) { - String key = ScaReachabilityDependencyRegistry.depKey(dep.name, dep.version); - Dependency previous = knownDeps.put(key, dep); - DependencySnapshot snapshot = snapshotByKey.remove(key); - if (snapshot == null && isNewPhysicalCopy(previous, dep)) { - // First detection of this physical JAR: peek CVE state so we can enrich the emission - // instead of overwriting the backend's state with metadata:[]. Skip when the same - // physical copy is re-detected (same source/hash from a different classloader) — - // peekSnapshot would re-emit a stale hit already reported. - snapshot = ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot(dep.name, dep.version); + @Override + public void doIteration(TelemetryService telService) { + // Trigger pending retransformations (method-level symbols on already-loaded classes, or + // classes where JAR version resolution failed at load time and needs a retry). + Runnable work = ScaReachabilityDependencyRegistry.INSTANCE.getPeriodicWorkCallback(); + if (work != null) { + work.run(); } - if (snapshot != null) { - telService.addDependency( - new Dependency(dep.name, dep.version, dep.source, dep.hash, buildMetadata(snapshot))); - } else if (isNewPhysicalCopy(previous, dep)) { - // First detection of this physical copy, no CVE state yet — metadata:[] signals "SCA is - // monitoring this dep". - telService.addDependency( - new Dependency(dep.name, dep.version, dep.source, dep.hash, Collections.emptyList())); + + // Step 1: drain registry → map keyed by "artifact@version" for O(1) lookup below. + List pending = ScaReachabilityDependencyRegistry.INSTANCE.drainPendingDependencies(); + Map snapshotByKey = new HashMap<>(pending.size() * 2); + for (DependencySnapshot snapshot : pending) { + snapshotByKey.put(ScaReachabilityDependencyRegistry.depKey(snapshot.artifact, snapshot.version), snapshot); } - // Re-detection of the same physical copy (same source/hash) with no state change: nothing - // to emit. The backend already received this dep. - } - } - // Step 3: handle CVE state changes for deps not in DependencyService this heartbeat. - // Always emit — never block CVE data. Use knownDeps for source/hash enrichment when the JAR - // was resolved in a prior heartbeat; otherwise emit without source/hash so the backend still - // receives the CVE state (it may correlate by name:version alone, and subsequent emissions - // for the same dep — triggered by method hits — will carry source/hash once knownDeps is - // populated). - for (DependencySnapshot snapshot : snapshotByKey.values()) { - String key = ScaReachabilityDependencyRegistry.depKey(snapshot.artifact, snapshot.version); - Dependency known = knownDeps.get(key); - if (known != null) { - // Dep was resolved in a prior heartbeat — emit enriched with source/hash. - telService.addDependency( - new Dependency( - known.name, known.version, known.source, known.hash, buildMetadata(snapshot))); - } else { - // Dep not yet resolved — emit without source/hash so CVE data is not delayed. - // When the dep is eventually resolved (stored in knownDeps via Step 2), subsequent - // CVE emissions (e.g., after a method hit) will include source/hash automatically. - telService.addDependency( - new Dependency( - snapshot.artifact, snapshot.version, null, null, buildMetadata(snapshot))); - } - } - } + // Step 2: drain DependencyService (newly detected JARs this heartbeat). + // Store each dep in knownDeps regardless of whether it has a CVE match — future heartbeats + // with CVE hits will look it up in Step 3. + if (dependencyService != null) { + for (Dependency dep : dependencyService.drainDeterminedDependencies()) { + String key = ScaReachabilityDependencyRegistry.depKey(dep.name, dep.version); + Dependency previous = knownDeps.put(key, dep); + DependencySnapshot snapshot = snapshotByKey.remove(key); + if (snapshot == null && isNewPhysicalCopy(previous, dep)) { + // First detection of this physical JAR: peek CVE state so we can enrich the emission + // instead of overwriting the backend's state with metadata:[]. Skip when the same + // physical copy is re-detected (same source/hash from a different classloader) — + // peekSnapshot would re-emit a stale hit already reported. + snapshot = ScaReachabilityDependencyRegistry.INSTANCE.peekSnapshot(dep.name, dep.version); + } + if (snapshot != null) { + telService.addDependency( + new Dependency(dep.name, dep.version, dep.source, dep.hash, buildMetadata(snapshot))); + } else if (isNewPhysicalCopy(previous, dep)) { + // First detection of this physical copy, no CVE state yet — metadata:[] signals "SCA is + // monitoring this dep". + telService.addDependency( + new Dependency(dep.name, dep.version, dep.source, dep.hash, Collections.emptyList())); + } + // Re-detection of the same physical copy (same source/hash) with no state change: nothing + // to emit. The backend already received this dep. + } + } - /** - * Decides whether {@code dep} represents a new physical copy of an artifact that the backend has - * not yet seen, as opposed to a re-detection of a JAR already reported. - * - *

A "new physical copy" is a distinct file on disk. Two copies share the same {@code - * name@version} key but differ in {@code source} (the path/URI the JAR was loaded from) and/or - * {@code hash} (the content digest). A "re-detection" is the exact same physical file surfacing - * again, carrying an identical {@code source} and {@code hash}. - * - *

Returns {@code true} when: - * - *

    - *
  • {@code previous == null} — first time we see this {@code name@version} key, or - *
  • {@code dep.source} differs from {@code previous.source}, or - *
  • {@code dep.hash} differs from {@code previous.hash}. - *
- * - *

{@link Objects#equals} is used for both comparisons because {@code source} and {@code hash} - * may legitimately be {@code null} (e.g. JARs whose origin or digest could not be resolved), and - * a plain {@code ==}/{@code .equals} would either NPE or misclassify two null values. - * - *

Case this protects (must emit both): a Tomcat container running two webapps that each ship - * their own physical copy of the same artifact under different {@code WEB-INF/lib} paths. The - * copies have different {@code source}/{@code hash}, so both are reported — matching the behavior - * of the legacy {@link datadog.telemetry.dependency.DependencyPeriodicAction}. - * - *

Case this suppresses (re-detection, do not re-emit): a Spring Boot fat JAR where {@code - * LaunchedURLClassLoader} reports the same nested JAR from the same URI on multiple heartbeats. - * The {@code source}/{@code hash} are identical, so the dep is emitted only once. - */ - private static boolean isNewPhysicalCopy(Dependency previous, Dependency dep) { - if (previous == null) { - return true; + // Step 3: handle CVE state changes for deps not in DependencyService this heartbeat. + // Always emit — never block CVE data. Use knownDeps for source/hash enrichment when the JAR + // was resolved in a prior heartbeat; otherwise emit without source/hash so the backend still + // receives the CVE state (it may correlate by name:version alone, and subsequent emissions + // for the same dep — triggered by method hits — will carry source/hash once knownDeps is + // populated). + for (DependencySnapshot snapshot : snapshotByKey.values()) { + String key = ScaReachabilityDependencyRegistry.depKey(snapshot.artifact, snapshot.version); + Dependency known = knownDeps.get(key); + if (known != null) { + // Dep was resolved in a prior heartbeat — emit enriched with source/hash. + telService.addDependency( + new Dependency(known.name, known.version, known.source, known.hash, buildMetadata(snapshot))); + } else { + // Dep not yet resolved — emit without source/hash so CVE data is not delayed. + // When the dep is eventually resolved (stored in knownDeps via Step 2), subsequent + // CVE emissions (e.g., after a method hit) will include source/hash automatically. + telService.addDependency( + new Dependency(snapshot.artifact, snapshot.version, null, null, buildMetadata(snapshot))); + } + } } - if (!Objects.equals(dep.source, previous.source)) { - return true; + + /** + * Decides whether {@code dep} represents a new physical copy of an artifact that the backend has + * not yet seen, as opposed to a re-detection of a JAR already reported. + * + *

A "new physical copy" is a distinct file on disk. Two copies share the same {@code + * name@version} key but differ in {@code source} (the path/URI the JAR was loaded from) and/or + * {@code hash} (the content digest). A "re-detection" is the exact same physical file surfacing + * again, carrying an identical {@code source} and {@code hash}. + * + *

Returns {@code true} when: + * + *

    + *
  • {@code previous == null} — first time we see this {@code name@version} key, or + *
  • {@code dep.source} differs from {@code previous.source}, or + *
  • {@code dep.hash} differs from {@code previous.hash}. + *
+ * + *

{@link Objects#equals} is used for both comparisons because {@code source} and {@code hash} + * may legitimately be {@code null} (e.g. JARs whose origin or digest could not be resolved), and + * a plain {@code ==}/{@code .equals} would either NPE or misclassify two null values. + * + *

Case this protects (must emit both): a Tomcat container running two webapps that each ship + * their own physical copy of the same artifact under different {@code WEB-INF/lib} paths. The + * copies have different {@code source}/{@code hash}, so both are reported — matching the behavior + * of the legacy {@link datadog.telemetry.dependency.DependencyPeriodicAction}. + * + *

Case this suppresses (re-detection, do not re-emit): a Spring Boot fat JAR where {@code + * LaunchedURLClassLoader} reports the same nested JAR from the same URI on multiple heartbeats. + * The {@code source}/{@code hash} are identical, so the dep is emitted only once. + */ + private static boolean isNewPhysicalCopy(Dependency previous, Dependency dep) { + if (previous == null) { + return true; + } + if (!Objects.equals(dep.source, previous.source)) { + return true; + } + return !Objects.equals(dep.hash, previous.hash); } - return !Objects.equals(dep.hash, previous.hash); - } - private static List buildMetadata(DependencySnapshot snapshot) { - List metadataValues = new ArrayList<>(snapshot.cves.size()); - for (CveSnapshot cve : snapshot.cves) { - metadataValues.add(buildMetadataValue(cve)); + private static List buildMetadata(DependencySnapshot snapshot) { + List metadataValues = new ArrayList<>(snapshot.cves.size()); + for (CveSnapshot cve : snapshot.cves) { + metadataValues.add(buildMetadataValue(cve)); + } + return metadataValues; } - return metadataValues; - } - /** - * Builds the stringified JSON value for one CVE snapshot, per RFC: - * - *

    - *
  • Not yet reached: {@code {"id":"GHSA-xxx","reached":[]}} - *
  • Reached: {@code - * {"id":"GHSA-xxx","reached":[{"path":"com.foo.Bar","symbol":"...","line":N}]}} - *
- * - *

Values are JSON-escaped via {@link #jsonEscape} even though GHSA IDs, JVM class names, and - * method names are structurally guaranteed not to contain {@code "} or {@code \} by the JVM spec. - * The escaping is a safety net against future changes to the value sources. - */ - static String buildMetadataValue(CveSnapshot cve) { - ScaReachabilityHit hit = cve.hit; - if (hit == null) { - // CVE known but no callsite yet - signals "monitoring, not reached" - return "{\"id\":\"" + jsonEscape(cve.vulnId) + "\",\"reached\":[]}"; + /** + * Builds the stringified JSON value for one CVE snapshot, per RFC: + * + *

    + *
  • Not yet reached: {@code {"id":"GHSA-xxx","reached":[]}} + *
  • Reached: {@code + * {"id":"GHSA-xxx","reached":[{"path":"com.foo.Bar","symbol":"...","line":N}]}} + *
+ * + *

Values are JSON-escaped via {@link #jsonEscape} even though GHSA IDs, JVM class names, and + * method names are structurally guaranteed not to contain {@code "} or {@code \} by the JVM spec. + * The escaping is a safety net against future changes to the value sources. + */ + static String buildMetadataValue(CveSnapshot cve) { + ScaReachabilityHit hit = cve.hit; + if (hit == null) { + // CVE known but no callsite yet - signals "monitoring, not reached" + return "{\"id\":\"" + jsonEscape(cve.vulnId) + "\",\"reached\":[]}"; + } + // CVE has been reached - include the callsite + return "{\"id\":\"" + + jsonEscape(hit.vulnId()) + + "\",\"reached\":[{\"path\":\"" + + jsonEscape(hit.className()) + + "\",\"symbol\":\"" + + jsonEscape(hit.symbolName()) + + "\",\"line\":" + + hit.line() + + "}]}"; } - // CVE has been reached - include the callsite - return "{\"id\":\"" - + jsonEscape(hit.vulnId()) - + "\",\"reached\":[{\"path\":\"" - + jsonEscape(hit.className()) - + "\",\"symbol\":\"" - + jsonEscape(hit.symbolName()) - + "\",\"line\":" - + hit.line() - + "}]}"; - } - /** Escapes a string for embedding in a JSON string literal. */ - private static String jsonEscape(String value) { - if (value.indexOf('"') == -1 && value.indexOf('\\') == -1) { - return value; // fast path: no escaping needed (the common case) + /** Escapes a string for embedding in a JSON string literal. */ + private static String jsonEscape(String value) { + if (value.indexOf('"') == -1 && value.indexOf('\\') == -1) { + return value; // fast path: no escaping needed (the common case) + } + return value.replace("\\", "\\\\").replace("\"", "\\\""); } - return value.replace("\\", "\\\\").replace("\"", "\\\""); - } } diff --git a/telemetry/src/test/java/datadog/telemetry/BufferedEventsTest.java b/telemetry/src/test/java/datadog/telemetry/BufferedEventsTest.java index a85206287f5..73c0155af53 100644 --- a/telemetry/src/test/java/datadog/telemetry/BufferedEventsTest.java +++ b/telemetry/src/test/java/datadog/telemetry/BufferedEventsTest.java @@ -16,112 +16,112 @@ class BufferedEventsTest { - @Test - void emptyEvents() { - BufferedEvents events = new BufferedEvents(); - - assertTrue(events.isEmpty()); - assertFalse(events.hasConfigChangeEvent()); - assertFalse(events.hasDependencyEvent()); - assertFalse(events.hasDistributionSeriesEvent()); - assertFalse(events.hasIntegrationEvent()); - assertFalse(events.hasLogMessageEvent()); - assertFalse(events.hasMetricEvent()); - assertFalse(events.hasEndpoint()); - } - - @Test - void returnAddedEvents() { - BufferedEvents events = new BufferedEvents(); - ConfigSetting configSetting = ConfigSetting.of("key", "value", ConfigOrigin.DEFAULT); - Dependency dependency = new Dependency("name", "version", "source", "hash"); - DistributionSeries series = new DistributionSeries(); - Integration integration = new Integration("integration-name", true); - LogMessage logMessage = new LogMessage(); - Metric metric = new Metric(); - Endpoint endpoint = new Endpoint(); - - // when - events.addConfigChangeEvent(configSetting); - - // then - assertFalse(events.isEmpty()); - assertTrue(events.hasConfigChangeEvent()); - assertEquals(configSetting, events.nextConfigChangeEvent()); - assertFalse(events.hasConfigChangeEvent()); - assertTrue(events.isEmpty()); - - // when - events.addDependencyEvent(dependency); - - // then - assertFalse(events.isEmpty()); - assertTrue(events.hasDependencyEvent()); - assertEquals(dependency, events.nextDependencyEvent()); - assertFalse(events.hasDependencyEvent()); - assertTrue(events.isEmpty()); - - // when - events.addDistributionSeriesEvent(series); - - // then - assertFalse(events.isEmpty()); - assertTrue(events.hasDistributionSeriesEvent()); - assertEquals(series, events.nextDistributionSeriesEvent()); - assertFalse(events.hasDistributionSeriesEvent()); - assertTrue(events.isEmpty()); - - // when - events.addIntegrationEvent(integration); - - // then - assertFalse(events.isEmpty()); - assertTrue(events.hasIntegrationEvent()); - assertEquals(integration, events.nextIntegrationEvent()); - assertFalse(events.hasIntegrationEvent()); - assertTrue(events.isEmpty()); - - // when - events.addLogMessageEvent(logMessage); - - // then - assertFalse(events.isEmpty()); - assertTrue(events.hasLogMessageEvent()); - assertEquals(logMessage, events.nextLogMessageEvent()); - assertFalse(events.hasLogMessageEvent()); - assertTrue(events.isEmpty()); - - // when - events.addMetricEvent(metric); - - // then - assertFalse(events.isEmpty()); - assertTrue(events.hasMetricEvent()); - assertEquals(metric, events.nextMetricEvent()); - assertFalse(events.hasMetricEvent()); - assertTrue(events.isEmpty()); - - // when - events.addEndpointEvent(endpoint); - - // then - assertFalse(events.isEmpty()); - assertTrue(events.hasEndpoint()); - assertEquals(endpoint, events.nextEndpoint()); - assertFalse(events.hasEndpoint()); - assertTrue(events.isEmpty()); - } - - @Test - void noopSink() { - EventSink sink = EventSink.NOOP; - - sink.addMetricEvent(null); - sink.addLogMessageEvent(null); - sink.addIntegrationEvent(null); - sink.addDistributionSeriesEvent(null); - sink.addDependencyEvent(null); - sink.addConfigChangeEvent(null); - sink.addEndpointEvent(null); - } + @Test + void emptyEvents() { + BufferedEvents events = new BufferedEvents(); + + assertTrue(events.isEmpty()); + assertFalse(events.hasConfigChangeEvent()); + assertFalse(events.hasDependencyEvent()); + assertFalse(events.hasDistributionSeriesEvent()); + assertFalse(events.hasIntegrationEvent()); + assertFalse(events.hasLogMessageEvent()); + assertFalse(events.hasMetricEvent()); + assertFalse(events.hasEndpoint()); + } + + @Test + void returnAddedEvents() { + BufferedEvents events = new BufferedEvents(); + ConfigSetting configSetting = ConfigSetting.of("key", "value", ConfigOrigin.DEFAULT); + Dependency dependency = new Dependency("name", "version", "source", "hash"); + DistributionSeries series = new DistributionSeries(); + Integration integration = new Integration("integration-name", true); + LogMessage logMessage = new LogMessage(); + Metric metric = new Metric(); + Endpoint endpoint = new Endpoint(); + + // when + events.addConfigChangeEvent(configSetting); + + // then + assertFalse(events.isEmpty()); + assertTrue(events.hasConfigChangeEvent()); + assertEquals(configSetting, events.nextConfigChangeEvent()); + assertFalse(events.hasConfigChangeEvent()); + assertTrue(events.isEmpty()); + + // when + events.addDependencyEvent(dependency); + + // then + assertFalse(events.isEmpty()); + assertTrue(events.hasDependencyEvent()); + assertEquals(dependency, events.nextDependencyEvent()); + assertFalse(events.hasDependencyEvent()); + assertTrue(events.isEmpty()); + + // when + events.addDistributionSeriesEvent(series); + + // then + assertFalse(events.isEmpty()); + assertTrue(events.hasDistributionSeriesEvent()); + assertEquals(series, events.nextDistributionSeriesEvent()); + assertFalse(events.hasDistributionSeriesEvent()); + assertTrue(events.isEmpty()); + + // when + events.addIntegrationEvent(integration); + + // then + assertFalse(events.isEmpty()); + assertTrue(events.hasIntegrationEvent()); + assertEquals(integration, events.nextIntegrationEvent()); + assertFalse(events.hasIntegrationEvent()); + assertTrue(events.isEmpty()); + + // when + events.addLogMessageEvent(logMessage); + + // then + assertFalse(events.isEmpty()); + assertTrue(events.hasLogMessageEvent()); + assertEquals(logMessage, events.nextLogMessageEvent()); + assertFalse(events.hasLogMessageEvent()); + assertTrue(events.isEmpty()); + + // when + events.addMetricEvent(metric); + + // then + assertFalse(events.isEmpty()); + assertTrue(events.hasMetricEvent()); + assertEquals(metric, events.nextMetricEvent()); + assertFalse(events.hasMetricEvent()); + assertTrue(events.isEmpty()); + + // when + events.addEndpointEvent(endpoint); + + // then + assertFalse(events.isEmpty()); + assertTrue(events.hasEndpoint()); + assertEquals(endpoint, events.nextEndpoint()); + assertFalse(events.hasEndpoint()); + assertTrue(events.isEmpty()); + } + + @Test + void noopSink() { + EventSink sink = EventSink.NOOP; + + sink.addMetricEvent(null); + sink.addLogMessageEvent(null); + sink.addIntegrationEvent(null); + sink.addDistributionSeriesEvent(null); + sink.addDependencyEvent(null); + sink.addConfigChangeEvent(null); + sink.addEndpointEvent(null); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/DisabledDependencyServiceTest.java b/telemetry/src/test/java/datadog/telemetry/DisabledDependencyServiceTest.java index b019bba1738..b0187f92ffd 100644 --- a/telemetry/src/test/java/datadog/telemetry/DisabledDependencyServiceTest.java +++ b/telemetry/src/test/java/datadog/telemetry/DisabledDependencyServiceTest.java @@ -16,13 +16,13 @@ @WithConfig(key = TELEMETRY_DEPENDENCY_COLLECTION_ENABLED, value = "false") class DisabledDependencyServiceTest { - @Test - void installsDisabledDependencyServiceAndVerifyTransformer() { - Instrumentation instrumentation = mock(Instrumentation.class); + @Test + void installsDisabledDependencyServiceAndVerifyTransformer() { + Instrumentation instrumentation = mock(Instrumentation.class); - DependencyService dependencyService = TelemetrySystem.createDependencyService(instrumentation); + DependencyService dependencyService = TelemetrySystem.createDependencyService(instrumentation); - verifyNoInteractions(instrumentation); - assertNull(dependencyService); - } + verifyNoInteractions(instrumentation); + assertNull(dependencyService); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/EventSourceTest.java b/telemetry/src/test/java/datadog/telemetry/EventSourceTest.java index 3625f0a04df..d2186bc5c75 100644 --- a/telemetry/src/test/java/datadog/telemetry/EventSourceTest.java +++ b/telemetry/src/test/java/datadog/telemetry/EventSourceTest.java @@ -23,85 +23,81 @@ class EventSourceTest { - private static class EventQueues { - final Queue configChangeQueue = new LinkedBlockingQueue<>(); - final Queue integrationQueue = new LinkedBlockingQueue<>(); - final Queue dependencyQueue = new LinkedBlockingQueue<>(); - final Queue metricQueue = new LinkedBlockingQueue<>(); - final Queue distributionSeriesQueue = new LinkedBlockingQueue<>(); - final Queue logMessageQueue = new LinkedBlockingQueue<>(); - final Queue productChanges = new LinkedBlockingQueue<>(); - final Queue endpointQueue = new LinkedBlockingQueue<>(); - } + private static class EventQueues { + final Queue configChangeQueue = new LinkedBlockingQueue<>(); + final Queue integrationQueue = new LinkedBlockingQueue<>(); + final Queue dependencyQueue = new LinkedBlockingQueue<>(); + final Queue metricQueue = new LinkedBlockingQueue<>(); + final Queue distributionSeriesQueue = new LinkedBlockingQueue<>(); + final Queue logMessageQueue = new LinkedBlockingQueue<>(); + final Queue productChanges = new LinkedBlockingQueue<>(); + final Queue endpointQueue = new LinkedBlockingQueue<>(); + } - @SuppressWarnings("unchecked") - private static void addInstance(Queue queue, Object instance) { - queue.add((T) instance); - } + @SuppressWarnings("unchecked") + private static void addInstance(Queue queue, Object instance) { + queue.add((T) instance); + } - @ParameterizedTest(name = "test isEmpty when adding and clearing {0}") - @MethodSource("testIsEmptyWhenAddingAndClearingArguments") - void testIsEmptyWhenAddingAndClearing( - String eventType, Function> queueSelector, Object eventInstance) { - EventQueues eventQueues = new EventQueues(); - EventSource eventSource = - new EventSource.Queued( - eventQueues.configChangeQueue, - eventQueues.integrationQueue, - eventQueues.dependencyQueue, - eventQueues.metricQueue, - eventQueues.distributionSeriesQueue, - eventQueues.logMessageQueue, - eventQueues.productChanges, - eventQueues.endpointQueue); + @ParameterizedTest(name = "test isEmpty when adding and clearing {0}") + @MethodSource("testIsEmptyWhenAddingAndClearingArguments") + void testIsEmptyWhenAddingAndClearing( + String eventType, Function> queueSelector, Object eventInstance) { + EventQueues eventQueues = new EventQueues(); + EventSource eventSource = new EventSource.Queued( + eventQueues.configChangeQueue, + eventQueues.integrationQueue, + eventQueues.dependencyQueue, + eventQueues.metricQueue, + eventQueues.distributionSeriesQueue, + eventQueues.logMessageQueue, + eventQueues.productChanges, + eventQueues.endpointQueue); - assertTrue(eventSource.isEmpty()); + assertTrue(eventSource.isEmpty()); - // add an event to the queue - Queue queue = queueSelector.apply(eventQueues); - addInstance(queue, eventInstance); + // add an event to the queue + Queue queue = queueSelector.apply(eventQueues); + addInstance(queue, eventInstance); - // eventSource should not be empty - assertFalse(eventSource.isEmpty()); + // eventSource should not be empty + assertFalse(eventSource.isEmpty()); - // clear the queue - queue.clear(); + // clear the queue + queue.clear(); - // eventSource should be empty again - assertTrue(eventSource.isEmpty()); - } + // eventSource should be empty again + assertTrue(eventSource.isEmpty()); + } - private static Stream testIsEmptyWhenAddingAndClearingArguments() { - return Stream.of( - arguments( - "Config Change", - (Function>) queues -> queues.configChangeQueue, - ConfigSetting.of("key", "value", ConfigOrigin.ENV)), - arguments( - "Integration", - (Function>) queues -> queues.integrationQueue, - new Integration("name", true)), - arguments( - "Dependency", - (Function>) queues -> queues.dependencyQueue, - new Dependency("name", "version", "type", null)), - arguments( - "Metric", (Function>) queues -> queues.metricQueue, new Metric()), - arguments( - "Distribution Series", - (Function>) queues -> queues.distributionSeriesQueue, - new DistributionSeries()), - arguments( - "Log Message", - (Function>) queues -> queues.logMessageQueue, - new LogMessage()), - arguments( - "Product Change", - (Function>) queues -> queues.productChanges, - new ProductChange()), - arguments( - "Endpoint", - (Function>) queues -> queues.endpointQueue, - new Endpoint())); - } + private static Stream testIsEmptyWhenAddingAndClearingArguments() { + return Stream.of( + arguments( + "Config Change", + (Function>) queues -> queues.configChangeQueue, + ConfigSetting.of("key", "value", ConfigOrigin.ENV)), + arguments( + "Integration", + (Function>) queues -> queues.integrationQueue, + new Integration("name", true)), + arguments( + "Dependency", + (Function>) queues -> queues.dependencyQueue, + new Dependency("name", "version", "type", null)), + arguments("Metric", (Function>) queues -> queues.metricQueue, new Metric()), + arguments( + "Distribution Series", + (Function>) queues -> queues.distributionSeriesQueue, + new DistributionSeries()), + arguments( + "Log Message", + (Function>) queues -> queues.logMessageQueue, + new LogMessage()), + arguments( + "Product Change", + (Function>) queues -> queues.productChanges, + new ProductChange()), + arguments( + "Endpoint", (Function>) queues -> queues.endpointQueue, new Endpoint())); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/ExtendedHeartbeatDataTest.java b/telemetry/src/test/java/datadog/telemetry/ExtendedHeartbeatDataTest.java index 057972fabc2..e7d2e0341c9 100644 --- a/telemetry/src/test/java/datadog/telemetry/ExtendedHeartbeatDataTest.java +++ b/telemetry/src/test/java/datadog/telemetry/ExtendedHeartbeatDataTest.java @@ -13,77 +13,76 @@ class ExtendedHeartbeatDataTest { - private final Dependency dependency = new Dependency("name", "version", "source", "hash"); - private final ConfigSetting configSetting = - ConfigSetting.of("key", "value", ConfigOrigin.DEFAULT); - private final Integration integration = new Integration("integration", true); - - @TableTest({ - "scenario | limit", - "no limit | 0 ", - "small limit | 2 ", - "large limit | 10 " - }) - void discardDependenciesAfterExceedingLimit(int limit) { - ExtendedHeartbeatData extHeartbeatData = new ExtendedHeartbeatData(limit); - - for (int pushed = 0; pushed < limit + 1; pushed++) { - extHeartbeatData.pushDependency(dependency); + private final Dependency dependency = new Dependency("name", "version", "source", "hash"); + private final ConfigSetting configSetting = ConfigSetting.of("key", "value", ConfigOrigin.DEFAULT); + private final Integration integration = new Integration("integration", true); + + @TableTest({ + "scenario | limit", + "no limit | 0 ", + "small limit | 2 ", + "large limit | 10 " + }) + void discardDependenciesAfterExceedingLimit(int limit) { + ExtendedHeartbeatData extHeartbeatData = new ExtendedHeartbeatData(limit); + + for (int pushed = 0; pushed < limit + 1; pushed++) { + extHeartbeatData.pushDependency(dependency); + } + + EventSource snapshot = extHeartbeatData.snapshot(); + int dependencyCount = 0; + while (snapshot.hasDependencyEvent()) { + snapshot.nextDependencyEvent(); + dependencyCount++; + } + assertEquals(limit, dependencyCount); } - EventSource snapshot = extHeartbeatData.snapshot(); - int dependencyCount = 0; - while (snapshot.hasDependencyEvent()) { - snapshot.nextDependencyEvent(); - dependencyCount++; - } - assertEquals(limit, dependencyCount); - } - - @Test - void returnAllCollectedData() { - ExtendedHeartbeatData extHeartbeatData = new ExtendedHeartbeatData(); + @Test + void returnAllCollectedData() { + ExtendedHeartbeatData extHeartbeatData = new ExtendedHeartbeatData(); - // when - EventSource emptySnapshot = extHeartbeatData.snapshot(); - // then - assertTrue(emptySnapshot.isEmpty()); + // when + EventSource emptySnapshot = extHeartbeatData.snapshot(); + // then + assertTrue(emptySnapshot.isEmpty()); - // when - extHeartbeatData.pushDependency(dependency); - extHeartbeatData.pushConfigSetting(configSetting); - extHeartbeatData.pushIntegration(integration); + // when + extHeartbeatData.pushDependency(dependency); + extHeartbeatData.pushConfigSetting(configSetting); + extHeartbeatData.pushIntegration(integration); - // then - EventSource snapshot = extHeartbeatData.snapshot(); + // then + EventSource snapshot = extHeartbeatData.snapshot(); - assertFalse(snapshot.isEmpty()); + assertFalse(snapshot.isEmpty()); - assertTrue(snapshot.hasDependencyEvent()); - assertEquals(dependency, snapshot.nextDependencyEvent()); - assertFalse(snapshot.hasDependencyEvent()); + assertTrue(snapshot.hasDependencyEvent()); + assertEquals(dependency, snapshot.nextDependencyEvent()); + assertFalse(snapshot.hasDependencyEvent()); - assertFalse(snapshot.isEmpty()); + assertFalse(snapshot.isEmpty()); - assertTrue(snapshot.hasConfigChangeEvent()); - assertEquals(configSetting, snapshot.nextConfigChangeEvent()); - assertFalse(snapshot.hasConfigChangeEvent()); + assertTrue(snapshot.hasConfigChangeEvent()); + assertEquals(configSetting, snapshot.nextConfigChangeEvent()); + assertFalse(snapshot.hasConfigChangeEvent()); - assertFalse(snapshot.isEmpty()); + assertFalse(snapshot.isEmpty()); - assertTrue(snapshot.hasIntegrationEvent()); - assertEquals(integration, snapshot.nextIntegrationEvent()); - assertFalse(snapshot.hasIntegrationEvent()); + assertTrue(snapshot.hasIntegrationEvent()); + assertEquals(integration, snapshot.nextIntegrationEvent()); + assertFalse(snapshot.hasIntegrationEvent()); - assertTrue(snapshot.isEmpty()); + assertTrue(snapshot.isEmpty()); - // when another snapshot includes all data - EventSource anotherSnapshot = extHeartbeatData.snapshot(); + // when another snapshot includes all data + EventSource anotherSnapshot = extHeartbeatData.snapshot(); - // then - assertFalse(anotherSnapshot.isEmpty()); - assertTrue(anotherSnapshot.hasDependencyEvent()); - assertTrue(anotherSnapshot.hasConfigChangeEvent()); - assertTrue(anotherSnapshot.hasIntegrationEvent()); - } + // then + assertFalse(anotherSnapshot.isEmpty()); + assertTrue(anotherSnapshot.hasDependencyEvent()); + assertTrue(anotherSnapshot.hasConfigChangeEvent()); + assertTrue(anotherSnapshot.hasIntegrationEvent()); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/FileBasedTelemetryClientTest.java b/telemetry/src/test/java/datadog/telemetry/FileBasedTelemetryClientTest.java index 1104d3549d5..9cb084ead3b 100644 --- a/telemetry/src/test/java/datadog/telemetry/FileBasedTelemetryClientTest.java +++ b/telemetry/src/test/java/datadog/telemetry/FileBasedTelemetryClientTest.java @@ -21,123 +21,121 @@ class FileBasedTelemetryClientTest { - private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); - - @Test - void writesRequestBodyToFile(@TempDir Path tmp) throws IOException { - Path outputDir = tmp.resolve("payloads/telemetry"); - FileBasedTelemetryClient client = new FileBasedTelemetryClient(outputDir.toString()); - byte[] body = "{\"request_type\":\"app-started\"}".getBytes(StandardCharsets.UTF_8); - - TelemetryClient.Result result = client.sendHttpRequest(requestBuilder("app-started", body)); - - assertEquals(TelemetryClient.Result.SUCCESS, result); - List files = listFiles(outputDir); - assertEquals(1, files.size()); - assertArrayEquals(body, Files.readAllBytes(files.get(0))); - } - - @Test - void createsOutputDirectoryIfMissing(@TempDir Path tmp) { - Path outputDir = tmp.resolve("does/not/exist/yet"); - assertFalse(Files.exists(outputDir)); - - FileBasedTelemetryClient client = new FileBasedTelemetryClient(outputDir.toString()); - TelemetryClient.Result result = - client.sendHttpRequest( - requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); - - assertEquals(TelemetryClient.Result.SUCCESS, result); - assertTrue(Files.isDirectory(outputDir)); - } - - @Test - void filenameFollowsTelemetryConvention(@TempDir Path tmp) throws IOException { - FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); - - client.sendHttpRequest(requestBuilder("app-started", "{}".getBytes(StandardCharsets.UTF_8))); - - List files = listFiles(tmp); - assertEquals(1, files.size()); - String expected = String.format("telemetry-%020d-%s.json", 0L, PidHelper.getPid()); - assertEquals(expected, files.get(0).getFileName().toString()); - } - - @Test - void sequenceIncrementsPerRequest(@TempDir Path tmp) throws IOException { - FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); - - for (int i = 0; i < 3; i++) { - client.sendHttpRequest( - requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + @Test + void writesRequestBodyToFile(@TempDir Path tmp) throws IOException { + Path outputDir = tmp.resolve("payloads/telemetry"); + FileBasedTelemetryClient client = new FileBasedTelemetryClient(outputDir.toString()); + byte[] body = "{\"request_type\":\"app-started\"}".getBytes(StandardCharsets.UTF_8); + + TelemetryClient.Result result = client.sendHttpRequest(requestBuilder("app-started", body)); + + assertEquals(TelemetryClient.Result.SUCCESS, result); + List files = listFiles(outputDir); + assertEquals(1, files.size()); + assertArrayEquals(body, Files.readAllBytes(files.get(0))); + } + + @Test + void createsOutputDirectoryIfMissing(@TempDir Path tmp) { + Path outputDir = tmp.resolve("does/not/exist/yet"); + assertFalse(Files.exists(outputDir)); + + FileBasedTelemetryClient client = new FileBasedTelemetryClient(outputDir.toString()); + TelemetryClient.Result result = + client.sendHttpRequest(requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); + + assertEquals(TelemetryClient.Result.SUCCESS, result); + assertTrue(Files.isDirectory(outputDir)); + } + + @Test + void filenameFollowsTelemetryConvention(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); + + client.sendHttpRequest(requestBuilder("app-started", "{}".getBytes(StandardCharsets.UTF_8))); + + List files = listFiles(tmp); + assertEquals(1, files.size()); + String expected = String.format("telemetry-%020d-%s.json", 0L, PidHelper.getPid()); + assertEquals(expected, files.get(0).getFileName().toString()); + } + + @Test + void sequenceIncrementsPerRequest(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); + + for (int i = 0; i < 3; i++) { + client.sendHttpRequest(requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); + } + + List files = listFiles(tmp); + assertEquals(3, files.size()); + String pid = PidHelper.getPid(); + for (int i = 0; i < files.size(); i++) { + // files are unsorted from DirectoryStream — build expected set, check contains + String expected = String.format("telemetry-%020d-%s.json", (long) i, pid); + assertTrue( + files.stream().anyMatch(p -> p.getFileName().toString().equals(expected)), + "Missing file with sequence " + i + ": " + expected); + } } - List files = listFiles(tmp); - assertEquals(3, files.size()); - String pid = PidHelper.getPid(); - for (int i = 0; i < files.size(); i++) { - // files are unsorted from DirectoryStream — build expected set, check contains - String expected = String.format("telemetry-%020d-%s.json", (long) i, pid); - assertTrue( - files.stream().anyMatch(p -> p.getFileName().toString().equals(expected)), - "Missing file with sequence " + i + ": " + expected); + @Test + void handlesNullRequestBody(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); + Request.Builder builder = new Request.Builder() + .addHeader("DD-Telemetry-Request-Type", "app-closing") + .get(); + + TelemetryClient.Result result = client.sendHttpRequest(builder); + + assertEquals(TelemetryClient.Result.SUCCESS, result); + List files = listFiles(tmp); + assertEquals(1, files.size()); + assertEquals(0, Files.size(files.get(0))); } - } - - @Test - void handlesNullRequestBody(@TempDir Path tmp) throws IOException { - FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); - Request.Builder builder = - new Request.Builder().addHeader("DD-Telemetry-Request-Type", "app-closing").get(); - - TelemetryClient.Result result = client.sendHttpRequest(builder); - - assertEquals(TelemetryClient.Result.SUCCESS, result); - List files = listFiles(tmp); - assertEquals(1, files.size()); - assertEquals(0, Files.size(files.get(0))); - } - - @Test - void returnsFailureWhenOutputDirIsNotWritable(@TempDir Path tmp) throws IOException { - // Create a regular file at the expected output-dir path so createDirectories fails. - Path collision = tmp.resolve("not-a-dir"); - Files.createFile(collision); - FileBasedTelemetryClient client = - new FileBasedTelemetryClient(collision.resolve("sub").toString()); - - TelemetryClient.Result result = - client.sendHttpRequest( - requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); - - assertEquals(TelemetryClient.Result.FAILURE, result); - } - - @Test - void leavesNoLingeringTempFiles(@TempDir Path tmp) throws IOException { - FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); - - client.sendHttpRequest(requestBuilder("app-started", "{}".getBytes(StandardCharsets.UTF_8))); - - List files = listFiles(tmp); - assertFalse( - files.stream().anyMatch(p -> p.getFileName().toString().endsWith(".tmp")), - "Found leftover .tmp file: " + files); - } - - private static Request.Builder requestBuilder(String requestType, byte[] body) { - return new Request.Builder() - .addHeader("DD-Telemetry-Request-Type", requestType) - .post(RequestBody.create(JSON, body)); - } - - private static List listFiles(Path dir) throws IOException { - List files = new ArrayList<>(); - try (DirectoryStream stream = Files.newDirectoryStream(dir)) { - for (Path p : stream) { - files.add(p); - } + + @Test + void returnsFailureWhenOutputDirIsNotWritable(@TempDir Path tmp) throws IOException { + // Create a regular file at the expected output-dir path so createDirectories fails. + Path collision = tmp.resolve("not-a-dir"); + Files.createFile(collision); + FileBasedTelemetryClient client = + new FileBasedTelemetryClient(collision.resolve("sub").toString()); + + TelemetryClient.Result result = + client.sendHttpRequest(requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); + + assertEquals(TelemetryClient.Result.FAILURE, result); + } + + @Test + void leavesNoLingeringTempFiles(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp.toString()); + + client.sendHttpRequest(requestBuilder("app-started", "{}".getBytes(StandardCharsets.UTF_8))); + + List files = listFiles(tmp); + assertFalse( + files.stream().anyMatch(p -> p.getFileName().toString().endsWith(".tmp")), + "Found leftover .tmp file: " + files); + } + + private static Request.Builder requestBuilder(String requestType, byte[] body) { + return new Request.Builder() + .addHeader("DD-Telemetry-Request-Type", requestType) + .post(RequestBody.create(JSON, body)); + } + + private static List listFiles(Path dir) throws IOException { + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + for (Path p : stream) { + files.add(p); + } + } + return files; } - return files; - } } diff --git a/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java b/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java index d4266fa7bfb..4877e38fe7c 100644 --- a/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java +++ b/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java @@ -17,64 +17,63 @@ class HostInfoTest { - @Test - void getHostname() { - String hostname = HostInfo.getHostname(); + @Test + void getHostname() { + String hostname = HostInfo.getHostname(); - assertNotNull(hostname); - assertFalse(hostname.trim().isEmpty()); - } + assertNotNull(hostname); + assertFalse(hostname.trim().isEmpty()); + } - @Test - void getOsName() { - String osName = HostInfo.getOsName(); + @Test + void getOsName() { + String osName = HostInfo.getOsName(); - assertTrue(Arrays.asList("Linux", "Windows", "Darwin").contains(osName)); - } + assertTrue(Arrays.asList("Linux", "Windows", "Darwin").contains(osName)); + } - @Test - void getOsVersion() { - String osVersion = HostInfo.getOsVersion(); + @Test + void getOsVersion() { + String osVersion = HostInfo.getOsVersion(); - assertNotNull(osVersion); - assertFalse(osVersion.trim().isEmpty()); - } + assertNotNull(osVersion); + assertFalse(osVersion.trim().isEmpty()); + } - @Test - void compareToUname() throws IOException, InterruptedException { - assumeTrue(exitCode("uname", "-a") == 0); + @Test + void compareToUname() throws IOException, InterruptedException { + assumeTrue(exitCode("uname", "-a") == 0); - assertEquals(runCommand("uname", "-n"), HostInfo.getHostname()); - assertEquals(runCommand("uname", "-s"), HostInfo.getOsName()); - assertEquals(runCommand("uname", "-s"), HostInfo.getKernelName()); - if (OperatingSystem.isMacOs()) { - // uname -r will return X.Y.Z version, while JVM will report just X.Y - // disabled, the uname -r gives the Kernel version which is different from Mac OS version - // assertTrue(runCommand("uname", "-r").startsWith(HostInfo.getKernelRelease())); + assertEquals(runCommand("uname", "-n"), HostInfo.getHostname()); + assertEquals(runCommand("uname", "-s"), HostInfo.getOsName()); + assertEquals(runCommand("uname", "-s"), HostInfo.getKernelName()); + if (OperatingSystem.isMacOs()) { + // uname -r will return X.Y.Z version, while JVM will report just X.Y + // disabled, the uname -r gives the Kernel version which is different from Mac OS version + // assertTrue(runCommand("uname", "-r").startsWith(HostInfo.getKernelRelease())); - // No /proc in macOS, so using property os.version like for KernelRelease - assertEquals(HostInfo.getKernelRelease(), HostInfo.getKernelVersion()); - } else { - assertEquals(runCommand("uname", "-r"), HostInfo.getKernelRelease()); - // Ideally, this would be equal, but for now, we'll compromise to startWith. - assertTrue(runCommand("uname", "-v").startsWith(HostInfo.getKernelVersion())); + // No /proc in macOS, so using property os.version like for KernelRelease + assertEquals(HostInfo.getKernelRelease(), HostInfo.getKernelVersion()); + } else { + assertEquals(runCommand("uname", "-r"), HostInfo.getKernelRelease()); + // Ideally, this would be equal, but for now, we'll compromise to startWith. + assertTrue(runCommand("uname", "-v").startsWith(HostInfo.getKernelVersion())); + } } - } - private static String runCommand(String... command) throws IOException, InterruptedException { - Process process = new ProcessBuilder(command).start(); - String output; - try (BufferedReader reader = - new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - output = reader.lines().collect(Collectors.joining("\n")); + private static String runCommand(String... command) throws IOException, InterruptedException { + Process process = new ProcessBuilder(command).start(); + String output; + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + process.waitFor(); + return output.trim(); } - process.waitFor(); - return output.trim(); - } - private static int exitCode(String... command) throws IOException, InterruptedException { - Process process = new ProcessBuilder(command).start(); - return process.waitFor(); - } + private static int exitCode(String... command) throws IOException, InterruptedException { + Process process = new ProcessBuilder(command).start(); + return process.waitFor(); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetryClientTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetryClientTest.java index 37f5a54dbc2..f2ca62e9e9b 100644 --- a/telemetry/src/test/java/datadog/telemetry/TelemetryClientTest.java +++ b/telemetry/src/test/java/datadog/telemetry/TelemetryClientTest.java @@ -21,56 +21,50 @@ class TelemetryClientTest { - @TableTest({ - "scenario | ciVisEnabled | ciVisAgentlessEnabled | ciVisAgentlessUrl | expectedUrl ", - "ci visibility agentless enabled | true | true | http://ci.visibility.agentless.url | http://ci.visibility.agentless.url/api/v2/apmtelemetry ", - "ci visibility disabled | false | true | http://ci.visibility.agentless.url | https://all-http-intake.logs.datad0g.com/api/v2/apmtelemetry", - "ci visibility agentless disabled | true | false | http://ci.visibility.agentless.url | https://all-http-intake.logs.datad0g.com/api/v2/apmtelemetry", - "ci visibility agentless url is null | true | true | | https://all-http-intake.logs.datad0g.com/api/v2/apmtelemetry" - }) - void intakeClientUsesCiVisibilityAgentlessUrlIfConfiguredToDoSo( - boolean ciVisEnabled, - boolean ciVisAgentlessEnabled, - String ciVisAgentlessUrl, - String expectedUrl) { - Config config = spy(Config.get()); - doReturn("dummy-key").when(config).getApiKey(); - doReturn(123).when(config).getAgentTimeout(); - doReturn("datad0g.com").when(config).getSite(); - doReturn(ciVisEnabled).when(config).isCiVisibilityEnabled(); - doReturn(ciVisAgentlessEnabled).when(config).isCiVisibilityAgentlessEnabled(); - doReturn(ciVisAgentlessUrl).when(config).getCiVisibilityAgentlessUrl(); + @TableTest({ + "scenario | ciVisEnabled | ciVisAgentlessEnabled | ciVisAgentlessUrl | expectedUrl ", + "ci visibility agentless enabled | true | true | http://ci.visibility.agentless.url | http://ci.visibility.agentless.url/api/v2/apmtelemetry ", + "ci visibility disabled | false | true | http://ci.visibility.agentless.url | https://all-http-intake.logs.datad0g.com/api/v2/apmtelemetry", + "ci visibility agentless disabled | true | false | http://ci.visibility.agentless.url | https://all-http-intake.logs.datad0g.com/api/v2/apmtelemetry", + "ci visibility agentless url is null | true | true | | https://all-http-intake.logs.datad0g.com/api/v2/apmtelemetry" + }) + void intakeClientUsesCiVisibilityAgentlessUrlIfConfiguredToDoSo( + boolean ciVisEnabled, boolean ciVisAgentlessEnabled, String ciVisAgentlessUrl, String expectedUrl) { + Config config = spy(Config.get()); + doReturn("dummy-key").when(config).getApiKey(); + doReturn(123).when(config).getAgentTimeout(); + doReturn("datad0g.com").when(config).getSite(); + doReturn(ciVisEnabled).when(config).isCiVisibilityEnabled(); + doReturn(ciVisAgentlessEnabled).when(config).isCiVisibilityAgentlessEnabled(); + doReturn(ciVisAgentlessUrl).when(config).getCiVisibilityAgentlessUrl(); - TelemetryClient intakeClient = - TelemetryClient.buildIntakeClient(config, HttpRetryPolicy.Factory.NEVER_RETRY); + TelemetryClient intakeClient = TelemetryClient.buildIntakeClient(config, HttpRetryPolicy.Factory.NEVER_RETRY); - assertEquals(expectedUrl, intakeClient.getUrl().toString()); - } + assertEquals(expectedUrl, intakeClient.getUrl().toString()); + } - @Test - void intakeClientRetriesTelemetryRequestIfConfiguredToDoSo() { - OkHttpClient httpClient = mock(OkHttpClient.class); - HttpRetryPolicy.Factory httpRetryPolicy = new HttpRetryPolicy.Factory(2, 50, 1.5, true); - HttpUrl httpUrl = HttpUrl.get("https://intake.example.com"); - TelemetryClient intakeClient = - new TelemetryClient(httpClient, httpRetryPolicy, httpUrl, "dummy-api-key"); + @Test + void intakeClientRetriesTelemetryRequestIfConfiguredToDoSo() { + OkHttpClient httpClient = mock(OkHttpClient.class); + HttpRetryPolicy.Factory httpRetryPolicy = new HttpRetryPolicy.Factory(2, 50, 1.5, true); + HttpUrl httpUrl = HttpUrl.get("https://intake.example.com"); + TelemetryClient intakeClient = new TelemetryClient(httpClient, httpRetryPolicy, httpUrl, "dummy-api-key"); - doAnswer( - invocation -> { - throw new ConnectException("exception"); - }) - .when(httpClient) - .newCall(any()); + doAnswer(invocation -> { + throw new ConnectException("exception"); + }) + .when(httpClient) + .newCall(any()); - intakeClient.sendHttpRequest(dummyRequest()); + intakeClient.sendHttpRequest(dummyRequest()); - // original request + 2 retries - verify(httpClient, times(3)).newCall(any()); - } + // original request + 2 retries + verify(httpClient, times(3)).newCall(any()); + } - private Request.Builder dummyRequest() { - return new TelemetryRequest( - mock(EventSource.class), mock(EventSink.class), 1000, RequestType.APP_STARTED, false) - .httpRequest(); - } + private Request.Builder dummyRequest() { + return new TelemetryRequest( + mock(EventSource.class), mock(EventSink.class), 1000, RequestType.APP_STARTED, false) + .httpRequest(); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyDependencyMetadataTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyDependencyMetadataTest.java index 9751aaf68d6..418b76cad03 100644 --- a/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyDependencyMetadataTest.java +++ b/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyDependencyMetadataTest.java @@ -18,79 +18,75 @@ */ class TelemetryRequestBodyDependencyMetadataTest { - @Test - void writeDependency_includesMetadataArrayWhenPresent() throws IOException { - String metadataValue = - "{\"id\":\"GHSA-645p-88qh-w398\"," - + "\"reached\":[{\"path\":\"com.fasterxml.jackson.databind.ObjectMapper\"," - + "\"symbol\":\"\",\"line\":1}]}"; - Dependency dep = - new Dependency( - "com.fasterxml.jackson.core:jackson-databind", - "2.8.5", - null, - null, - Collections.singletonList(metadataValue)); - - String json = serializeDependency(dep); - - assertTrue(json.contains("\"metadata\""), "metadata array must be present"); - assertTrue(json.contains("\"type\":\"reachability\""), "type field must be reachability"); - assertTrue(json.contains("\"value\":"), "value field must be present"); - assertTrue(json.contains("GHSA-645p-88qh-w398"), "GHSA ID must appear in value"); - } - - @Test - void writeDependency_includesAllMetadataEntriesForMultipleCves() throws IOException { - Dependency dep = - new Dependency( - "com.example:lib", - "1.0.0", - null, - null, - Arrays.asList( - "{\"id\":\"GHSA-aaa-1111-2222\",\"reached\":[]}", - "{\"id\":\"GHSA-bbb-3333-4444\",\"reached\":[]}")); - - String json = serializeDependency(dep); - - assertTrue(json.contains("GHSA-aaa-1111-2222"), "first CVE must be present"); - assertTrue(json.contains("GHSA-bbb-3333-4444"), "second CVE must be present"); - } - - @Test - void writeDependency_omitsMetadataFieldWhenNull() throws IOException { - Dependency dep = new Dependency("com.example:lib", "1.0.0", null, null); - - String json = serializeDependency(dep); - - assertFalse(json.contains("\"metadata\""), "metadata field must be absent when null"); - } - - @Test - void writeDependency_includesEmptyMetadataArrayWhenListIsEmpty() throws IOException { - // RFC: metadata:[] (non-null, empty) means "SCA is active for this dep but no CVEs detected". - // Must be written so the backend knows SCA is monitoring the dependency. - Dependency dep = - new Dependency("com.example:lib", "1.0.0", null, null, Collections.emptyList()); - - String json = serializeDependency(dep); - - assertTrue(json.contains("\"metadata\":[]"), "metadata:[] must be present when list is empty"); - } - - private static String serializeDependency(Dependency dep) throws IOException { - TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_DEPENDENCIES_LOADED); - req.beginRequest(false); - req.beginDependencies(); - req.writeDependency(dep); - req.endDependencies(); - req.endRequest(); - - Buffer buf = new Buffer(); - req.writeTo(buf); - byte[] bytes = new byte[(int) buf.size()]; - buf.read(bytes); - return new String(bytes, StandardCharsets.UTF_8); - } + @Test + void writeDependency_includesMetadataArrayWhenPresent() throws IOException { + String metadataValue = "{\"id\":\"GHSA-645p-88qh-w398\"," + + "\"reached\":[{\"path\":\"com.fasterxml.jackson.databind.ObjectMapper\"," + + "\"symbol\":\"\",\"line\":1}]}"; + Dependency dep = new Dependency( + "com.fasterxml.jackson.core:jackson-databind", + "2.8.5", + null, + null, + Collections.singletonList(metadataValue)); + + String json = serializeDependency(dep); + + assertTrue(json.contains("\"metadata\""), "metadata array must be present"); + assertTrue(json.contains("\"type\":\"reachability\""), "type field must be reachability"); + assertTrue(json.contains("\"value\":"), "value field must be present"); + assertTrue(json.contains("GHSA-645p-88qh-w398"), "GHSA ID must appear in value"); + } + + @Test + void writeDependency_includesAllMetadataEntriesForMultipleCves() throws IOException { + Dependency dep = new Dependency( + "com.example:lib", + "1.0.0", + null, + null, + Arrays.asList( + "{\"id\":\"GHSA-aaa-1111-2222\",\"reached\":[]}", + "{\"id\":\"GHSA-bbb-3333-4444\",\"reached\":[]}")); + + String json = serializeDependency(dep); + + assertTrue(json.contains("GHSA-aaa-1111-2222"), "first CVE must be present"); + assertTrue(json.contains("GHSA-bbb-3333-4444"), "second CVE must be present"); + } + + @Test + void writeDependency_omitsMetadataFieldWhenNull() throws IOException { + Dependency dep = new Dependency("com.example:lib", "1.0.0", null, null); + + String json = serializeDependency(dep); + + assertFalse(json.contains("\"metadata\""), "metadata field must be absent when null"); + } + + @Test + void writeDependency_includesEmptyMetadataArrayWhenListIsEmpty() throws IOException { + // RFC: metadata:[] (non-null, empty) means "SCA is active for this dep but no CVEs detected". + // Must be written so the backend knows SCA is monitoring the dependency. + Dependency dep = new Dependency("com.example:lib", "1.0.0", null, null, Collections.emptyList()); + + String json = serializeDependency(dep); + + assertTrue(json.contains("\"metadata\":[]"), "metadata:[] must be present when list is empty"); + } + + private static String serializeDependency(Dependency dep) throws IOException { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_DEPENDENCIES_LOADED); + req.beginRequest(false); + req.beginDependencies(); + req.writeDependency(dep); + req.endDependencies(); + req.endRequest(); + + Buffer buf = new Buffer(); + req.writeTo(buf); + byte[] bytes = new byte[(int) buf.size()]; + buf.read(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyTest.java index b1c4600eaa5..a6b54bde5eb 100644 --- a/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyTest.java +++ b/telemetry/src/test/java/datadog/telemetry/TelemetryRequestBodyTest.java @@ -37,186 +37,177 @@ @ExtendWith(WithConfigExtension.class) class TelemetryRequestBodyTest { - @AfterEach - void resetProcessTags() { - ProcessTags.reset(Config.get()); - } - - @Test - void throwSerializationExceptionInCaseOfJsonNestingProblem() { - TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); - - req.beginRequest(false); - TelemetryRequestBody.SerializationException exception = - assertThrows( - TelemetryRequestBody.SerializationException.class, () -> req.beginRequest(false)); - - assertEquals("Failed serializing Telemetry begin-request part!", exception.getMessage()); - assertNotNull(exception.getCause()); - } - - @Test - void throwSerializationExceptionInCaseOfMoreThanOneTopLevelJsonValue() { - TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); - - req.beginRequest(false); - req.endRequest(); - TelemetryRequestBody.SerializationException exception = - assertThrows( - TelemetryRequestBody.SerializationException.class, () -> req.beginRequest(false)); - - assertEquals("Failed serializing Telemetry begin-request part!", exception.getMessage()); - assertNotNull(exception.getCause()); - } - - @Test - void writeConfigMustSupportValuesOfBooleanStringNumberAndNull() throws IOException { - TelemetryRequestBody req = - new TelemetryRequestBody(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); - Map map = new HashMap<>(); - map.put("key1", "value1"); - map.put("key2", Double.parseDouble("432.32")); - map.put("key3", 324); - - req.beginRequest(false); - // exclude request header to simplify assertion - drainToString(req); - - req.beginConfiguration(); - List configSettings = - Arrays.asList( - ConfigSetting.of("string", "bar", ConfigOrigin.REMOTE), - ConfigSetting.of("int", 2342, ConfigOrigin.DEFAULT), - ConfigSetting.of("double", Double.valueOf("123.456"), ConfigOrigin.ENV), - ConfigSetting.of("map", map, ConfigOrigin.JVM_PROP), - ConfigSetting.of("list", Arrays.asList("1", "2", 3), ConfigOrigin.DEFAULT), - // make sure null values are serialized - ConfigSetting.of("null", null, ConfigOrigin.DEFAULT)); - for (ConfigSetting configSetting : configSettings) { - req.writeConfiguration(configSetting); + @AfterEach + void resetProcessTags() { + ProcessTags.reset(Config.get()); } - req.endConfiguration(); - - String expectedJson = - ",\"configuration\":[" - + "{\"name\":\"DD_STRING\",\"value\":\"bar\",\"origin\":\"remote_config\",\"seq_id\":0}," - + "{\"name\":\"DD_INT\",\"value\":\"2342\",\"origin\":\"default\",\"seq_id\":0}," - + "{\"name\":\"DD_DOUBLE\",\"value\":\"123.456\",\"origin\":\"env_var\",\"seq_id\":0}," - + "{\"name\":\"DD_MAP\",\"value\":\"key1:value1,key2:432.32,key3:324\",\"origin\":\"jvm_prop\",\"seq_id\":0}," - + "{\"name\":\"DD_LIST\",\"value\":\"1,2,3\",\"origin\":\"default\",\"seq_id\":0}," - + "{\"name\":\"DD_NULL\",\"value\":null,\"origin\":\"default\",\"seq_id\":0}]"; - assertEquals(expectedJson, drainToString(req)); - } - - @Test - void useEnvironmentVariableForSettingKeys() throws IOException { - TelemetryRequestBody req = - new TelemetryRequestBody(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); - - req.beginRequest(false); - // exclude request header to simplify assertion - drainToString(req); - - req.beginConfiguration(); - req.writeConfiguration(ConfigSetting.of("this.is.a.key", "value", ConfigOrigin.REMOTE)); - req.endConfiguration(); - - assertEquals( - ",\"configuration\":[{\"name\":\"DD_THIS_IS_A_KEY\",\"value\":\"value\",\"origin\":\"remote_config\",\"seq_id\":0}]", - drainToString(req)); - } - - @Test - void addDebugFlag() throws IOException { - TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); - - req.beginRequest(true); - req.endRequest(); - - assertTrue(drainToString(req).contains("\"debug\":true")); - } - - @TableTest({ - "scenario | appsecChange | profilerChange | dynamicInstrumentationChange | appsecEnabled | profilerEnabled | dynamicInstrumentationEnabled", - "all products changed and enabled | true | true | true | true | true | true ", - "all products changed and disabled | true | true | true | false | false | false ", - "no product changed | false | false | false | true | true | true ", - "only profiler and dynamic instrumentation changed | false | true | true | true | true | true ", - "only appsec and dynamic instrumentation changed | true | false | true | true | true | true ", - "only appsec and profiler changed | true | true | false | true | true | true " - }) - void writeProducts( - boolean appsecChange, - boolean profilerChange, - boolean dynamicInstrumentationChange, - boolean appsecEnabled, - boolean profilerEnabled, - boolean dynamicInstrumentationEnabled) - throws IOException { - TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_PRODUCT_CHANGE); - Map products = new EnumMap<>(ProductType.class); - if (appsecChange) { - products.put(APPSEC, appsecEnabled); + + @Test + void throwSerializationExceptionInCaseOfJsonNestingProblem() { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); + + req.beginRequest(false); + TelemetryRequestBody.SerializationException exception = + assertThrows(TelemetryRequestBody.SerializationException.class, () -> req.beginRequest(false)); + + assertEquals("Failed serializing Telemetry begin-request part!", exception.getMessage()); + assertNotNull(exception.getCause()); + } + + @Test + void throwSerializationExceptionInCaseOfMoreThanOneTopLevelJsonValue() { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); + + req.beginRequest(false); + req.endRequest(); + TelemetryRequestBody.SerializationException exception = + assertThrows(TelemetryRequestBody.SerializationException.class, () -> req.beginRequest(false)); + + assertEquals("Failed serializing Telemetry begin-request part!", exception.getMessage()); + assertNotNull(exception.getCause()); + } + + @Test + void writeConfigMustSupportValuesOfBooleanStringNumberAndNull() throws IOException { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); + Map map = new HashMap<>(); + map.put("key1", "value1"); + map.put("key2", Double.parseDouble("432.32")); + map.put("key3", 324); + + req.beginRequest(false); + // exclude request header to simplify assertion + drainToString(req); + + req.beginConfiguration(); + List configSettings = Arrays.asList( + ConfigSetting.of("string", "bar", ConfigOrigin.REMOTE), + ConfigSetting.of("int", 2342, ConfigOrigin.DEFAULT), + ConfigSetting.of("double", Double.valueOf("123.456"), ConfigOrigin.ENV), + ConfigSetting.of("map", map, ConfigOrigin.JVM_PROP), + ConfigSetting.of("list", Arrays.asList("1", "2", 3), ConfigOrigin.DEFAULT), + // make sure null values are serialized + ConfigSetting.of("null", null, ConfigOrigin.DEFAULT)); + for (ConfigSetting configSetting : configSettings) { + req.writeConfiguration(configSetting); + } + req.endConfiguration(); + + String expectedJson = ",\"configuration\":[" + + "{\"name\":\"DD_STRING\",\"value\":\"bar\",\"origin\":\"remote_config\",\"seq_id\":0}," + + "{\"name\":\"DD_INT\",\"value\":\"2342\",\"origin\":\"default\",\"seq_id\":0}," + + "{\"name\":\"DD_DOUBLE\",\"value\":\"123.456\",\"origin\":\"env_var\",\"seq_id\":0}," + + "{\"name\":\"DD_MAP\",\"value\":\"key1:value1,key2:432.32,key3:324\",\"origin\":\"jvm_prop\",\"seq_id\":0}," + + "{\"name\":\"DD_LIST\",\"value\":\"1,2,3\",\"origin\":\"default\",\"seq_id\":0}," + + "{\"name\":\"DD_NULL\",\"value\":null,\"origin\":\"default\",\"seq_id\":0}]"; + assertEquals(expectedJson, drainToString(req)); + } + + @Test + void useEnvironmentVariableForSettingKeys() throws IOException { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); + + req.beginRequest(false); + // exclude request header to simplify assertion + drainToString(req); + + req.beginConfiguration(); + req.writeConfiguration(ConfigSetting.of("this.is.a.key", "value", ConfigOrigin.REMOTE)); + req.endConfiguration(); + + assertEquals( + ",\"configuration\":[{\"name\":\"DD_THIS_IS_A_KEY\",\"value\":\"value\",\"origin\":\"remote_config\",\"seq_id\":0}]", + drainToString(req)); + } + + @Test + void addDebugFlag() throws IOException { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); + + req.beginRequest(true); + req.endRequest(); + + assertTrue(drainToString(req).contains("\"debug\":true")); } - if (profilerChange) { - products.put(PROFILER, profilerEnabled); + + @TableTest({ + "scenario | appsecChange | profilerChange | dynamicInstrumentationChange | appsecEnabled | profilerEnabled | dynamicInstrumentationEnabled", + "all products changed and enabled | true | true | true | true | true | true ", + "all products changed and disabled | true | true | true | false | false | false ", + "no product changed | false | false | false | true | true | true ", + "only profiler and dynamic instrumentation changed | false | true | true | true | true | true ", + "only appsec and dynamic instrumentation changed | true | false | true | true | true | true ", + "only appsec and profiler changed | true | true | false | true | true | true " + }) + void writeProducts( + boolean appsecChange, + boolean profilerChange, + boolean dynamicInstrumentationChange, + boolean appsecEnabled, + boolean profilerEnabled, + boolean dynamicInstrumentationEnabled) + throws IOException { + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_PRODUCT_CHANGE); + Map products = new EnumMap<>(ProductType.class); + if (appsecChange) { + products.put(APPSEC, appsecEnabled); + } + if (profilerChange) { + products.put(PROFILER, profilerEnabled); + } + if (dynamicInstrumentationChange) { + products.put(DYNAMIC_INSTRUMENTATION, dynamicInstrumentationEnabled); + } + + req.beginRequest(false); + req.writeProducts(products); + req.endRequest(); + + String result = drainToString(req); + assertEquals(appsecChange, result.contains("\"appsec\":{\"enabled\":" + appsecEnabled + "}")); + assertEquals(profilerChange, result.contains("\"profiler\":{\"enabled\":" + profilerEnabled + "}")); + assertEquals( + dynamicInstrumentationChange, + result.contains("\"dynamic_instrumentation\":{\"enabled\":" + dynamicInstrumentationEnabled + "}")); } - if (dynamicInstrumentationChange) { - products.put(DYNAMIC_INSTRUMENTATION, dynamicInstrumentationEnabled); + + @TableTest({ + "scenario | processTagsEnabled", + "enabled | true ", + "disabled | false " + }) + @SuppressWarnings("unchecked") + void shouldPropagateProcessTagsWhenEnabled(boolean processTagsEnabled) throws IOException { + WithConfigExtension.injectSysConfig( + EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, String.valueOf(processTagsEnabled)); + try { + ProcessTags.reset(Config.get()); + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); + + req.beginRequest(true); + req.endRequest(); + + JsonAdapter adapter = new Moshi.Builder() + .build() + .adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); + Map parsed = (Map) adapter.fromJson(drainToString(req)); + Map application = (Map) parsed.get("application"); + Object parsedTags = application.get("process_tags"); + if (processTagsEnabled) { + assertEquals(ProcessTags.getTagsForSerialization().toString(), parsedTags); + } else { + assertNull(parsedTags); + } + } finally { + WithConfigExtension.injectSysConfig(EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "false"); + } } - req.beginRequest(false); - req.writeProducts(products); - req.endRequest(); - - String result = drainToString(req); - assertEquals(appsecChange, result.contains("\"appsec\":{\"enabled\":" + appsecEnabled + "}")); - assertEquals( - profilerChange, result.contains("\"profiler\":{\"enabled\":" + profilerEnabled + "}")); - assertEquals( - dynamicInstrumentationChange, - result.contains( - "\"dynamic_instrumentation\":{\"enabled\":" + dynamicInstrumentationEnabled + "}")); - } - - @TableTest({ - "scenario | processTagsEnabled", - "enabled | true ", - "disabled | false " - }) - @SuppressWarnings("unchecked") - void shouldPropagateProcessTagsWhenEnabled(boolean processTagsEnabled) throws IOException { - WithConfigExtension.injectSysConfig( - EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, String.valueOf(processTagsEnabled)); - try { - ProcessTags.reset(Config.get()); - TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED); - - req.beginRequest(true); - req.endRequest(); - - JsonAdapter adapter = - new Moshi.Builder() - .build() - .adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); - Map parsed = (Map) adapter.fromJson(drainToString(req)); - Map application = (Map) parsed.get("application"); - Object parsedTags = application.get("process_tags"); - if (processTagsEnabled) { - assertEquals(ProcessTags.getTagsForSerialization().toString(), parsedTags); - } else { - assertNull(parsedTags); - } - } finally { - WithConfigExtension.injectSysConfig(EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "false"); + private static String drainToString(RequestBody body) throws IOException { + Buffer buf = new Buffer(); + body.writeTo(buf); + byte[] bytes = new byte[(int) buf.size()]; + buf.read(bytes); + return new String(bytes); } - } - - private static String drainToString(RequestBody body) throws IOException { - Buffer buf = new Buffer(); - body.writeTo(buf); - byte[] bytes = new byte[(int) buf.size()]; - buf.read(bytes); - return new String(bytes); - } } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetryRouterTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetryRouterTest.java index 5cc474ce23a..ae728c686a6 100644 --- a/telemetry/src/test/java/datadog/telemetry/TelemetryRouterTest.java +++ b/telemetry/src/test/java/datadog/telemetry/TelemetryRouterTest.java @@ -33,492 +33,479 @@ class TelemetryRouterTest { - private static final HttpUrl AGENT_URL = HttpUrl.get("https://agent.example.com"); - private static final HttpUrl AGENT_TELEMETRY_URL = - AGENT_URL.resolve("telemetry/proxy/api/v2/apmtelemetry"); - private static final HttpUrl INTAKE_URL = HttpUrl.get("https://intake.example.com"); - private static final String API_KEY = "api-key"; - private static final String API_KEY_HEADER = "DD-API-KEY"; - - private final OkHttpClient okHttpClient = mock(OkHttpClient.class); - private final DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery = - mock(DDAgentFeaturesDiscovery.class); - - private TelemetryClient agentTelemetryClient; - private TelemetryClient intakeTelemetryClient; - private TelemetryRouter router; - - @BeforeEach - void setup() { - agentTelemetryClient = TelemetryClient.buildAgentClient(okHttpClient, AGENT_URL, NEVER_RETRY); - intakeTelemetryClient = new TelemetryClient(okHttpClient, NEVER_RETRY, INTAKE_URL, API_KEY); - router = - new TelemetryRouter( - ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, false); - } - - @TableTest({ - "scenario | httpCode | sendResult", - "informational status is a failure | 100 | FAILURE ", - "accepted status is a success | 202 | SUCCESS ", - "not found status is not found | 404 | NOT_FOUND ", - "server error status is a failure | 500 | FAILURE " - }) - void mapAnHttpStatusCodeToTheCorrectSendResult(int httpCode, String sendResult) { - stubNewCallReturning(httpCode); - - TelemetryClient.Result result = router.sendRequest(dummyRequest()); - - assertEquals(TelemetryClient.Result.valueOf(sendResult), result); - verify(okHttpClient, times(1)).newCall(any()); - } - - @Test - void catchIOExceptionFromOkHttpClientAndReturnFailure() { - stubNewCallThrowing(new IOException("exception")); - - TelemetryClient.Result result = router.sendRequest(dummyRequest()); - - assertEquals(FAILURE, result); - verify(okHttpClient, times(1)).newCall(any()); - } - - @Test - void catchInterruptedIOExceptionFromOkHttpClientAndReturnInterrupted() { - stubNewCallThrowing(new InterruptedIOException("interrupted")); - - TelemetryClient.Result result = router.sendRequest(dummyRequest()); - - assertEquals(TelemetryClient.Result.INTERRUPTED, result); - verify(okHttpClient, times(1)).newCall(any()); - } - - @TableTest({ - "returnCode", - "200 ", - "404 ", - "500 " - }) - void keepTryingToSendTelemetryToAgentDespiteOfReturnCodeWhenIntakeClientIsNull(int returnCode) { - TelemetryRouter agentOnlyRouter = - new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, null, false); - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, returnCode); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); - - agentOnlyRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - agentOnlyRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - } - - @TableTest({ - "returnCode", - "404 ", - "500 " - }) - void switchToIntakeWhenAgentStopsSupportingTelemetryProxyAndTelemetryRequestsStartFailing( - int returnCode) { - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, returnCode); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - } - - @Test - void whenConfiguredToPreferIntakeUseIntakeClientFromTheStart() { - TelemetryRouter telemetryRouter = - new TelemetryRouter( - ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, 200); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - telemetryRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - } - - @Test - void - whenConfiguredToPreferIntakeDoNotSwitchToAgentIfIntakeRequestSucceedsEvenIfAgentSupportsTelemetryProxy() { - TelemetryRouter telemetryRouter = - new TelemetryRouter( - ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, 200); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); - - telemetryRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - telemetryRouter.sendRequest(dummyRequest()); - - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - } - - @Test - void whenConfiguredToPreferIntakeDoNotSwitchToAgentIfRequestIsInterrupted() { - TelemetryRouter telemetryRouter = - new TelemetryRouter( - ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); - Request[] capturedRequest = new Request[1]; - doAnswer( - invocation -> { - capturedRequest[0] = invocation.getArgument(0); - throw new InterruptedIOException("interrupted"); - }) - .when(okHttpClient) - .newCall(any()); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); - - telemetryRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - stubNewCallCapturingRequest(capturedRequest, 200); - - telemetryRouter.sendRequest(dummyRequest()); - - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - } - - @Test - void whenConfiguredToPreferIntakeSwitchToAgentIfIntakeRequestFails() { - TelemetryRouter telemetryRouter = - new TelemetryRouter( - ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, 403); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); - - telemetryRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - stubNewCallCapturingRequest(capturedRequest, 200); - - telemetryRouter.sendRequest(dummyRequest()); - - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - } - - @Test - void doNotSwitchToIntakeWhenAgentStopsSupportingTelemetryProxyButAcceptsTelemetryRequests() { - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, 200); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - stubNewCallCapturingRequest(capturedRequest, 201); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - } - - @TableTest({ - "returnCode", - "404 ", - "500 " - }) - void switchToIntakeWhenAgentFailsToReceiveTelemetryRequests(int returnCode) { - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, returnCode); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true, false); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - } - - @TableTest({ - "returnCode | expectedApiKey | expectedUrlIsAgent", - "404 | | true ", - "500 | | true " - }) - void useAgentWhenIntakeIsNotAvailable( - int returnCode, String expectedApiKey, boolean expectedUrlIsAgent) { - HttpUrl expectedUrl = expectedUrlIsAgent ? AGENT_TELEMETRY_URL : INTAKE_URL; - TelemetryRouter agentOnlyRouter = - new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, null, false); - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, returnCode); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - agentOnlyRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(expectedUrl, capturedRequest[0].url()); - assertEquals(expectedApiKey, capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - agentOnlyRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - agentOnlyRouter.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(expectedUrl, capturedRequest[0].url()); - assertEquals(expectedApiKey, capturedRequest[0].header(API_KEY_HEADER)); - } - - @TableTest({ - "returnCode", - "404 ", - "500 " - }) - void switchToIntakeThenBackToAgentWhenBothFailToReceiveTelemetryRequests(int returnCode) { - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, returnCode); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - // always send first telemetry request to Agent - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - // switch to Intake if sending a telemetry request to Agent failed or Agent supports - // telemetry proxy - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - // switch back to Agent if Intake request fails - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - } - - @Test - void singleClientConstructorSkipsFeatureDiscoveryAndDelegatesToTheGivenClient() { - TelemetryClient singleClient = mock(TelemetryClient.class); - TelemetryRouter singleClientRouter = new TelemetryRouter(singleClient); - when(singleClient.sendHttpRequest(any())).thenReturn(SUCCESS); - - TelemetryClient.Result result = singleClientRouter.sendRequest(dummyRequest()); - - assertEquals(SUCCESS, result); - verify(singleClient, times(1)).sendHttpRequest(any()); - verify(ddAgentFeaturesDiscovery, never()).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, never()).supportsTelemetryProxy(); - } - - @Test - void singleClientConstructorDoesNotSwitchClientsOnFailure() { - TelemetryClient singleClient = mock(TelemetryClient.class); - TelemetryRouter singleClientRouter = new TelemetryRouter(singleClient); - - // first request fails - when(singleClient.sendHttpRequest(any())).thenReturn(FAILURE); - TelemetryClient.Result firstResult = singleClientRouter.sendRequest(dummyRequest()); - - assertEquals(FAILURE, firstResult); - verify(singleClient, times(1)).sendHttpRequest(any()); - - // second request goes to the same client - when(singleClient.sendHttpRequest(any())).thenReturn(SUCCESS); - TelemetryClient.Result secondResult = singleClientRouter.sendRequest(dummyRequest()); - - assertEquals(SUCCESS, secondResult); - verify(singleClient, times(2)).sendHttpRequest(any()); - verifyNoMoreInteractions(ddAgentFeaturesDiscovery); - } - - @TableTest({ - "returnCode", - "404 ", - "500 " - }) - void switchBackToAgentIfItStartsSupportingTelemetry(int returnCode) { - Request[] capturedRequest = new Request[1]; - stubNewCallCapturingRequest(capturedRequest, returnCode); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - // always send first telemetry request to Agent - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - // switch to Intake if sending a telemetry request to Agent failed or Agent supports - // telemetry proxy - stubNewCallCapturingRequest(capturedRequest, 201); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(INTAKE_URL, capturedRequest[0].url()); - assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); - clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); - - // switch back to Agent if it starts supporting telemetry proxy - stubNewCallCapturingRequest(capturedRequest, returnCode); - when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); - - router.sendRequest(dummyRequest()); - - verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); - verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); - verify(okHttpClient, times(1)).newCall(any()); - assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); - assertNull(capturedRequest[0].header(API_KEY_HEADER)); - } - - private TelemetryRequest dummyRequest() { - return new TelemetryRequest( - mock(EventSource.class), mock(EventSink.class), 1000, RequestType.APP_STARTED, false); - } - - private static Call mockCall(int code) throws IOException { - Call call = mock(Call.class); - when(call.execute()) - .thenReturn( - new Response.Builder() - .request(new Request.Builder().url(HttpUrl.get("https://example.com")).build()) - .protocol(Protocol.HTTP_1_1) - .message("OK") - .body(ResponseBody.create(MediaType.get("text/plain"), "OK")) - .code(code) - .build()); - return call; - } - - // stubs newCall() via doAnswer rather than when().thenAnswer() so that re-stubbing across - // multiple stages of the same test does not trigger a previously configured throwing answer - private void stubNewCallReturning(int code) { - doAnswer(invocation -> mockCall(code)).when(okHttpClient).newCall(any()); - } - - private void stubNewCallCapturingRequest(Request[] capturedRequest, int code) { - doAnswer( - invocation -> { - capturedRequest[0] = invocation.getArgument(0); - return mockCall(code); - }) - .when(okHttpClient) - .newCall(any()); - } - - private void stubNewCallThrowing(IOException exception) { - doAnswer( - invocation -> { - throw exception; - }) - .when(okHttpClient) - .newCall(any()); - } + private static final HttpUrl AGENT_URL = HttpUrl.get("https://agent.example.com"); + private static final HttpUrl AGENT_TELEMETRY_URL = AGENT_URL.resolve("telemetry/proxy/api/v2/apmtelemetry"); + private static final HttpUrl INTAKE_URL = HttpUrl.get("https://intake.example.com"); + private static final String API_KEY = "api-key"; + private static final String API_KEY_HEADER = "DD-API-KEY"; + + private final OkHttpClient okHttpClient = mock(OkHttpClient.class); + private final DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery = mock(DDAgentFeaturesDiscovery.class); + + private TelemetryClient agentTelemetryClient; + private TelemetryClient intakeTelemetryClient; + private TelemetryRouter router; + + @BeforeEach + void setup() { + agentTelemetryClient = TelemetryClient.buildAgentClient(okHttpClient, AGENT_URL, NEVER_RETRY); + intakeTelemetryClient = new TelemetryClient(okHttpClient, NEVER_RETRY, INTAKE_URL, API_KEY); + router = new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, false); + } + + @TableTest({ + "scenario | httpCode | sendResult", + "informational status is a failure | 100 | FAILURE ", + "accepted status is a success | 202 | SUCCESS ", + "not found status is not found | 404 | NOT_FOUND ", + "server error status is a failure | 500 | FAILURE " + }) + void mapAnHttpStatusCodeToTheCorrectSendResult(int httpCode, String sendResult) { + stubNewCallReturning(httpCode); + + TelemetryClient.Result result = router.sendRequest(dummyRequest()); + + assertEquals(TelemetryClient.Result.valueOf(sendResult), result); + verify(okHttpClient, times(1)).newCall(any()); + } + + @Test + void catchIOExceptionFromOkHttpClientAndReturnFailure() { + stubNewCallThrowing(new IOException("exception")); + + TelemetryClient.Result result = router.sendRequest(dummyRequest()); + + assertEquals(FAILURE, result); + verify(okHttpClient, times(1)).newCall(any()); + } + + @Test + void catchInterruptedIOExceptionFromOkHttpClientAndReturnInterrupted() { + stubNewCallThrowing(new InterruptedIOException("interrupted")); + + TelemetryClient.Result result = router.sendRequest(dummyRequest()); + + assertEquals(TelemetryClient.Result.INTERRUPTED, result); + verify(okHttpClient, times(1)).newCall(any()); + } + + @TableTest({ + "returnCode", + "200 ", + "404 ", + "500 " + }) + void keepTryingToSendTelemetryToAgentDespiteOfReturnCodeWhenIntakeClientIsNull(int returnCode) { + TelemetryRouter agentOnlyRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, null, false); + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, returnCode); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); + + agentOnlyRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + agentOnlyRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + } + + @TableTest({ + "returnCode", + "404 ", + "500 " + }) + void switchToIntakeWhenAgentStopsSupportingTelemetryProxyAndTelemetryRequestsStartFailing(int returnCode) { + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, returnCode); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + } + + @Test + void whenConfiguredToPreferIntakeUseIntakeClientFromTheStart() { + TelemetryRouter telemetryRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, 200); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + telemetryRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + } + + @Test + void whenConfiguredToPreferIntakeDoNotSwitchToAgentIfIntakeRequestSucceedsEvenIfAgentSupportsTelemetryProxy() { + TelemetryRouter telemetryRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, 200); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); + + telemetryRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + telemetryRouter.sendRequest(dummyRequest()); + + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + } + + @Test + void whenConfiguredToPreferIntakeDoNotSwitchToAgentIfRequestIsInterrupted() { + TelemetryRouter telemetryRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); + Request[] capturedRequest = new Request[1]; + doAnswer(invocation -> { + capturedRequest[0] = invocation.getArgument(0); + throw new InterruptedIOException("interrupted"); + }) + .when(okHttpClient) + .newCall(any()); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); + + telemetryRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + stubNewCallCapturingRequest(capturedRequest, 200); + + telemetryRouter.sendRequest(dummyRequest()); + + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + } + + @Test + void whenConfiguredToPreferIntakeSwitchToAgentIfIntakeRequestFails() { + TelemetryRouter telemetryRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient, true); + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, 403); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); + + telemetryRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + stubNewCallCapturingRequest(capturedRequest, 200); + + telemetryRouter.sendRequest(dummyRequest()); + + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + } + + @Test + void doNotSwitchToIntakeWhenAgentStopsSupportingTelemetryProxyButAcceptsTelemetryRequests() { + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, 200); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + stubNewCallCapturingRequest(capturedRequest, 201); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + } + + @TableTest({ + "returnCode", + "404 ", + "500 " + }) + void switchToIntakeWhenAgentFailsToReceiveTelemetryRequests(int returnCode) { + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, returnCode); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true, false); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + } + + @TableTest({ + "returnCode | expectedApiKey | expectedUrlIsAgent", + "404 | | true ", + "500 | | true " + }) + void useAgentWhenIntakeIsNotAvailable(int returnCode, String expectedApiKey, boolean expectedUrlIsAgent) { + HttpUrl expectedUrl = expectedUrlIsAgent ? AGENT_TELEMETRY_URL : INTAKE_URL; + TelemetryRouter agentOnlyRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, null, false); + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, returnCode); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + agentOnlyRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(expectedUrl, capturedRequest[0].url()); + assertEquals(expectedApiKey, capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + agentOnlyRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + agentOnlyRouter.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(expectedUrl, capturedRequest[0].url()); + assertEquals(expectedApiKey, capturedRequest[0].header(API_KEY_HEADER)); + } + + @TableTest({ + "returnCode", + "404 ", + "500 " + }) + void switchToIntakeThenBackToAgentWhenBothFailToReceiveTelemetryRequests(int returnCode) { + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, returnCode); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + // always send first telemetry request to Agent + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + // switch to Intake if sending a telemetry request to Agent failed or Agent supports + // telemetry proxy + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + // switch back to Agent if Intake request fails + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + } + + @Test + void singleClientConstructorSkipsFeatureDiscoveryAndDelegatesToTheGivenClient() { + TelemetryClient singleClient = mock(TelemetryClient.class); + TelemetryRouter singleClientRouter = new TelemetryRouter(singleClient); + when(singleClient.sendHttpRequest(any())).thenReturn(SUCCESS); + + TelemetryClient.Result result = singleClientRouter.sendRequest(dummyRequest()); + + assertEquals(SUCCESS, result); + verify(singleClient, times(1)).sendHttpRequest(any()); + verify(ddAgentFeaturesDiscovery, never()).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, never()).supportsTelemetryProxy(); + } + + @Test + void singleClientConstructorDoesNotSwitchClientsOnFailure() { + TelemetryClient singleClient = mock(TelemetryClient.class); + TelemetryRouter singleClientRouter = new TelemetryRouter(singleClient); + + // first request fails + when(singleClient.sendHttpRequest(any())).thenReturn(FAILURE); + TelemetryClient.Result firstResult = singleClientRouter.sendRequest(dummyRequest()); + + assertEquals(FAILURE, firstResult); + verify(singleClient, times(1)).sendHttpRequest(any()); + + // second request goes to the same client + when(singleClient.sendHttpRequest(any())).thenReturn(SUCCESS); + TelemetryClient.Result secondResult = singleClientRouter.sendRequest(dummyRequest()); + + assertEquals(SUCCESS, secondResult); + verify(singleClient, times(2)).sendHttpRequest(any()); + verifyNoMoreInteractions(ddAgentFeaturesDiscovery); + } + + @TableTest({ + "returnCode", + "404 ", + "500 " + }) + void switchBackToAgentIfItStartsSupportingTelemetry(int returnCode) { + Request[] capturedRequest = new Request[1]; + stubNewCallCapturingRequest(capturedRequest, returnCode); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + // always send first telemetry request to Agent + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + // switch to Intake if sending a telemetry request to Agent failed or Agent supports + // telemetry proxy + stubNewCallCapturingRequest(capturedRequest, 201); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(true); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(INTAKE_URL, capturedRequest[0].url()); + assertEquals(API_KEY, capturedRequest[0].header(API_KEY_HEADER)); + clearInvocations(okHttpClient, ddAgentFeaturesDiscovery); + + // switch back to Agent if it starts supporting telemetry proxy + stubNewCallCapturingRequest(capturedRequest, returnCode); + when(ddAgentFeaturesDiscovery.supportsTelemetryProxy()).thenReturn(false); + + router.sendRequest(dummyRequest()); + + verify(ddAgentFeaturesDiscovery, times(1)).discoverIfOutdated(); + verify(ddAgentFeaturesDiscovery, times(1)).supportsTelemetryProxy(); + verify(okHttpClient, times(1)).newCall(any()); + assertEquals(AGENT_TELEMETRY_URL, capturedRequest[0].url()); + assertNull(capturedRequest[0].header(API_KEY_HEADER)); + } + + private TelemetryRequest dummyRequest() { + return new TelemetryRequest( + mock(EventSource.class), mock(EventSink.class), 1000, RequestType.APP_STARTED, false); + } + + private static Call mockCall(int code) throws IOException { + Call call = mock(Call.class); + when(call.execute()) + .thenReturn(new Response.Builder() + .request(new Request.Builder() + .url(HttpUrl.get("https://example.com")) + .build()) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .body(ResponseBody.create(MediaType.get("text/plain"), "OK")) + .code(code) + .build()); + return call; + } + + // stubs newCall() via doAnswer rather than when().thenAnswer() so that re-stubbing across + // multiple stages of the same test does not trigger a previously configured throwing answer + private void stubNewCallReturning(int code) { + doAnswer(invocation -> mockCall(code)).when(okHttpClient).newCall(any()); + } + + private void stubNewCallCapturingRequest(Request[] capturedRequest, int code) { + doAnswer(invocation -> { + capturedRequest[0] = invocation.getArgument(0); + return mockCall(code); + }) + .when(okHttpClient) + .newCall(any()); + } + + private void stubNewCallThrowing(IOException exception) { + doAnswer(invocation -> { + throw exception; + }) + .when(okHttpClient) + .newCall(any()); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetryRunnableTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetryRunnableTest.java index 25b4d30f064..023527ae293 100644 --- a/telemetry/src/test/java/datadog/telemetry/TelemetryRunnableTest.java +++ b/telemetry/src/test/java/datadog/telemetry/TelemetryRunnableTest.java @@ -27,411 +27,399 @@ class TelemetryRunnableTest { - private Thread thread; + private Thread thread; + + @AfterEach + void cleanup() throws InterruptedException { + if (thread != null && thread.isAlive()) { + thread.interrupt(); + thread.join(); + } + } - @AfterEach - void cleanup() throws InterruptedException { - if (thread != null && thread.isAlive()) { - thread.interrupt(); - thread.join(); + @Test + @WithConfig(key = "TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL", value = "65", env = true) + void happyPath() throws InterruptedException, BrokenBarrierException, TimeoutException { + TelemetryRunnable.ThreadSleeper sleeperMock = mock(TelemetryRunnable.ThreadSleeper.class); + TickSleeper sleeper = new TickSleeper(sleeperMock); + TimeSource timeSource = mock(TimeSource.class); + TelemetryService telemetryService = mock(TelemetryService.class); + MetricCollector metricCollector = mock(MetricCollector.class); + MetricPeriodicAction metricAction = mock(MetricPeriodicAction.class); + when(metricAction.collector()).thenReturn(metricCollector); + TelemetryRunnable.TelemetryPeriodicAction periodicAction = + mock(TelemetryRunnable.TelemetryPeriodicAction.class); + TelemetryRunnable runnable = + new TelemetryRunnable(telemetryService, asList(metricAction, periodicAction), sleeper, timeSource); + thread = new Thread(runnable); + + // when initial iteration before the first sleep (metrics and heartbeat) + when(telemetryService.sendAppStartedEvent()).thenReturn(false, false, true); + when(timeSource.getCurrentTimeMillis()).thenReturn(60L * 1000, 60L * 1000 + 1); + when(metricCollector.drain()).thenReturn(asList()); + when(metricCollector.drainDistributionSeries()).thenReturn(asList()); + when(telemetryService.sendTelemetryEvents()).thenReturn(true, true, false); + thread.start(); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + // then two unsuccessful attempts to send app-started with the following successful attempt + verify(telemetryService, times(3)).sendAppStartedEvent(); + verify(timeSource, times(2)).getCurrentTimeMillis(); + // then two partial and one final telemetry data requests + verify(metricCollector, times(1)).prepareMetrics(); + verify(metricCollector, times(1)).drain(); + verify(metricCollector, times(1)).drainDistributionSeries(); + verify(periodicAction, times(1)).doIteration(telemetryService); + verify(telemetryService, times(3)).sendTelemetryEvents(); + verify(sleeperMock, times(1)).sleep(9999); + verify(telemetryService, atLeast(0)).addConfiguration(any()); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when second iteration (10 seconds, metrics) + when(timeSource.getCurrentTimeMillis()).thenReturn(70L * 1000, 70L * 1000 + 2); + sleeper.go.await(10, TimeUnit.SECONDS); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(metricCollector, times(1)).prepareMetrics(); + verify(sleeperMock, times(1)).sleep(9998); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when third iteration (20 seconds, metrics) + when(timeSource.getCurrentTimeMillis()).thenReturn(80L * 1000, 80L * 1000 + 3); + sleeper.go.await(10, TimeUnit.SECONDS); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(metricCollector, times(1)).prepareMetrics(); + verify(sleeperMock, times(1)).sleep(9997); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when fourth iteration (30 seconds, metrics) + when(timeSource.getCurrentTimeMillis()).thenReturn(90L * 1000, 90L * 1000 + 4); + sleeper.go.await(10, TimeUnit.SECONDS); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(metricCollector, times(1)).prepareMetrics(); + verify(sleeperMock, times(1)).sleep(9996); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when fifth iteration (40 seconds, metrics) + when(timeSource.getCurrentTimeMillis()).thenReturn(100L * 1000, 100L * 1000 + 5); + sleeper.go.await(10, TimeUnit.SECONDS); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(metricCollector, times(1)).prepareMetrics(); + verify(sleeperMock, times(1)).sleep(9995); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when sixth iteration (50 seconds, metrics) + when(timeSource.getCurrentTimeMillis()).thenReturn(110L * 1000, 110L * 1000 + 6); + sleeper.go.await(10, TimeUnit.SECONDS); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(metricCollector, times(1)).prepareMetrics(); + verify(sleeperMock, times(1)).sleep(9994); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when seventh iteration (60 seconds, metrics, heartbeat) + when(timeSource.getCurrentTimeMillis()).thenReturn(120L * 1000, 120L * 1000 + 7); + sleeper.go.await(10, TimeUnit.SECONDS); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(metricCollector, times(1)).prepareMetrics(); + verify(metricCollector, times(1)).drain(); + verify(metricCollector, times(1)).drainDistributionSeries(); + verify(periodicAction, times(1)).doIteration(telemetryService); + verify(telemetryService, times(1)).sendTelemetryEvents(); + verify(sleeperMock, times(1)).sleep(9993); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when eighth iteration (65 seconds, extended-heartbeat) + when(timeSource.getCurrentTimeMillis()).thenReturn(125L * 1000, 125L * 1000 + 8); + sleeper.go.await(5, TimeUnit.SECONDS); + sleeper.sleeped.await(5, TimeUnit.SECONDS); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(telemetryService, times(1)).sendExtendedHeartbeat(); + verify(sleeperMock, times(1)).sleep(4992); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); + clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); + + // when + thread.interrupt(); + thread.join(); + + // then + // flush pending data before shutdown + verify(metricCollector, times(1)).prepareMetrics(); + verify(metricCollector, times(1)).drain(); + verify(metricCollector, times(1)).drainDistributionSeries(); + verify(periodicAction, times(1)).doIteration(telemetryService); + verify(telemetryService, times(1)).sendTelemetryEvents(); + verify(telemetryService, times(1)).sendAppClosingEvent(); + verifyNoMoreInteractions(telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); } - } - - @Test - @WithConfig(key = "TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL", value = "65", env = true) - void happyPath() throws InterruptedException, BrokenBarrierException, TimeoutException { - TelemetryRunnable.ThreadSleeper sleeperMock = mock(TelemetryRunnable.ThreadSleeper.class); - TickSleeper sleeper = new TickSleeper(sleeperMock); - TimeSource timeSource = mock(TimeSource.class); - TelemetryService telemetryService = mock(TelemetryService.class); - MetricCollector metricCollector = mock(MetricCollector.class); - MetricPeriodicAction metricAction = mock(MetricPeriodicAction.class); - when(metricAction.collector()).thenReturn(metricCollector); - TelemetryRunnable.TelemetryPeriodicAction periodicAction = - mock(TelemetryRunnable.TelemetryPeriodicAction.class); - TelemetryRunnable runnable = - new TelemetryRunnable( - telemetryService, asList(metricAction, periodicAction), sleeper, timeSource); - thread = new Thread(runnable); - - // when initial iteration before the first sleep (metrics and heartbeat) - when(telemetryService.sendAppStartedEvent()).thenReturn(false, false, true); - when(timeSource.getCurrentTimeMillis()).thenReturn(60L * 1000, 60L * 1000 + 1); - when(metricCollector.drain()).thenReturn(asList()); - when(metricCollector.drainDistributionSeries()).thenReturn(asList()); - when(telemetryService.sendTelemetryEvents()).thenReturn(true, true, false); - thread.start(); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - // then two unsuccessful attempts to send app-started with the following successful attempt - verify(telemetryService, times(3)).sendAppStartedEvent(); - verify(timeSource, times(2)).getCurrentTimeMillis(); - // then two partial and one final telemetry data requests - verify(metricCollector, times(1)).prepareMetrics(); - verify(metricCollector, times(1)).drain(); - verify(metricCollector, times(1)).drainDistributionSeries(); - verify(periodicAction, times(1)).doIteration(telemetryService); - verify(telemetryService, times(3)).sendTelemetryEvents(); - verify(sleeperMock, times(1)).sleep(9999); - verify(telemetryService, atLeast(0)).addConfiguration(any()); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when second iteration (10 seconds, metrics) - when(timeSource.getCurrentTimeMillis()).thenReturn(70L * 1000, 70L * 1000 + 2); - sleeper.go.await(10, TimeUnit.SECONDS); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(metricCollector, times(1)).prepareMetrics(); - verify(sleeperMock, times(1)).sleep(9998); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when third iteration (20 seconds, metrics) - when(timeSource.getCurrentTimeMillis()).thenReturn(80L * 1000, 80L * 1000 + 3); - sleeper.go.await(10, TimeUnit.SECONDS); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(metricCollector, times(1)).prepareMetrics(); - verify(sleeperMock, times(1)).sleep(9997); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when fourth iteration (30 seconds, metrics) - when(timeSource.getCurrentTimeMillis()).thenReturn(90L * 1000, 90L * 1000 + 4); - sleeper.go.await(10, TimeUnit.SECONDS); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(metricCollector, times(1)).prepareMetrics(); - verify(sleeperMock, times(1)).sleep(9996); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when fifth iteration (40 seconds, metrics) - when(timeSource.getCurrentTimeMillis()).thenReturn(100L * 1000, 100L * 1000 + 5); - sleeper.go.await(10, TimeUnit.SECONDS); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(metricCollector, times(1)).prepareMetrics(); - verify(sleeperMock, times(1)).sleep(9995); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when sixth iteration (50 seconds, metrics) - when(timeSource.getCurrentTimeMillis()).thenReturn(110L * 1000, 110L * 1000 + 6); - sleeper.go.await(10, TimeUnit.SECONDS); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(metricCollector, times(1)).prepareMetrics(); - verify(sleeperMock, times(1)).sleep(9994); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when seventh iteration (60 seconds, metrics, heartbeat) - when(timeSource.getCurrentTimeMillis()).thenReturn(120L * 1000, 120L * 1000 + 7); - sleeper.go.await(10, TimeUnit.SECONDS); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(metricCollector, times(1)).prepareMetrics(); - verify(metricCollector, times(1)).drain(); - verify(metricCollector, times(1)).drainDistributionSeries(); - verify(periodicAction, times(1)).doIteration(telemetryService); - verify(telemetryService, times(1)).sendTelemetryEvents(); - verify(sleeperMock, times(1)).sleep(9993); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when eighth iteration (65 seconds, extended-heartbeat) - when(timeSource.getCurrentTimeMillis()).thenReturn(125L * 1000, 125L * 1000 + 8); - sleeper.go.await(5, TimeUnit.SECONDS); - sleeper.sleeped.await(5, TimeUnit.SECONDS); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(telemetryService, times(1)).sendExtendedHeartbeat(); - verify(sleeperMock, times(1)).sleep(4992); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - clearInvocations(telemetryService, timeSource, metricCollector, periodicAction, sleeperMock); - - // when - thread.interrupt(); - thread.join(); - - // then - // flush pending data before shutdown - verify(metricCollector, times(1)).prepareMetrics(); - verify(metricCollector, times(1)).drain(); - verify(metricCollector, times(1)).drainDistributionSeries(); - verify(periodicAction, times(1)).doIteration(telemetryService); - verify(telemetryService, times(1)).sendTelemetryEvents(); - verify(telemetryService, times(1)).sendAppClosingEvent(); - verifyNoMoreInteractions( - telemetryService, timeSource, sleeperMock, metricCollector, periodicAction); - } - - @Test - void doNotReattemptAppStartedEventUntilNextCycle() - throws InterruptedException, BrokenBarrierException, TimeoutException { - TelemetryRunnable.ThreadSleeper sleeperMock = mock(TelemetryRunnable.ThreadSleeper.class); - TickSleeper sleeper = new TickSleeper(sleeperMock); - TimeSource timeSource = mock(TimeSource.class); - TelemetryService telemetryService = mock(TelemetryService.class); - MetricCollector metricCollector = mock(MetricCollector.class); - MetricPeriodicAction metricAction = mock(MetricPeriodicAction.class); - when(metricAction.collector()).thenReturn(metricCollector); - TelemetryRunnable.TelemetryPeriodicAction periodicAction = - mock(TelemetryRunnable.TelemetryPeriodicAction.class); - TelemetryRunnable runnable = - new TelemetryRunnable( - telemetryService, asList(metricAction, periodicAction), sleeper, timeSource); - thread = new Thread(runnable); - - // three unsuccessful attempts to send app-started (TelemetryRunnable.MAX_APP_STARTED_RETRIES) - when(telemetryService.sendAppStartedEvent()).thenReturn(false, false, false); - when(timeSource.getCurrentTimeMillis()).thenReturn(60L * 1000); - - thread.start(); - sleeper.sleeped.await(10, TimeUnit.SECONDS); - - verify(telemetryService, times(3)).sendAppStartedEvent(); - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(sleeperMock, times(1)).sleep(10000); - } - - @Test - void schedulerSkipsMetricsIntervals() { - TimeSource timeSource = mock(TimeSource.class); - TelemetryRunnable.ThreadSleeper sleeper = mock(TelemetryRunnable.ThreadSleeper.class); - TelemetryRunnable.Scheduler scheduler = - new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000, 0); - - // first iteration: run everything - when(timeSource.getCurrentTimeMillis()).thenReturn(0L); - - scheduler.init(); - - assertTrue(scheduler.shouldRunMetrics()); - assertTrue(scheduler.shouldRunHeartbeat()); - verify(timeSource, times(1)).getCurrentTimeMillis(); - verifyNoMoreInteractions(timeSource, sleeper); - clearInvocations(timeSource, sleeper); - - when(timeSource.getCurrentTimeMillis()).thenReturn(1L, 10L * 1000); - - scheduler.sleepUntilNextIteration(); - - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(sleeper, times(1)).sleep(10 * 1000 - 1); - verifyNoMoreInteractions(timeSource, sleeper); - clearInvocations(timeSource, sleeper); - - // one metrics interval is exceeded - assertTrue(scheduler.shouldRunMetrics()); - assertFalse(scheduler.shouldRunHeartbeat()); - - when(timeSource.getCurrentTimeMillis()).thenReturn(20L * 1000 + 1, 30L * 1000); - - scheduler.sleepUntilNextIteration(); - - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(sleeper, times(1)).sleep(9999); - verifyNoMoreInteractions(timeSource, sleeper); - clearInvocations(timeSource, sleeper); - - // two metrics intervals are exceeded - assertTrue(scheduler.shouldRunMetrics()); - assertFalse(scheduler.shouldRunHeartbeat()); - - when(timeSource.getCurrentTimeMillis()).thenReturn(50L * 1000 + 2, 60L * 1000); - - scheduler.sleepUntilNextIteration(); - - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(sleeper, times(1)).sleep(9998); - verifyNoMoreInteractions(timeSource, sleeper); - assertTrue(scheduler.shouldRunMetrics()); - assertTrue(scheduler.shouldRunHeartbeat()); - } - - @Test - void schedulerSkipsHeartbeatIntervals() { - TimeSource timeSource = mock(TimeSource.class); - TelemetryRunnable.ThreadSleeper sleeper = mock(TelemetryRunnable.ThreadSleeper.class); - TelemetryRunnable.Scheduler scheduler = - new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000, 0); - - // first iteration - when(timeSource.getCurrentTimeMillis()).thenReturn(0L); - scheduler.init(); - - // run everything - assertTrue(scheduler.shouldRunMetrics()); - assertTrue(scheduler.shouldRunHeartbeat()); - verify(timeSource, times(1)).getCurrentTimeMillis(); - verifyNoMoreInteractions(timeSource, sleeper); - clearInvocations(timeSource, sleeper); - - // when - when(timeSource.getCurrentTimeMillis()).thenReturn(1L, 10L * 1000); - scheduler.sleepUntilNextIteration(); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(sleeper, times(1)).sleep(10 * 1000 - 1); - verifyNoMoreInteractions(timeSource, sleeper); - clearInvocations(timeSource, sleeper); - - // when heartbeat interval is exceeded - assertTrue(scheduler.shouldRunMetrics()); - assertFalse(scheduler.shouldRunHeartbeat()); - when(timeSource.getCurrentTimeMillis()).thenReturn(70L * 1000); - scheduler.sleepUntilNextIteration(); - - // then - verify(timeSource, times(1)).getCurrentTimeMillis(); - verifyNoMoreInteractions(timeSource, sleeper); - assertTrue(scheduler.shouldRunMetrics()); - assertTrue(scheduler.shouldRunHeartbeat()); - clearInvocations(timeSource, sleeper); - - // when metrics interval has been adjusted - when(timeSource.getCurrentTimeMillis()).thenReturn(70L * 1000 + 1, 80L * 1000); - scheduler.sleepUntilNextIteration(); - - // then - verify(timeSource, times(2)).getCurrentTimeMillis(); - verify(sleeper, times(1)).sleep(10 * 1000 - 1); - verifyNoMoreInteractions(timeSource, sleeper); - assertTrue(scheduler.shouldRunMetrics()); - assertFalse(scheduler.shouldRunHeartbeat()); - } - - @TableTest({ - "scenario | iters | metricsSecs | heartbeatSecs | extHeartbeatSecs | expectedMetrics | expectedHeartbeats | expectedExtHeartbeats", - "no intervals configured | 10 | 0 | 0 | 0 | 10 | 10 | 10 ", - "one second intervals | 10 | 1 | 1 | 1 | 10 | 10 | 9 ", - "metrics runs more frequently than heartbeat | 12 | 10 | 60 | 60 | 12 | 2 | 1 ", - "heartbeat runs more frequently than metrics | 12 | 60 | 10 | 10 | 2 | 12 | 11 ", - "metrics and heartbeat intervals close together (3, 5) | 6 | 3 | 5 | 5 | 4 | 3 | 2 ", - "metrics and heartbeat intervals close together (5, 3) | 6 | 5 | 3 | 3 | 3 | 4 | 3 " - }) - void schedulerWithHeartbeatMetricsAndExtendedHeartbeatIntervals( - int iters, - int metricsSecs, - int heartbeatSecs, - int extHeartbeatSecs, - int expectedMetrics, - int expectedHeartbeats, - int expectedExtHeartbeats) { - TimeSourceAndSleeper timing = new TimeSourceAndSleeper(); - TelemetryRunnable.Scheduler scheduler = - new TelemetryRunnable.Scheduler( - timing, timing, heartbeatSecs * 1000L, metricsSecs * 1000L, extHeartbeatSecs * 1000L); - int metricsRunCount = 0; - int heartbeatsRunCount = 0; - int extHeartbeatsRunCount = 0; - - scheduler.init(); - for (int i = 0; i < iters; i++) { - if (scheduler.shouldRunMetrics()) { - metricsRunCount++; - } - if (scheduler.shouldRunHeartbeat()) { - heartbeatsRunCount++; - } - boolean runExtHeartbeat = scheduler.shouldRunExtendedHeartbeat(); - if (runExtHeartbeat) { - extHeartbeatsRunCount++; - // need to manually advance to retry next iteration if extended-heartbeat request failed - scheduler.scheduleNextExtendedHeartbeat(); - } - scheduler.sleepUntilNextIteration(); + + @Test + void doNotReattemptAppStartedEventUntilNextCycle() + throws InterruptedException, BrokenBarrierException, TimeoutException { + TelemetryRunnable.ThreadSleeper sleeperMock = mock(TelemetryRunnable.ThreadSleeper.class); + TickSleeper sleeper = new TickSleeper(sleeperMock); + TimeSource timeSource = mock(TimeSource.class); + TelemetryService telemetryService = mock(TelemetryService.class); + MetricCollector metricCollector = mock(MetricCollector.class); + MetricPeriodicAction metricAction = mock(MetricPeriodicAction.class); + when(metricAction.collector()).thenReturn(metricCollector); + TelemetryRunnable.TelemetryPeriodicAction periodicAction = + mock(TelemetryRunnable.TelemetryPeriodicAction.class); + TelemetryRunnable runnable = + new TelemetryRunnable(telemetryService, asList(metricAction, periodicAction), sleeper, timeSource); + thread = new Thread(runnable); + + // three unsuccessful attempts to send app-started (TelemetryRunnable.MAX_APP_STARTED_RETRIES) + when(telemetryService.sendAppStartedEvent()).thenReturn(false, false, false); + when(timeSource.getCurrentTimeMillis()).thenReturn(60L * 1000); + + thread.start(); + sleeper.sleeped.await(10, TimeUnit.SECONDS); + + verify(telemetryService, times(3)).sendAppStartedEvent(); + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(sleeperMock, times(1)).sleep(10000); } - assertEquals(expectedMetrics, metricsRunCount); - assertEquals(expectedHeartbeats, heartbeatsRunCount); - assertEquals(expectedExtHeartbeats, extHeartbeatsRunCount); - } + @Test + void schedulerSkipsMetricsIntervals() { + TimeSource timeSource = mock(TimeSource.class); + TelemetryRunnable.ThreadSleeper sleeper = mock(TelemetryRunnable.ThreadSleeper.class); + TelemetryRunnable.Scheduler scheduler = + new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000, 0); - // wraps a ThreadSleeper delegate with two barriers so the test thread can step the background - // runnable one iteration at a time - private static final class TickSleeper implements TelemetryRunnable.ThreadSleeper { - final CyclicBarrier sleeped = new CyclicBarrier(2); - final CyclicBarrier go = new CyclicBarrier(2); - final TelemetryRunnable.ThreadSleeper delegate; + // first iteration: run everything + when(timeSource.getCurrentTimeMillis()).thenReturn(0L); - TickSleeper(TelemetryRunnable.ThreadSleeper delegate) { - this.delegate = delegate; - } + scheduler.init(); - @Override - public void sleep(long timeoutMs) { - if (delegate != null) { - delegate.sleep(timeoutMs); - } - try { - sleeped.await(10, TimeUnit.SECONDS); - go.await(10, TimeUnit.SECONDS); - } catch (InterruptedException | BrokenBarrierException | TimeoutException e) { - // Thread.interrupt() on the background thread trips this barrier; rethrow the real - // exception unchecked so it reaches TelemetryRunnable.run()'s catch (InterruptedException) - // block exactly like the interrupted CyclicBarrier.await() would in the JVM. - sneakyThrow(e); - } - } + assertTrue(scheduler.shouldRunMetrics()); + assertTrue(scheduler.shouldRunHeartbeat()); + verify(timeSource, times(1)).getCurrentTimeMillis(); + verifyNoMoreInteractions(timeSource, sleeper); + clearInvocations(timeSource, sleeper); - @SuppressWarnings("unchecked") - private static void sneakyThrow(Throwable t) throws T { - throw (T) t; - } - } + when(timeSource.getCurrentTimeMillis()).thenReturn(1L, 10L * 1000); + + scheduler.sleepUntilNextIteration(); + + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(sleeper, times(1)).sleep(10 * 1000 - 1); + verifyNoMoreInteractions(timeSource, sleeper); + clearInvocations(timeSource, sleeper); + + // one metrics interval is exceeded + assertTrue(scheduler.shouldRunMetrics()); + assertFalse(scheduler.shouldRunHeartbeat()); + + when(timeSource.getCurrentTimeMillis()).thenReturn(20L * 1000 + 1, 30L * 1000); - private static final class TimeSourceAndSleeper - implements TimeSource, TelemetryRunnable.ThreadSleeper { + scheduler.sleepUntilNextIteration(); - private long currentTime = 0; + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(sleeper, times(1)).sleep(9999); + verifyNoMoreInteractions(timeSource, sleeper); + clearInvocations(timeSource, sleeper); - @Override - public void sleep(long timeoutMs) { - currentTime += timeoutMs; + // two metrics intervals are exceeded + assertTrue(scheduler.shouldRunMetrics()); + assertFalse(scheduler.shouldRunHeartbeat()); + + when(timeSource.getCurrentTimeMillis()).thenReturn(50L * 1000 + 2, 60L * 1000); + + scheduler.sleepUntilNextIteration(); + + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(sleeper, times(1)).sleep(9998); + verifyNoMoreInteractions(timeSource, sleeper); + assertTrue(scheduler.shouldRunMetrics()); + assertTrue(scheduler.shouldRunHeartbeat()); } - @Override - public long getCurrentTimeMillis() { - return currentTime; + @Test + void schedulerSkipsHeartbeatIntervals() { + TimeSource timeSource = mock(TimeSource.class); + TelemetryRunnable.ThreadSleeper sleeper = mock(TelemetryRunnable.ThreadSleeper.class); + TelemetryRunnable.Scheduler scheduler = + new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000, 0); + + // first iteration + when(timeSource.getCurrentTimeMillis()).thenReturn(0L); + scheduler.init(); + + // run everything + assertTrue(scheduler.shouldRunMetrics()); + assertTrue(scheduler.shouldRunHeartbeat()); + verify(timeSource, times(1)).getCurrentTimeMillis(); + verifyNoMoreInteractions(timeSource, sleeper); + clearInvocations(timeSource, sleeper); + + // when + when(timeSource.getCurrentTimeMillis()).thenReturn(1L, 10L * 1000); + scheduler.sleepUntilNextIteration(); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(sleeper, times(1)).sleep(10 * 1000 - 1); + verifyNoMoreInteractions(timeSource, sleeper); + clearInvocations(timeSource, sleeper); + + // when heartbeat interval is exceeded + assertTrue(scheduler.shouldRunMetrics()); + assertFalse(scheduler.shouldRunHeartbeat()); + when(timeSource.getCurrentTimeMillis()).thenReturn(70L * 1000); + scheduler.sleepUntilNextIteration(); + + // then + verify(timeSource, times(1)).getCurrentTimeMillis(); + verifyNoMoreInteractions(timeSource, sleeper); + assertTrue(scheduler.shouldRunMetrics()); + assertTrue(scheduler.shouldRunHeartbeat()); + clearInvocations(timeSource, sleeper); + + // when metrics interval has been adjusted + when(timeSource.getCurrentTimeMillis()).thenReturn(70L * 1000 + 1, 80L * 1000); + scheduler.sleepUntilNextIteration(); + + // then + verify(timeSource, times(2)).getCurrentTimeMillis(); + verify(sleeper, times(1)).sleep(10 * 1000 - 1); + verifyNoMoreInteractions(timeSource, sleeper); + assertTrue(scheduler.shouldRunMetrics()); + assertFalse(scheduler.shouldRunHeartbeat()); } - @Override - public long getNanoTicks() { - throw new UnsupportedOperationException("NOT IMPLEMENTED"); + @TableTest({ + "scenario | iters | metricsSecs | heartbeatSecs | extHeartbeatSecs | expectedMetrics | expectedHeartbeats | expectedExtHeartbeats", + "no intervals configured | 10 | 0 | 0 | 0 | 10 | 10 | 10 ", + "one second intervals | 10 | 1 | 1 | 1 | 10 | 10 | 9 ", + "metrics runs more frequently than heartbeat | 12 | 10 | 60 | 60 | 12 | 2 | 1 ", + "heartbeat runs more frequently than metrics | 12 | 60 | 10 | 10 | 2 | 12 | 11 ", + "metrics and heartbeat intervals close together (3, 5) | 6 | 3 | 5 | 5 | 4 | 3 | 2 ", + "metrics and heartbeat intervals close together (5, 3) | 6 | 5 | 3 | 3 | 3 | 4 | 3 " + }) + void schedulerWithHeartbeatMetricsAndExtendedHeartbeatIntervals( + int iters, + int metricsSecs, + int heartbeatSecs, + int extHeartbeatSecs, + int expectedMetrics, + int expectedHeartbeats, + int expectedExtHeartbeats) { + TimeSourceAndSleeper timing = new TimeSourceAndSleeper(); + TelemetryRunnable.Scheduler scheduler = new TelemetryRunnable.Scheduler( + timing, timing, heartbeatSecs * 1000L, metricsSecs * 1000L, extHeartbeatSecs * 1000L); + int metricsRunCount = 0; + int heartbeatsRunCount = 0; + int extHeartbeatsRunCount = 0; + + scheduler.init(); + for (int i = 0; i < iters; i++) { + if (scheduler.shouldRunMetrics()) { + metricsRunCount++; + } + if (scheduler.shouldRunHeartbeat()) { + heartbeatsRunCount++; + } + boolean runExtHeartbeat = scheduler.shouldRunExtendedHeartbeat(); + if (runExtHeartbeat) { + extHeartbeatsRunCount++; + // need to manually advance to retry next iteration if extended-heartbeat request failed + scheduler.scheduleNextExtendedHeartbeat(); + } + scheduler.sleepUntilNextIteration(); + } + + assertEquals(expectedMetrics, metricsRunCount); + assertEquals(expectedHeartbeats, heartbeatsRunCount); + assertEquals(expectedExtHeartbeats, extHeartbeatsRunCount); } - @Override - public long getCurrentTimeMicros() { - throw new UnsupportedOperationException("NOT IMPLEMENTED"); + // wraps a ThreadSleeper delegate with two barriers so the test thread can step the background + // runnable one iteration at a time + private static final class TickSleeper implements TelemetryRunnable.ThreadSleeper { + final CyclicBarrier sleeped = new CyclicBarrier(2); + final CyclicBarrier go = new CyclicBarrier(2); + final TelemetryRunnable.ThreadSleeper delegate; + + TickSleeper(TelemetryRunnable.ThreadSleeper delegate) { + this.delegate = delegate; + } + + @Override + public void sleep(long timeoutMs) { + if (delegate != null) { + delegate.sleep(timeoutMs); + } + try { + sleeped.await(10, TimeUnit.SECONDS); + go.await(10, TimeUnit.SECONDS); + } catch (InterruptedException | BrokenBarrierException | TimeoutException e) { + // Thread.interrupt() on the background thread trips this barrier; rethrow the real + // exception unchecked so it reaches TelemetryRunnable.run()'s catch (InterruptedException) + // block exactly like the interrupted CyclicBarrier.await() would in the JVM. + sneakyThrow(e); + } + } + + @SuppressWarnings("unchecked") + private static void sneakyThrow(Throwable t) throws T { + throw (T) t; + } } - @Override - public long getCurrentTimeNanos() { - throw new UnsupportedOperationException("NOT IMPLEMENTED"); + private static final class TimeSourceAndSleeper implements TimeSource, TelemetryRunnable.ThreadSleeper { + + private long currentTime = 0; + + @Override + public void sleep(long timeoutMs) { + currentTime += timeoutMs; + } + + @Override + public long getCurrentTimeMillis() { + return currentTime; + } + + @Override + public long getNanoTicks() { + throw new UnsupportedOperationException("NOT IMPLEMENTED"); + } + + @Override + public long getCurrentTimeMicros() { + throw new UnsupportedOperationException("NOT IMPLEMENTED"); + } + + @Override + public long getCurrentTimeNanos() { + throw new UnsupportedOperationException("NOT IMPLEMENTED"); + } } - } } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetryServiceTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetryServiceTest.java index 661ef36f474..c8a4aab6823 100644 --- a/telemetry/src/test/java/datadog/telemetry/TelemetryServiceTest.java +++ b/telemetry/src/test/java/datadog/telemetry/TelemetryServiceTest.java @@ -50,637 +50,626 @@ @ExtendWith(WithConfigExtension.class) class TelemetryServiceTest { - private final ConfigOrigin confKeyOrigin = ConfigOrigin.DEFAULT; - private final ConfigSetting confKeyValue = - ConfigSetting.of("confkey", "confvalue", confKeyOrigin); - private final Map> configuration = - singletonMap(confKeyOrigin, singletonMap("confkey", confKeyValue)); - private final Integration integration = new Integration("integration", true); - private final Dependency dependency = new Dependency("dependency", "1.0.0", "src", "hash"); - private final Metric metric = - new Metric() - .namespace("tracers") - .metric("metric") - .points(Collections.>singletonList(Arrays.asList(1, 2))) - .tags(Arrays.asList("tag1", "tag2")); - private final DistributionSeries distribution = - new DistributionSeries() - .namespace("tracers") - .metric("distro") - .points(Arrays.asList(1, 2, 3)) - .tags(Arrays.asList("tag1", "tag2")) - .common(false); - private final LogMessage logMessage = - new LogMessage() - .message("log-message") - .tags("tag1:tag2") - .level(LogMessageLevel.DEBUG) - .stackTrace("stack-trace") - .tracerTime(32423L) - .count(1); - private final ProductChange productChange = - new ProductChange().productType(ProductChange.ProductType.APPSEC).enabled(true); - private final Endpoint endpoint = - new Endpoint() - .first(true) - .type("REST") - .method("GET") - .operation("http.request") - .resource("GET /test") - .path("/test") - .requestBodyType(singletonList("application/json")) - .responseBodyType(singletonList("application/json")) - .responseCode(singletonList(200)) - .authentication(singletonList("JWT")); - - @Test - void happyPathWithoutData() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - // first iteration - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - // app-started - testHttpClient.assertRequestBody(APP_STARTED).assertPayload().products(); - testHttpClient.assertNoMoreRequests(); - - // second iteration - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - // app-heartbeat only - testHttpClient.assertRequestBody(APP_HEARTBEAT); - testHttpClient.assertNoMoreRequests(); - - // third iteration - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - // app-heartbeat only - testHttpClient.assertRequestBody(APP_HEARTBEAT); - testHttpClient.assertNoMoreRequests(); - } - - @Test - void happyPathWithData() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - // add data before first iteration - telemetryService.addConfiguration(configuration); - telemetryService.addIntegration(integration); - telemetryService.addDependency(dependency); - telemetryService.addMetric(metric); - telemetryService.addDistributionSeries(distribution); - telemetryService.addLogMessage(logMessage); - telemetryService.addProductChange(productChange); - telemetryService.addEndpoint(endpoint); - - // send messages - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products() - .configuration(singletonList(confKeyValue)); - - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - testHttpClient - .assertRequestBody(MESSAGE_BATCH) - .assertBatch(8) - .assertFirstMessage(APP_HEARTBEAT) - .hasNoPayload() - // no configuration here as it has already been sent with the app-started event - .assertNextMessage(APP_INTEGRATIONS_CHANGE) - .hasPayload() - .integrations(singletonList(integration)) - .assertNextMessage(APP_DEPENDENCIES_LOADED) - .hasPayload() - .dependencies(singletonList(dependency)) - .assertNextMessage(GENERATE_METRICS) - .hasPayload() - .namespace("tracers") - .metrics(singletonList(metric)) - .assertNextMessage(DISTRIBUTIONS) - .hasPayload() - .namespace("tracers") - .distributionSeries(singletonList(distribution)) - .assertNextMessage(LOGS) - .hasPayload() - .logs(singletonList(logMessage)) - .assertNextMessage(APP_PRODUCT_CHANGE) - .hasPayload() - .productChange(productChange) - .assertNextMessage(APP_ENDPOINTS) - .hasPayload() - .endpoint(endpoint) - .assertNoMoreMessages(); - testHttpClient.assertNoMoreRequests(); - - // second iteration heartbeat only - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - testHttpClient.assertRequestBody(APP_HEARTBEAT).assertNoPayload(); - testHttpClient.assertNoMoreRequests(); - - // third iteration metrics data - telemetryService.addMetric(metric); - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - testHttpClient - .assertRequestBody(MESSAGE_BATCH) - .assertBatch(2) - .assertFirstMessage(APP_HEARTBEAT) - .hasNoPayload() - .assertNextMessage(GENERATE_METRICS) - .hasPayload() - .namespace("tracers") - .metrics(singletonList(metric)) - .assertNoMoreMessages(); - testHttpClient.assertNoMoreRequests(); - } - - @Test - void happyPathWithDataAfterAppStarted() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - // send messages - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - testHttpClient.assertRequestBody(APP_STARTED).assertPayload().products(); - testHttpClient.assertNoMoreRequests(); - - // add data after first iteration - telemetryService.addConfiguration(configuration); - telemetryService.addIntegration(integration); - telemetryService.addDependency(dependency); - telemetryService.addMetric(metric); - telemetryService.addDistributionSeries(distribution); - telemetryService.addLogMessage(logMessage); - telemetryService.addProductChange(productChange); - telemetryService.addEndpoint(endpoint); - - // send messages - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - testHttpClient - .assertRequestBody(MESSAGE_BATCH) - .assertBatch(9) - .assertFirstMessage(APP_HEARTBEAT) - .hasNoPayload() - .assertNextMessage(RequestType.APP_CLIENT_CONFIGURATION_CHANGE) - .hasPayload() - .configuration(singletonList(confKeyValue)) - .assertNextMessage(APP_INTEGRATIONS_CHANGE) - .hasPayload() - .integrations(singletonList(integration)) - .assertNextMessage(APP_DEPENDENCIES_LOADED) - .hasPayload() - .dependencies(singletonList(dependency)) - .assertNextMessage(GENERATE_METRICS) - .hasPayload() - .namespace("tracers") - .metrics(singletonList(metric)) - .assertNextMessage(DISTRIBUTIONS) - .hasPayload() - .namespace("tracers") - .distributionSeries(singletonList(distribution)) - .assertNextMessage(LOGS) - .hasPayload() - .logs(singletonList(logMessage)) - .assertNextMessage(APP_PRODUCT_CHANGE) - .hasPayload() - .productChange(productChange) - .assertNextMessage(APP_ENDPOINTS) - .hasPayload() - .endpoint(endpoint) - .assertNoMoreMessages(); - testHttpClient.assertNoMoreRequests(); - } - - @Test - void doNotDiscardDataForAppStartedEventUntilItHasBeenSuccessfullySent() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - telemetryService.addConfiguration(configuration); - - // attempt with 404 error - testHttpClient.expectRequest(NOT_FOUND); - assertFalse(telemetryService.sendAppStartedEvent()); - - // app-started is attempted - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products() - .configuration(singletonList(confKeyValue)); - testHttpClient.assertNoMoreRequests(); - - // attempt with 500 error - testHttpClient.expectRequest(FAILURE); - assertFalse(telemetryService.sendAppStartedEvent()); - - // app-started is attempted - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products() - .configuration(singletonList(confKeyValue)); - testHttpClient.assertNoMoreRequests(); - - // attempt with unexpected FAILURE (not valid) - testHttpClient.expectRequest(FAILURE); - assertFalse(telemetryService.sendAppStartedEvent()); - - // app-started is attempted - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products() - .configuration(singletonList(confKeyValue)); - testHttpClient.assertNoMoreRequests(); - - // attempt with success - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - // app-started is attempted - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products() - .configuration(singletonList(confKeyValue)); - testHttpClient.assertNoMoreRequests(); - } - - @Test - void resendDataOnSuccessfulAttemptAfterAFailure() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - telemetryService.addConfiguration(configuration); - telemetryService.addIntegration(integration); - telemetryService.addDependency(dependency); - telemetryService.addMetric(metric); - telemetryService.addDistributionSeries(distribution); - telemetryService.addLogMessage(logMessage); - telemetryService.addProductChange(productChange); - telemetryService.addEndpoint(endpoint); - - // attempt with NOT_FOUND error - testHttpClient.expectRequest(NOT_FOUND); - assertFalse(telemetryService.sendAppStartedEvent()); - - // app-started attempted with config - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products() - .configuration(singletonList(confKeyValue)); - testHttpClient.assertNoMoreRequests(); - - // successful app-started attempt - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - // attempt app-started with SUCCESS - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products() - .configuration(singletonList(confKeyValue)); - - // successful batch attempt - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - // attempt batch with SUCCESS - testHttpClient - .assertRequestBody(MESSAGE_BATCH) - .assertBatch(8) - .assertFirstMessage(APP_HEARTBEAT) - .hasNoPayload() - // no configuration here as it has already been sent with the app-started event - .assertNextMessage(APP_INTEGRATIONS_CHANGE) - .hasPayload() - .integrations(singletonList(integration)) - .assertNextMessage(APP_DEPENDENCIES_LOADED) - .hasPayload() - .dependencies(singletonList(dependency)) - .assertNextMessage(GENERATE_METRICS) - .hasPayload() - .namespace("tracers") - .metrics(singletonList(metric)) - .assertNextMessage(DISTRIBUTIONS) - .hasPayload() - .namespace("tracers") - .distributionSeries(singletonList(distribution)) - .assertNextMessage(LOGS) - .hasPayload() - .logs(singletonList(logMessage)) - .assertNextMessage(APP_PRODUCT_CHANGE) - .hasPayload() - .productChange(productChange) - .assertNextMessage(APP_ENDPOINTS) - .hasPayload() - .endpoint(endpoint) - .assertNoMoreMessages(); - testHttpClient.assertNoMoreRequests(); - - // attempt with NOT_FOUND error - testHttpClient.expectRequest(NOT_FOUND); - telemetryService.sendTelemetryEvents(); - - // message-batch attempted with heartbeat - testHttpClient.assertRequestBody(APP_HEARTBEAT).assertNoPayload(); - testHttpClient.assertNoMoreRequests(); - } - - @Test - void sendClosingEventRequest() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppClosingEvent(); - - testHttpClient.assertRequestBody(RequestType.APP_CLOSING); - testHttpClient.assertNoMoreRequests(); - } - - @TableTest({ - "scenario | otelEnabled | otEnabled | expectedWarnings", - "both otel and ot enabled | true | true | 1 ", - "only otel enabled | true | false | 0 ", - "only ot enabled | false | true | 0 ", - "neither otel nor ot enabled | false | false | 0 " - }) - void reportWhenBothOTelAndOTAreEnabled( - boolean otelEnabled, boolean otEnabled, int expectedWarnings) { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = spy(new TelemetryService(testHttpClient, 1000, false)); - Integration otel = new Integration("opentelemetry-1", otelEnabled); - Integration ot = new Integration("opentracing", otEnabled); - - telemetryService.addIntegration(otel); - - verify(telemetryService, times(0)).warnAboutExclusiveIntegrations(); - - telemetryService.addIntegration(ot); - - verify(telemetryService, times(expectedWarnings)).warnAboutExclusiveIntegrations(); - } - - @Test - void splitTelemetryRequestsIfTheSizeAboveTheLimit() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 5000, false); - - // send a heartbeat request without telemetry data to measure body size to set stable request - // size limit - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - // get body size - int bodySize = testHttpClient.assertRequestBody(APP_HEARTBEAT).bodySize(); - assertTrue(bodySize > 0); - - // sending first part of data - telemetryService = new TelemetryService(testHttpClient, bodySize + 512, false); - - telemetryService.addConfiguration(configuration); - telemetryService.addIntegration(integration); - telemetryService.addDependency(dependency); - telemetryService.addMetric(metric); - telemetryService.addDistributionSeries(distribution); - telemetryService.addLogMessage(logMessage); - telemetryService.addProductChange(productChange); - telemetryService.addEndpoint(endpoint); - - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendTelemetryEvents(); - - // attempt with SUCCESS - testHttpClient - .assertRequestBody(MESSAGE_BATCH) - .assertBatch(5) - .assertFirstMessage(APP_HEARTBEAT) - .hasNoPayload() - .assertNextMessage(RequestType.APP_CLIENT_CONFIGURATION_CHANGE) - .hasPayload() - .configuration(singletonList(confKeyValue)) - .assertNextMessage(APP_INTEGRATIONS_CHANGE) - .hasPayload() - .integrations(singletonList(integration)) - .assertNextMessage(APP_DEPENDENCIES_LOADED) - .hasPayload() - .dependencies(singletonList(dependency)) - .assertNextMessage(GENERATE_METRICS) - .hasPayload() - .namespace("tracers") - .metrics(singletonList(metric)) - // no more data fit this message is sent in the next message - .assertNoMoreMessages(); - - // sending second part of data - testHttpClient.expectRequest(SUCCESS); - assertFalse(telemetryService.sendTelemetryEvents()); - - testHttpClient - .assertRequestBody(MESSAGE_BATCH) - .assertBatch(5) - .assertFirstMessage(APP_HEARTBEAT) - .hasNoPayload() - .assertNextMessage(DISTRIBUTIONS) - .hasPayload() - .namespace("tracers") - .distributionSeries(singletonList(distribution)) - .assertNextMessage(LOGS) - .hasPayload() - .logs(singletonList(logMessage)) - .assertNextMessage(APP_PRODUCT_CHANGE) - .hasPayload() - .productChange(productChange) - .assertNextMessage(APP_ENDPOINTS) - .hasPayload() - .endpoint(endpoint) - .assertNoMoreMessages(); - testHttpClient.assertNoMoreRequests(); - } - - @Test - void sendAllCollectedDataWithExtendedHeartbeatRequestEveryTime() throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - telemetryService.addConfiguration(configuration); - telemetryService.addIntegration(integration); - telemetryService.addDependency(dependency); - - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendExtendedHeartbeat(); - - testHttpClient - .assertRequestBody(APP_EXTENDED_HEARTBEAT) - .assertPayload() - .configuration(singletonList(confKeyValue)) - .integrations(singletonList(integration)) - .dependencies(singletonList(dependency)); - testHttpClient.assertNoMoreRequests(); - - telemetryService.addConfiguration(configuration); - telemetryService.addIntegration(integration); - telemetryService.addDependency(dependency); - - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendExtendedHeartbeat(); - - testHttpClient - .assertRequestBody(APP_EXTENDED_HEARTBEAT) - .assertPayload() - .configuration(Arrays.asList(confKeyValue, confKeyValue)) - .integrations(Arrays.asList(integration, integration)) - .dependencies(Arrays.asList(dependency, dependency)); - testHttpClient.assertNoMoreRequests(); - } - - @TableTest({ - "scenario | resultCode", - "success | SUCCESS ", - "failure | FAILURE ", - "notFound | NOT_FOUND " - }) - void - sendExtendedHeartbeatRequestEvenIfDataAlreadyHasBeenSentOrAttemptedAsPartOfAnotherTelemetryEvents( - String resultCode) throws IOException { - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - telemetryService.addConfiguration(configuration); - telemetryService.addIntegration(integration); - telemetryService.addDependency(dependency); - - testHttpClient.expectRequest(TelemetryClient.Result.valueOf(resultCode)); - telemetryService.sendTelemetryEvents(); - - testHttpClient.assertRequestBody(MESSAGE_BATCH); - - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendExtendedHeartbeat(); - - testHttpClient - .assertRequestBody(APP_EXTENDED_HEARTBEAT) - .assertPayload() - .configuration(singletonList(confKeyValue)) - .integrations(singletonList(integration)) - .dependencies(singletonList(dependency)); - testHttpClient.assertNoMoreRequests(); - } - - @TableTest({ - "scenario | id ", - "with value | foo", - "null value | ", - "empty value | '' " - }) - void appCanPropagateConfigurationId(String id) throws IOException { - String instrumentationConfigIdKey = "instrumentation_config_id"; - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - Map configMap = - singletonMap( - instrumentationConfigIdKey, - ConfigSetting.of(instrumentationConfigIdKey, id, ConfigOrigin.ENV)); - telemetryService.addConfiguration(singletonMap(ConfigOrigin.ENV, configMap)); - - // first iteration - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - // app-started - testHttpClient.assertRequestBody(APP_STARTED).assertPayload().instrumentationConfigId(id); - testHttpClient.assertNoMoreRequests(); - } - - @TableTest({ - "scenario | installId | installType | installTime", - "no install data | | | ", - "install time only | | | 1703188334 ", - "install type only | | k8s_single_step | ", - "install type and time | | k8s_single_step | 1703188212 ", - "install id only | 68e75c99-57ca-4a12-adfc-575c4b05fcbe | | ", - "install id and time | 68e75c48-57ca-4a12-adfc-575c4b05bfff | | 1704183412 ", - "install id and type | 68e75c55-57ca-4a12-adfc-575c4b05aaaa | k8s_single_step | ", - "install id, type and time | 68e75c77-57ca-4a12-adfc-575c4b05fc44 | k8s_single_step | 1993188215 " - }) - void appStartedMustHaveInstallSignature(String installId, String installType, String installTime) - throws IOException { - WithConfigExtension.injectEnvConfig("INSTRUMENTATION_INSTALL_ID", installId); - WithConfigExtension.injectEnvConfig("INSTRUMENTATION_INSTALL_TYPE", installType); - WithConfigExtension.injectEnvConfig("INSTRUMENTATION_INSTALL_TIME", installTime); - - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - // first iteration - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - // app-started - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .installSignature(installId, installType, installTime); - testHttpClient.assertNoMoreRequests(); - } - - @TableTest({ - "scenario | appsecConfig | appsecEnabled | profilingConfig | profilingEnabled | dynInstrConfig | dynInstrEnabled", - "all products enabled | 1 | true | 1 | true | 1 | true ", - "dynamic instrumentation disabled | 1 | true | 1 | true | 0 | false ", - "profiling disabled | 1 | true | 0 | false | 1 | true ", - "profiling and dyn instr disabled | 1 | true | 0 | false | 0 | false ", - "all products disabled | 0 | false | 0 | false | 0 | false ", - "appsec inactive value treated enabled | inactive | true | 0 | false | 0 | false " - }) - void appStartedMustIncludeActivatedProductsInfo( - String appsecConfig, - boolean appsecEnabled, - String profilingConfig, - boolean profilingEnabled, - String dynInstrConfig, - boolean dynInstrEnabled) - throws IOException { - WithConfigExtension.injectEnvConfig( - ConfigStrings.toEnvVar(AppSecConfig.APPSEC_ENABLED), appsecConfig); - WithConfigExtension.injectEnvConfig( - ConfigStrings.toEnvVar(ProfilingConfig.PROFILING_ENABLED), profilingConfig); - WithConfigExtension.injectEnvConfig( - ConfigStrings.toEnvVar(DebuggerConfig.DYNAMIC_INSTRUMENTATION_ENABLED), dynInstrConfig); - - TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); - TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); - - // first iteration - testHttpClient.expectRequest(SUCCESS); - telemetryService.sendAppStartedEvent(); - - // app-started - testHttpClient - .assertRequestBody(APP_STARTED) - .assertPayload() - .products(appsecEnabled, profilingEnabled, dynInstrEnabled); - testHttpClient.assertNoMoreRequests(); - } + private final ConfigOrigin confKeyOrigin = ConfigOrigin.DEFAULT; + private final ConfigSetting confKeyValue = ConfigSetting.of("confkey", "confvalue", confKeyOrigin); + private final Map> configuration = + singletonMap(confKeyOrigin, singletonMap("confkey", confKeyValue)); + private final Integration integration = new Integration("integration", true); + private final Dependency dependency = new Dependency("dependency", "1.0.0", "src", "hash"); + private final Metric metric = new Metric() + .namespace("tracers") + .metric("metric") + .points(Collections.>singletonList(Arrays.asList(1, 2))) + .tags(Arrays.asList("tag1", "tag2")); + private final DistributionSeries distribution = new DistributionSeries() + .namespace("tracers") + .metric("distro") + .points(Arrays.asList(1, 2, 3)) + .tags(Arrays.asList("tag1", "tag2")) + .common(false); + private final LogMessage logMessage = new LogMessage() + .message("log-message") + .tags("tag1:tag2") + .level(LogMessageLevel.DEBUG) + .stackTrace("stack-trace") + .tracerTime(32423L) + .count(1); + private final ProductChange productChange = + new ProductChange().productType(ProductChange.ProductType.APPSEC).enabled(true); + private final Endpoint endpoint = new Endpoint() + .first(true) + .type("REST") + .method("GET") + .operation("http.request") + .resource("GET /test") + .path("/test") + .requestBodyType(singletonList("application/json")) + .responseBodyType(singletonList("application/json")) + .responseCode(singletonList(200)) + .authentication(singletonList("JWT")); + + @Test + void happyPathWithoutData() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + // first iteration + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + // app-started + testHttpClient.assertRequestBody(APP_STARTED).assertPayload().products(); + testHttpClient.assertNoMoreRequests(); + + // second iteration + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + // app-heartbeat only + testHttpClient.assertRequestBody(APP_HEARTBEAT); + testHttpClient.assertNoMoreRequests(); + + // third iteration + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + // app-heartbeat only + testHttpClient.assertRequestBody(APP_HEARTBEAT); + testHttpClient.assertNoMoreRequests(); + } + + @Test + void happyPathWithData() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + // add data before first iteration + telemetryService.addConfiguration(configuration); + telemetryService.addIntegration(integration); + telemetryService.addDependency(dependency); + telemetryService.addMetric(metric); + telemetryService.addDistributionSeries(distribution); + telemetryService.addLogMessage(logMessage); + telemetryService.addProductChange(productChange); + telemetryService.addEndpoint(endpoint); + + // send messages + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products() + .configuration(singletonList(confKeyValue)); + + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + testHttpClient + .assertRequestBody(MESSAGE_BATCH) + .assertBatch(8) + .assertFirstMessage(APP_HEARTBEAT) + .hasNoPayload() + // no configuration here as it has already been sent with the app-started event + .assertNextMessage(APP_INTEGRATIONS_CHANGE) + .hasPayload() + .integrations(singletonList(integration)) + .assertNextMessage(APP_DEPENDENCIES_LOADED) + .hasPayload() + .dependencies(singletonList(dependency)) + .assertNextMessage(GENERATE_METRICS) + .hasPayload() + .namespace("tracers") + .metrics(singletonList(metric)) + .assertNextMessage(DISTRIBUTIONS) + .hasPayload() + .namespace("tracers") + .distributionSeries(singletonList(distribution)) + .assertNextMessage(LOGS) + .hasPayload() + .logs(singletonList(logMessage)) + .assertNextMessage(APP_PRODUCT_CHANGE) + .hasPayload() + .productChange(productChange) + .assertNextMessage(APP_ENDPOINTS) + .hasPayload() + .endpoint(endpoint) + .assertNoMoreMessages(); + testHttpClient.assertNoMoreRequests(); + + // second iteration heartbeat only + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + testHttpClient.assertRequestBody(APP_HEARTBEAT).assertNoPayload(); + testHttpClient.assertNoMoreRequests(); + + // third iteration metrics data + telemetryService.addMetric(metric); + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + testHttpClient + .assertRequestBody(MESSAGE_BATCH) + .assertBatch(2) + .assertFirstMessage(APP_HEARTBEAT) + .hasNoPayload() + .assertNextMessage(GENERATE_METRICS) + .hasPayload() + .namespace("tracers") + .metrics(singletonList(metric)) + .assertNoMoreMessages(); + testHttpClient.assertNoMoreRequests(); + } + + @Test + void happyPathWithDataAfterAppStarted() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + // send messages + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + testHttpClient.assertRequestBody(APP_STARTED).assertPayload().products(); + testHttpClient.assertNoMoreRequests(); + + // add data after first iteration + telemetryService.addConfiguration(configuration); + telemetryService.addIntegration(integration); + telemetryService.addDependency(dependency); + telemetryService.addMetric(metric); + telemetryService.addDistributionSeries(distribution); + telemetryService.addLogMessage(logMessage); + telemetryService.addProductChange(productChange); + telemetryService.addEndpoint(endpoint); + + // send messages + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + testHttpClient + .assertRequestBody(MESSAGE_BATCH) + .assertBatch(9) + .assertFirstMessage(APP_HEARTBEAT) + .hasNoPayload() + .assertNextMessage(RequestType.APP_CLIENT_CONFIGURATION_CHANGE) + .hasPayload() + .configuration(singletonList(confKeyValue)) + .assertNextMessage(APP_INTEGRATIONS_CHANGE) + .hasPayload() + .integrations(singletonList(integration)) + .assertNextMessage(APP_DEPENDENCIES_LOADED) + .hasPayload() + .dependencies(singletonList(dependency)) + .assertNextMessage(GENERATE_METRICS) + .hasPayload() + .namespace("tracers") + .metrics(singletonList(metric)) + .assertNextMessage(DISTRIBUTIONS) + .hasPayload() + .namespace("tracers") + .distributionSeries(singletonList(distribution)) + .assertNextMessage(LOGS) + .hasPayload() + .logs(singletonList(logMessage)) + .assertNextMessage(APP_PRODUCT_CHANGE) + .hasPayload() + .productChange(productChange) + .assertNextMessage(APP_ENDPOINTS) + .hasPayload() + .endpoint(endpoint) + .assertNoMoreMessages(); + testHttpClient.assertNoMoreRequests(); + } + + @Test + void doNotDiscardDataForAppStartedEventUntilItHasBeenSuccessfullySent() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + telemetryService.addConfiguration(configuration); + + // attempt with 404 error + testHttpClient.expectRequest(NOT_FOUND); + assertFalse(telemetryService.sendAppStartedEvent()); + + // app-started is attempted + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products() + .configuration(singletonList(confKeyValue)); + testHttpClient.assertNoMoreRequests(); + + // attempt with 500 error + testHttpClient.expectRequest(FAILURE); + assertFalse(telemetryService.sendAppStartedEvent()); + + // app-started is attempted + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products() + .configuration(singletonList(confKeyValue)); + testHttpClient.assertNoMoreRequests(); + + // attempt with unexpected FAILURE (not valid) + testHttpClient.expectRequest(FAILURE); + assertFalse(telemetryService.sendAppStartedEvent()); + + // app-started is attempted + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products() + .configuration(singletonList(confKeyValue)); + testHttpClient.assertNoMoreRequests(); + + // attempt with success + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + // app-started is attempted + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products() + .configuration(singletonList(confKeyValue)); + testHttpClient.assertNoMoreRequests(); + } + + @Test + void resendDataOnSuccessfulAttemptAfterAFailure() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + telemetryService.addConfiguration(configuration); + telemetryService.addIntegration(integration); + telemetryService.addDependency(dependency); + telemetryService.addMetric(metric); + telemetryService.addDistributionSeries(distribution); + telemetryService.addLogMessage(logMessage); + telemetryService.addProductChange(productChange); + telemetryService.addEndpoint(endpoint); + + // attempt with NOT_FOUND error + testHttpClient.expectRequest(NOT_FOUND); + assertFalse(telemetryService.sendAppStartedEvent()); + + // app-started attempted with config + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products() + .configuration(singletonList(confKeyValue)); + testHttpClient.assertNoMoreRequests(); + + // successful app-started attempt + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + // attempt app-started with SUCCESS + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products() + .configuration(singletonList(confKeyValue)); + + // successful batch attempt + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + // attempt batch with SUCCESS + testHttpClient + .assertRequestBody(MESSAGE_BATCH) + .assertBatch(8) + .assertFirstMessage(APP_HEARTBEAT) + .hasNoPayload() + // no configuration here as it has already been sent with the app-started event + .assertNextMessage(APP_INTEGRATIONS_CHANGE) + .hasPayload() + .integrations(singletonList(integration)) + .assertNextMessage(APP_DEPENDENCIES_LOADED) + .hasPayload() + .dependencies(singletonList(dependency)) + .assertNextMessage(GENERATE_METRICS) + .hasPayload() + .namespace("tracers") + .metrics(singletonList(metric)) + .assertNextMessage(DISTRIBUTIONS) + .hasPayload() + .namespace("tracers") + .distributionSeries(singletonList(distribution)) + .assertNextMessage(LOGS) + .hasPayload() + .logs(singletonList(logMessage)) + .assertNextMessage(APP_PRODUCT_CHANGE) + .hasPayload() + .productChange(productChange) + .assertNextMessage(APP_ENDPOINTS) + .hasPayload() + .endpoint(endpoint) + .assertNoMoreMessages(); + testHttpClient.assertNoMoreRequests(); + + // attempt with NOT_FOUND error + testHttpClient.expectRequest(NOT_FOUND); + telemetryService.sendTelemetryEvents(); + + // message-batch attempted with heartbeat + testHttpClient.assertRequestBody(APP_HEARTBEAT).assertNoPayload(); + testHttpClient.assertNoMoreRequests(); + } + + @Test + void sendClosingEventRequest() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppClosingEvent(); + + testHttpClient.assertRequestBody(RequestType.APP_CLOSING); + testHttpClient.assertNoMoreRequests(); + } + + @TableTest({ + "scenario | otelEnabled | otEnabled | expectedWarnings", + "both otel and ot enabled | true | true | 1 ", + "only otel enabled | true | false | 0 ", + "only ot enabled | false | true | 0 ", + "neither otel nor ot enabled | false | false | 0 " + }) + void reportWhenBothOTelAndOTAreEnabled(boolean otelEnabled, boolean otEnabled, int expectedWarnings) { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = spy(new TelemetryService(testHttpClient, 1000, false)); + Integration otel = new Integration("opentelemetry-1", otelEnabled); + Integration ot = new Integration("opentracing", otEnabled); + + telemetryService.addIntegration(otel); + + verify(telemetryService, times(0)).warnAboutExclusiveIntegrations(); + + telemetryService.addIntegration(ot); + + verify(telemetryService, times(expectedWarnings)).warnAboutExclusiveIntegrations(); + } + + @Test + void splitTelemetryRequestsIfTheSizeAboveTheLimit() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 5000, false); + + // send a heartbeat request without telemetry data to measure body size to set stable request + // size limit + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + // get body size + int bodySize = testHttpClient.assertRequestBody(APP_HEARTBEAT).bodySize(); + assertTrue(bodySize > 0); + + // sending first part of data + telemetryService = new TelemetryService(testHttpClient, bodySize + 512, false); + + telemetryService.addConfiguration(configuration); + telemetryService.addIntegration(integration); + telemetryService.addDependency(dependency); + telemetryService.addMetric(metric); + telemetryService.addDistributionSeries(distribution); + telemetryService.addLogMessage(logMessage); + telemetryService.addProductChange(productChange); + telemetryService.addEndpoint(endpoint); + + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendTelemetryEvents(); + + // attempt with SUCCESS + testHttpClient + .assertRequestBody(MESSAGE_BATCH) + .assertBatch(5) + .assertFirstMessage(APP_HEARTBEAT) + .hasNoPayload() + .assertNextMessage(RequestType.APP_CLIENT_CONFIGURATION_CHANGE) + .hasPayload() + .configuration(singletonList(confKeyValue)) + .assertNextMessage(APP_INTEGRATIONS_CHANGE) + .hasPayload() + .integrations(singletonList(integration)) + .assertNextMessage(APP_DEPENDENCIES_LOADED) + .hasPayload() + .dependencies(singletonList(dependency)) + .assertNextMessage(GENERATE_METRICS) + .hasPayload() + .namespace("tracers") + .metrics(singletonList(metric)) + // no more data fit this message is sent in the next message + .assertNoMoreMessages(); + + // sending second part of data + testHttpClient.expectRequest(SUCCESS); + assertFalse(telemetryService.sendTelemetryEvents()); + + testHttpClient + .assertRequestBody(MESSAGE_BATCH) + .assertBatch(5) + .assertFirstMessage(APP_HEARTBEAT) + .hasNoPayload() + .assertNextMessage(DISTRIBUTIONS) + .hasPayload() + .namespace("tracers") + .distributionSeries(singletonList(distribution)) + .assertNextMessage(LOGS) + .hasPayload() + .logs(singletonList(logMessage)) + .assertNextMessage(APP_PRODUCT_CHANGE) + .hasPayload() + .productChange(productChange) + .assertNextMessage(APP_ENDPOINTS) + .hasPayload() + .endpoint(endpoint) + .assertNoMoreMessages(); + testHttpClient.assertNoMoreRequests(); + } + + @Test + void sendAllCollectedDataWithExtendedHeartbeatRequestEveryTime() throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + telemetryService.addConfiguration(configuration); + telemetryService.addIntegration(integration); + telemetryService.addDependency(dependency); + + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendExtendedHeartbeat(); + + testHttpClient + .assertRequestBody(APP_EXTENDED_HEARTBEAT) + .assertPayload() + .configuration(singletonList(confKeyValue)) + .integrations(singletonList(integration)) + .dependencies(singletonList(dependency)); + testHttpClient.assertNoMoreRequests(); + + telemetryService.addConfiguration(configuration); + telemetryService.addIntegration(integration); + telemetryService.addDependency(dependency); + + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendExtendedHeartbeat(); + + testHttpClient + .assertRequestBody(APP_EXTENDED_HEARTBEAT) + .assertPayload() + .configuration(Arrays.asList(confKeyValue, confKeyValue)) + .integrations(Arrays.asList(integration, integration)) + .dependencies(Arrays.asList(dependency, dependency)); + testHttpClient.assertNoMoreRequests(); + } + + @TableTest({ + "scenario | resultCode", + "success | SUCCESS ", + "failure | FAILURE ", + "notFound | NOT_FOUND " + }) + void sendExtendedHeartbeatRequestEvenIfDataAlreadyHasBeenSentOrAttemptedAsPartOfAnotherTelemetryEvents( + String resultCode) throws IOException { + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + telemetryService.addConfiguration(configuration); + telemetryService.addIntegration(integration); + telemetryService.addDependency(dependency); + + testHttpClient.expectRequest(TelemetryClient.Result.valueOf(resultCode)); + telemetryService.sendTelemetryEvents(); + + testHttpClient.assertRequestBody(MESSAGE_BATCH); + + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendExtendedHeartbeat(); + + testHttpClient + .assertRequestBody(APP_EXTENDED_HEARTBEAT) + .assertPayload() + .configuration(singletonList(confKeyValue)) + .integrations(singletonList(integration)) + .dependencies(singletonList(dependency)); + testHttpClient.assertNoMoreRequests(); + } + + @TableTest({ + "scenario | id ", + "with value | foo", + "null value | ", + "empty value | '' " + }) + void appCanPropagateConfigurationId(String id) throws IOException { + String instrumentationConfigIdKey = "instrumentation_config_id"; + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + Map configMap = singletonMap( + instrumentationConfigIdKey, ConfigSetting.of(instrumentationConfigIdKey, id, ConfigOrigin.ENV)); + telemetryService.addConfiguration(singletonMap(ConfigOrigin.ENV, configMap)); + + // first iteration + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + // app-started + testHttpClient.assertRequestBody(APP_STARTED).assertPayload().instrumentationConfigId(id); + testHttpClient.assertNoMoreRequests(); + } + + @TableTest({ + "scenario | installId | installType | installTime", + "no install data | | | ", + "install time only | | | 1703188334 ", + "install type only | | k8s_single_step | ", + "install type and time | | k8s_single_step | 1703188212 ", + "install id only | 68e75c99-57ca-4a12-adfc-575c4b05fcbe | | ", + "install id and time | 68e75c48-57ca-4a12-adfc-575c4b05bfff | | 1704183412 ", + "install id and type | 68e75c55-57ca-4a12-adfc-575c4b05aaaa | k8s_single_step | ", + "install id, type and time | 68e75c77-57ca-4a12-adfc-575c4b05fc44 | k8s_single_step | 1993188215 " + }) + void appStartedMustHaveInstallSignature(String installId, String installType, String installTime) + throws IOException { + WithConfigExtension.injectEnvConfig("INSTRUMENTATION_INSTALL_ID", installId); + WithConfigExtension.injectEnvConfig("INSTRUMENTATION_INSTALL_TYPE", installType); + WithConfigExtension.injectEnvConfig("INSTRUMENTATION_INSTALL_TIME", installTime); + + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + // first iteration + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + // app-started + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .installSignature(installId, installType, installTime); + testHttpClient.assertNoMoreRequests(); + } + + @TableTest({ + "scenario | appsecConfig | appsecEnabled | profilingConfig | profilingEnabled | dynInstrConfig | dynInstrEnabled", + "all products enabled | 1 | true | 1 | true | 1 | true ", + "dynamic instrumentation disabled | 1 | true | 1 | true | 0 | false ", + "profiling disabled | 1 | true | 0 | false | 1 | true ", + "profiling and dyn instr disabled | 1 | true | 0 | false | 0 | false ", + "all products disabled | 0 | false | 0 | false | 0 | false ", + "appsec inactive value treated enabled | inactive | true | 0 | false | 0 | false " + }) + void appStartedMustIncludeActivatedProductsInfo( + String appsecConfig, + boolean appsecEnabled, + String profilingConfig, + boolean profilingEnabled, + String dynInstrConfig, + boolean dynInstrEnabled) + throws IOException { + WithConfigExtension.injectEnvConfig(ConfigStrings.toEnvVar(AppSecConfig.APPSEC_ENABLED), appsecConfig); + WithConfigExtension.injectEnvConfig(ConfigStrings.toEnvVar(ProfilingConfig.PROFILING_ENABLED), profilingConfig); + WithConfigExtension.injectEnvConfig( + ConfigStrings.toEnvVar(DebuggerConfig.DYNAMIC_INSTRUMENTATION_ENABLED), dynInstrConfig); + + TestTelemetryRouter testHttpClient = new TestTelemetryRouter(); + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false); + + // first iteration + testHttpClient.expectRequest(SUCCESS); + telemetryService.sendAppStartedEvent(); + + // app-started + testHttpClient + .assertRequestBody(APP_STARTED) + .assertPayload() + .products(appsecEnabled, profilingEnabled, dynInstrEnabled); + testHttpClient.assertNoMoreRequests(); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/TelemetrySystemTest.java b/telemetry/src/test/java/datadog/telemetry/TelemetrySystemTest.java index 76c57f9d842..f443147fba4 100644 --- a/telemetry/src/test/java/datadog/telemetry/TelemetrySystemTest.java +++ b/telemetry/src/test/java/datadog/telemetry/TelemetrySystemTest.java @@ -23,63 +23,61 @@ class TelemetrySystemTest { - @AfterEach - void cleanup() { - TelemetrySystem.stop(); - } + @AfterEach + void cleanup() { + TelemetrySystem.stop(); + } - @Test - void installsDependenciesTransformer() { - Instrumentation instrumentation = mock(Instrumentation.class); + @Test + void installsDependenciesTransformer() { + Instrumentation instrumentation = mock(Instrumentation.class); - DependencyService dependencyService = TelemetrySystem.createDependencyService(instrumentation); - try { - ArgumentCaptor transformerCaptor = - ArgumentCaptor.forClass(ClassFileTransformer.class); - verify(instrumentation, times(1)).addTransformer(transformerCaptor.capture()); - assertEquals( - "datadog.telemetry.dependency.LocationsCollectingTransformer", - transformerCaptor.getValue().getClass().getName()); - } finally { - dependencyService.stop(); + DependencyService dependencyService = TelemetrySystem.createDependencyService(instrumentation); + try { + ArgumentCaptor transformerCaptor = + ArgumentCaptor.forClass(ClassFileTransformer.class); + verify(instrumentation, times(1)).addTransformer(transformerCaptor.capture()); + assertEquals( + "datadog.telemetry.dependency.LocationsCollectingTransformer", + transformerCaptor.getValue().getClass().getName()); + } finally { + dependencyService.stop(); + } } - } - @Test - void createTelemetryThread() { - TelemetryService telemetryService = mock(TelemetryService.class); - DependencyService dependencyService = mock(DependencyService.class); + @Test + void createTelemetryThread() { + TelemetryService telemetryService = mock(TelemetryService.class); + DependencyService dependencyService = mock(DependencyService.class); - Thread thread = - TelemetrySystem.createTelemetryRunnable(telemetryService, dependencyService, true); + Thread thread = TelemetrySystem.createTelemetryRunnable(telemetryService, dependencyService, true); - assertNotNull(thread); - } + assertNotNull(thread); + } - @Test - @WithConfig(key = GeneralConfig.SITE, value = "datad0g.com") - @WithConfig(key = GeneralConfig.API_KEY, value = "api-key") - void startStopTelemetrySystem() { - Instrumentation instrumentation = mock(Instrumentation.class); + @Test + @WithConfig(key = GeneralConfig.SITE, value = "datad0g.com") + @WithConfig(key = GeneralConfig.API_KEY, value = "api-key") + void startStopTelemetrySystem() { + Instrumentation instrumentation = mock(Instrumentation.class); - TelemetrySystem.startTelemetry(instrumentation, sharedCommunicationObjects()); + TelemetrySystem.startTelemetry(instrumentation, sharedCommunicationObjects()); - assertNotNull(TelemetrySystem.getTelemetryThread()); + assertNotNull(TelemetrySystem.getTelemetryThread()); - TelemetrySystem.stop(); + TelemetrySystem.stop(); - assertTrue( - TelemetrySystem.getTelemetryThread() == null - || TelemetrySystem.getTelemetryThread().isInterrupted() - || !TelemetrySystem.getTelemetryThread().isAlive()); - } + assertTrue(TelemetrySystem.getTelemetryThread() == null + || TelemetrySystem.getTelemetryThread().isInterrupted() + || !TelemetrySystem.getTelemetryThread().isAlive()); + } - private SharedCommunicationObjects sharedCommunicationObjects() { - SharedCommunicationObjects sco = new SharedCommunicationObjects(); - sco.agentHttpClient = mock(OkHttpClient.class); - sco.monitoring = mock(Monitoring.class); - sco.agentUrl = HttpUrl.get("https://example.com"); - sco.setFeaturesDiscovery(mock(DDAgentFeaturesDiscovery.class)); - return sco; - } + private SharedCommunicationObjects sharedCommunicationObjects() { + SharedCommunicationObjects sco = new SharedCommunicationObjects(); + sco.agentHttpClient = mock(OkHttpClient.class); + sco.monitoring = mock(Monitoring.class); + sco.agentUrl = HttpUrl.get("https://example.com"); + sco.setFeaturesDiscovery(mock(DDAgentFeaturesDiscovery.class)); + return sco; + } } diff --git a/telemetry/src/test/java/datadog/telemetry/TestTelemetryRouter.java b/telemetry/src/test/java/datadog/telemetry/TestTelemetryRouter.java index 5038614045d..0824821de07 100644 --- a/telemetry/src/test/java/datadog/telemetry/TestTelemetryRouter.java +++ b/telemetry/src/test/java/datadog/telemetry/TestTelemetryRouter.java @@ -32,482 +32,470 @@ import okio.Buffer; class TestTelemetryRouter extends TelemetryRouter { - private final Queue mockResults = new LinkedList<>(); - private final Queue requests = new LinkedList<>(); - - TestTelemetryRouter() { - super(null, null, null, false); - } - - @Override - public TelemetryClient.Result sendRequest(TelemetryRequest request) { - if (mockResults.isEmpty()) { - throw new IllegalStateException( - "Unexpected request has been sent. State expectations with `expectRequests` prior sending requests."); - } + private final Queue mockResults = new LinkedList<>(); + private final Queue requests = new LinkedList<>(); - Request.Builder requestBuilder = request.httpRequest(); - requestBuilder.url("https://example.com"); - requests.add(new RequestAssertions(requestBuilder.build())); - return mockResults.poll(); - } + TestTelemetryRouter() { + super(null, null, null, false); + } - void expectRequest(TelemetryClient.Result mockResult) { - expectRequests(1, mockResult); - } + @Override + public TelemetryClient.Result sendRequest(TelemetryRequest request) { + if (mockResults.isEmpty()) { + throw new IllegalStateException( + "Unexpected request has been sent. State expectations with `expectRequests` prior sending requests."); + } - void expectRequests(int requestNumber, TelemetryClient.Result mockResult) { - for (int i = 0; i < requestNumber; i++) { - mockResults.add(mockResult); + Request.Builder requestBuilder = request.httpRequest(); + requestBuilder.url("https://example.com"); + requests.add(new RequestAssertions(requestBuilder.build())); + return mockResults.poll(); } - } - RequestAssertions assertRequest() { - if (!mockResults.isEmpty()) { - throw new IllegalStateException("Expected " + mockResults.size() + " more sendRequest calls"); + void expectRequest(TelemetryClient.Result mockResult) { + expectRequests(1, mockResult); } - if (requests.isEmpty()) { - throw new IllegalStateException("No more requests have been sent."); - } - return requests.poll(); - } - - BodyAssertions assertRequestBody(RequestType requestType) throws IOException { - return assertRequest().headers(requestType).assertBody().commonParts(requestType); - } - void assertNoMoreRequests() { - if (!mockResults.isEmpty()) { - throw new IllegalStateException("Still expect " + mockResults.size() + " request(s)"); - } - if (!requests.isEmpty()) { - throw new IllegalStateException( - "Still have " + requests.size() + " requests when none expected."); + void expectRequests(int requestNumber, TelemetryClient.Result mockResult) { + for (int i = 0; i < requestNumber; i++) { + mockResults.add(mockResult); + } } - } - private static List toDoubles(List numbers) { - if (numbers == null) { - return null; - } - List doubles = new ArrayList<>(numbers.size()); - for (Number number : numbers) { - doubles.add(number.doubleValue()); + RequestAssertions assertRequest() { + if (!mockResults.isEmpty()) { + throw new IllegalStateException("Expected " + mockResults.size() + " more sendRequest calls"); + } + if (requests.isEmpty()) { + throw new IllegalStateException("No more requests have been sent."); + } + return requests.poll(); } - return doubles; - } - - static class RequestAssertions { - private final Request request; - RequestAssertions(Request request) { - this.request = request; + BodyAssertions assertRequestBody(RequestType requestType) throws IOException { + return assertRequest().headers(requestType).assertBody().commonParts(requestType); } - RequestAssertions headers(RequestType requestType) { - assertEquals("POST", request.method()); - assertTrue( - request - .headers() - .names() - .containsAll( - Arrays.asList( - "Content-Type", - "Content-Length", - "DD-Client-Library-Language", - "DD-Client-Library-Version", - "DD-Telemetry-API-Version", - "DD-Telemetry-Request-Type", - "DD-Session-ID"))); - assertEquals("application/json; charset=utf-8", request.header("Content-Type")); - assertTrue(Integer.parseInt(request.header("Content-Length")) > 0); - assertEquals("jvm", request.header("DD-Client-Library-Language")); - assertEquals(TracerVersion.TRACER_VERSION, request.header("DD-Client-Library-Version")); - assertEquals("v2", request.header("DD-Telemetry-API-Version")); - assertEquals(requestType.toString(), request.header("DD-Telemetry-Request-Type")); - String entityId = request.header("Datadog-Entity-ID"); - assertTrue(entityId == null || entityId.startsWith("in-") || entityId.startsWith("cin-")); - String sessionId = request.header("DD-Session-ID"); - assertTrue( - sessionId != null && sessionId.matches("[\\da-f]{8}-([\\da-f]{4}-){3}[\\da-f]{12}")); - assertEquals(Config.get().getRuntimeId(), sessionId); - // DD-Root-Session-ID should only be present when inherited from a parent process - // (i.e., when rootSessionId != runtimeId). In normal test context, they're equal. - String rootSessionId = request.header("DD-Root-Session-ID"); - if (Config.get().getRootSessionId().equals(Config.get().getRuntimeId())) { - assertNull(rootSessionId); - } else { - assertEquals(Config.get().getRootSessionId(), rootSessionId); - } - return this; + void assertNoMoreRequests() { + if (!mockResults.isEmpty()) { + throw new IllegalStateException("Still expect " + mockResults.size() + " request(s)"); + } + if (!requests.isEmpty()) { + throw new IllegalStateException("Still have " + requests.size() + " requests when none expected."); + } } - @SuppressWarnings("unchecked") - BodyAssertions assertBody() throws IOException { - Buffer buf = new Buffer(); - request.body().writeTo(buf); - byte[] bytes = new byte[(int) buf.size()]; - buf.read(bytes); - Map parsed = - (Map) - new Moshi.Builder() - .build() - .adapter(Types.newParameterizedType(Map.class, String.class, Object.class)) - .fromJson(new String(bytes, StandardCharsets.UTF_8)); - return new BodyAssertions(parsed, bytes); + private static List toDoubles(List numbers) { + if (numbers == null) { + return null; + } + List doubles = new ArrayList<>(numbers.size()); + for (Number number : numbers) { + doubles.add(number.doubleValue()); + } + return doubles; } - } - static class BodyAssertions { - private final Map body; - private final byte[] bodyBytes; + static class RequestAssertions { + private final Request request; - BodyAssertions(Map body, byte[] bodyBytes) { - this.body = body; - this.bodyBytes = bodyBytes; - } + RequestAssertions(Request request) { + this.request = request; + } - int bodySize() { - return bodyBytes.length; - } + RequestAssertions headers(RequestType requestType) { + assertEquals("POST", request.method()); + assertTrue(request.headers() + .names() + .containsAll(Arrays.asList( + "Content-Type", + "Content-Length", + "DD-Client-Library-Language", + "DD-Client-Library-Version", + "DD-Telemetry-API-Version", + "DD-Telemetry-Request-Type", + "DD-Session-ID"))); + assertEquals("application/json; charset=utf-8", request.header("Content-Type")); + assertTrue(Integer.parseInt(request.header("Content-Length")) > 0); + assertEquals("jvm", request.header("DD-Client-Library-Language")); + assertEquals(TracerVersion.TRACER_VERSION, request.header("DD-Client-Library-Version")); + assertEquals("v2", request.header("DD-Telemetry-API-Version")); + assertEquals(requestType.toString(), request.header("DD-Telemetry-Request-Type")); + String entityId = request.header("Datadog-Entity-ID"); + assertTrue(entityId == null || entityId.startsWith("in-") || entityId.startsWith("cin-")); + String sessionId = request.header("DD-Session-ID"); + assertTrue(sessionId != null && sessionId.matches("[\\da-f]{8}-([\\da-f]{4}-){3}[\\da-f]{12}")); + assertEquals(Config.get().getRuntimeId(), sessionId); + // DD-Root-Session-ID should only be present when inherited from a parent process + // (i.e., when rootSessionId != runtimeId). In normal test context, they're equal. + String rootSessionId = request.header("DD-Root-Session-ID"); + if (Config.get().getRootSessionId().equals(Config.get().getRuntimeId())) { + assertNull(rootSessionId); + } else { + assertEquals(Config.get().getRootSessionId(), rootSessionId); + } + return this; + } - @SuppressWarnings("unchecked") - BodyAssertions commonParts(RequestType requestType) { - assertEquals("v2", body.get("api_version")); - - Map app = (Map) body.get("application"); - assertNotNull(app.get("env")); - assertEquals("jvm", app.get("language_name")); - assertTrue(((String) app.get("language_version")).matches("\\d+.*")); - assertNotNull(app.get("runtime_name")); - assertNotNull(app.get("runtime_version")); - assertNotNull(app.get("service_name")); - assertEquals("0.42.0", app.get("tracer_version")); - - Map host = (Map) body.get("host"); - assertNotNull(host.get("hostname")); - assertNotNull(host.get("os")); - assertNotNull(host.get("os_version")); - assertNotNull(host.get("kernel_name")); - assertNotNull(host.get("kernel_release")); - assertNotNull(host.get("kernel_version")); - - assertTrue( - ((String) body.get("runtime_id")).matches("[\\da-f]{8}-([\\da-f]{4}-){3}[\\da-f]{12}")); - assertTrue(((Number) body.get("seq_id")).doubleValue() > 0); - assertTrue(((Number) body.get("tracer_time")).doubleValue() > 0); - assertEquals(requestType.toString(), body.get("request_type")); - return this; + @SuppressWarnings("unchecked") + BodyAssertions assertBody() throws IOException { + Buffer buf = new Buffer(); + request.body().writeTo(buf); + byte[] bytes = new byte[(int) buf.size()]; + buf.read(bytes); + Map parsed = (Map) new Moshi.Builder() + .build() + .adapter(Types.newParameterizedType(Map.class, String.class, Object.class)) + .fromJson(new String(bytes, StandardCharsets.UTF_8)); + return new BodyAssertions(parsed, bytes); + } } - @SuppressWarnings("unchecked") - PayloadAssertions assertPayload() { - Map payload = (Map) body.get("payload"); - assertNotNull(payload); - return new PayloadAssertions(payload); - } + static class BodyAssertions { + private final Map body; + private final byte[] bodyBytes; - @SuppressWarnings("unchecked") - BatchAssertions assertBatch(int expectedNumberOfPayloads) { - List> payloads = (List>) body.get("payload"); - assertNotNull(payloads); - assertEquals(expectedNumberOfPayloads, payloads.size()); - return new BatchAssertions(payloads); - } + BodyAssertions(Map body, byte[] bodyBytes) { + this.body = body; + this.bodyBytes = bodyBytes; + } - void assertNoPayload() { - assertNull(body.get("payload")); - } - } + int bodySize() { + return bodyBytes.length; + } - static class BatchAssertions { - private final List> messages; + @SuppressWarnings("unchecked") + BodyAssertions commonParts(RequestType requestType) { + assertEquals("v2", body.get("api_version")); + + Map app = (Map) body.get("application"); + assertNotNull(app.get("env")); + assertEquals("jvm", app.get("language_name")); + assertTrue(((String) app.get("language_version")).matches("\\d+.*")); + assertNotNull(app.get("runtime_name")); + assertNotNull(app.get("runtime_version")); + assertNotNull(app.get("service_name")); + assertEquals("0.42.0", app.get("tracer_version")); + + Map host = (Map) body.get("host"); + assertNotNull(host.get("hostname")); + assertNotNull(host.get("os")); + assertNotNull(host.get("os_version")); + assertNotNull(host.get("kernel_name")); + assertNotNull(host.get("kernel_release")); + assertNotNull(host.get("kernel_version")); + + assertTrue(((String) body.get("runtime_id")).matches("[\\da-f]{8}-([\\da-f]{4}-){3}[\\da-f]{12}")); + assertTrue(((Number) body.get("seq_id")).doubleValue() > 0); + assertTrue(((Number) body.get("tracer_time")).doubleValue() > 0); + assertEquals(requestType.toString(), body.get("request_type")); + return this; + } - BatchAssertions(List> messages) { - this.messages = messages; - } + @SuppressWarnings("unchecked") + PayloadAssertions assertPayload() { + Map payload = (Map) body.get("payload"); + assertNotNull(payload); + return new PayloadAssertions(payload); + } - BatchMessageAssertions assertFirstMessage(RequestType expected) { - return assertMessage(0, expected); - } + @SuppressWarnings("unchecked") + BatchAssertions assertBatch(int expectedNumberOfPayloads) { + List> payloads = (List>) body.get("payload"); + assertNotNull(payloads); + assertEquals(expectedNumberOfPayloads, payloads.size()); + return new BatchAssertions(payloads); + } - private BatchMessageAssertions assertMessage(int index, RequestType expected) { - if (index > messages.size()) { - throw new IllegalStateException( - "Asserted more messages than available (" + messages.size() + ") in the batch"); - } - Map message = messages.get(index); - assertEquals(String.valueOf(expected), message.get("request_type")); - return new BatchMessageAssertions(this, index, message); - } - } - - static class BatchMessageAssertions { - private final BatchAssertions batchAssertions; - private int messageIndex; - private final Map message; - - BatchMessageAssertions( - BatchAssertions batchAssertions, int messageIndex, Map message) { - this.batchAssertions = batchAssertions; - this.messageIndex = messageIndex; - this.message = message; + void assertNoPayload() { + assertNull(body.get("payload")); + } } - BatchMessageAssertions hasNoPayload() { - assertNull(message.get("payload")); - return this; - } + static class BatchAssertions { + private final List> messages; - BatchMessageAssertions assertNextMessage(RequestType expected) { - messageIndex += 1; - if (messageIndex >= batchAssertions.messages.size()) { - throw new IllegalStateException("No more messages available"); - } - return batchAssertions.assertMessage(messageIndex, expected); - } + BatchAssertions(List> messages) { + this.messages = messages; + } - @SuppressWarnings("unchecked") - PayloadAssertions hasPayload() { - Map payload = (Map) message.get("payload"); - assertNotNull(payload); - return new PayloadAssertions(payload, this); - } + BatchMessageAssertions assertFirstMessage(RequestType expected) { + return assertMessage(0, expected); + } - void assertNoMoreMessages() { - assertEquals(batchAssertions.messages.size() - 1, messageIndex); + private BatchMessageAssertions assertMessage(int index, RequestType expected) { + if (index > messages.size()) { + throw new IllegalStateException( + "Asserted more messages than available (" + messages.size() + ") in the batch"); + } + Map message = messages.get(index); + assertEquals(String.valueOf(expected), message.get("request_type")); + return new BatchMessageAssertions(this, index, message); + } } - } - static class PayloadAssertions { - private final Map payload; - private final BatchMessageAssertions batch; + static class BatchMessageAssertions { + private final BatchAssertions batchAssertions; + private int messageIndex; + private final Map message; - PayloadAssertions(Map payload) { - this(payload, null); - } + BatchMessageAssertions(BatchAssertions batchAssertions, int messageIndex, Map message) { + this.batchAssertions = batchAssertions; + this.messageIndex = messageIndex; + this.message = message; + } - PayloadAssertions(Map payload, BatchMessageAssertions batch) { - this.payload = payload; - this.batch = batch; - } + BatchMessageAssertions hasNoPayload() { + assertNull(message.get("payload")); + return this; + } - PayloadAssertions configuration(List configuration) { - List> expected = configuration == null ? null : new ArrayList<>(); - if (configuration != null) { - for (ConfigSetting cs : configuration) { - Map item = new HashMap<>(); - item.put("name", cs.key); - item.put("value", cs.stringValue()); - item.put("origin", cs.origin.value); - item.put("seq_id", (double) cs.seqId); - expected.add(item); - } - } - assertEquals(expected, payload.get("configuration")); - return this; - } + BatchMessageAssertions assertNextMessage(RequestType expected) { + messageIndex += 1; + if (messageIndex >= batchAssertions.messages.size()) { + throw new IllegalStateException("No more messages available"); + } + return batchAssertions.assertMessage(messageIndex, expected); + } - @SuppressWarnings("unchecked") - PayloadAssertions instrumentationConfigId(String id) { - boolean checked = false; - List> configuration = - (List>) payload.get("configuration"); - for (Map entry : configuration) { - if ("DD_INSTRUMENTATION_CONFIG_ID".equals(entry.get("name"))) { - assertEquals(id, entry.get("value")); - checked = true; - } - } - if (!checked) { - assertNull(id); - } - return this; - } + @SuppressWarnings("unchecked") + PayloadAssertions hasPayload() { + Map payload = (Map) message.get("payload"); + assertNotNull(payload); + return new PayloadAssertions(payload, this); + } - PayloadAssertions productChange(ProductChange product) { - String name = product.getProductType().getName(); - Map expected = new HashMap<>(); - expected.put(name, Collections.singletonMap("enabled", product.isEnabled())); - assertEquals(expected, payload.get("products")); - return this; + void assertNoMoreMessages() { + assertEquals(batchAssertions.messages.size() - 1, messageIndex); + } } - PayloadAssertions endpoint(Endpoint... endpoints) { - List> expected = new ArrayList<>(); - for (Endpoint endpoint : endpoints) { - Map item = new HashMap<>(); - item.put("operation_name", endpoint.getOperation()); - item.put("resource_name", endpoint.getMethod() + " " + endpoint.getPath()); - if (endpoint.getType() != null) { - item.put("type", endpoint.getType()); - } - if (endpoint.getMethod() != null) { - item.put("method", endpoint.getMethod()); - } - if (endpoint.getPath() != null) { - item.put("path", endpoint.getPath()); + static class PayloadAssertions { + private final Map payload; + private final BatchMessageAssertions batch; + + PayloadAssertions(Map payload) { + this(payload, null); } - if (endpoint.getRequestBodyType() != null) { - item.put("request_body_type", endpoint.getRequestBodyType()); + + PayloadAssertions(Map payload, BatchMessageAssertions batch) { + this.payload = payload; + this.batch = batch; } - if (endpoint.getResponseBodyType() != null) { - item.put("response_body_type", endpoint.getResponseBodyType()); + + PayloadAssertions configuration(List configuration) { + List> expected = configuration == null ? null : new ArrayList<>(); + if (configuration != null) { + for (ConfigSetting cs : configuration) { + Map item = new HashMap<>(); + item.put("name", cs.key); + item.put("value", cs.stringValue()); + item.put("origin", cs.origin.value); + item.put("seq_id", (double) cs.seqId); + expected.add(item); + } + } + assertEquals(expected, payload.get("configuration")); + return this; } - if (endpoint.getAuthentication() != null) { - item.put("authentication", endpoint.getAuthentication()); + + @SuppressWarnings("unchecked") + PayloadAssertions instrumentationConfigId(String id) { + boolean checked = false; + List> configuration = (List>) payload.get("configuration"); + for (Map entry : configuration) { + if ("DD_INSTRUMENTATION_CONFIG_ID".equals(entry.get("name"))) { + assertEquals(id, entry.get("value")); + checked = true; + } + } + if (!checked) { + assertNull(id); + } + return this; } - if (endpoint.getResponseCode() != null) { - item.put("response_code", toDoubles(endpoint.getResponseCode())); + + PayloadAssertions productChange(ProductChange product) { + String name = product.getProductType().getName(); + Map expected = new HashMap<>(); + expected.put(name, Collections.singletonMap("enabled", product.isEnabled())); + assertEquals(expected, payload.get("products")); + return this; } - if (endpoint.getMetadata() != null) { - item.put("metadata", endpoint.getMetadata()); + + PayloadAssertions endpoint(Endpoint... endpoints) { + List> expected = new ArrayList<>(); + for (Endpoint endpoint : endpoints) { + Map item = new HashMap<>(); + item.put("operation_name", endpoint.getOperation()); + item.put("resource_name", endpoint.getMethod() + " " + endpoint.getPath()); + if (endpoint.getType() != null) { + item.put("type", endpoint.getType()); + } + if (endpoint.getMethod() != null) { + item.put("method", endpoint.getMethod()); + } + if (endpoint.getPath() != null) { + item.put("path", endpoint.getPath()); + } + if (endpoint.getRequestBodyType() != null) { + item.put("request_body_type", endpoint.getRequestBodyType()); + } + if (endpoint.getResponseBodyType() != null) { + item.put("response_body_type", endpoint.getResponseBodyType()); + } + if (endpoint.getAuthentication() != null) { + item.put("authentication", endpoint.getAuthentication()); + } + if (endpoint.getResponseCode() != null) { + item.put("response_code", toDoubles(endpoint.getResponseCode())); + } + if (endpoint.getMetadata() != null) { + item.put("metadata", endpoint.getMetadata()); + } + expected.add(item); + } + assertEquals(expected, payload.get("endpoints")); + return this; } - expected.add(item); - } - assertEquals(expected, payload.get("endpoints")); - return this; - } - PayloadAssertions products() { - return products(true, false, false); - } + PayloadAssertions products() { + return products(true, false, false); + } - PayloadAssertions products(boolean appsecEnabled) { - return products(appsecEnabled, false, false); - } + PayloadAssertions products(boolean appsecEnabled) { + return products(appsecEnabled, false, false); + } - PayloadAssertions products(boolean appsecEnabled, boolean profilerEnabled) { - return products(appsecEnabled, profilerEnabled, false); - } + PayloadAssertions products(boolean appsecEnabled, boolean profilerEnabled) { + return products(appsecEnabled, profilerEnabled, false); + } - PayloadAssertions products( - boolean appsecEnabled, boolean profilerEnabled, boolean dynamicInstrumentationEnabled) { - Map expected = new HashMap<>(); - expected.put("appsec", Collections.singletonMap("enabled", appsecEnabled)); - expected.put("profiler", Collections.singletonMap("enabled", profilerEnabled)); - expected.put( - "dynamic_instrumentation", - Collections.singletonMap("enabled", dynamicInstrumentationEnabled)); - assertEquals(expected, payload.get("products")); - return this; - } + PayloadAssertions products( + boolean appsecEnabled, boolean profilerEnabled, boolean dynamicInstrumentationEnabled) { + Map expected = new HashMap<>(); + expected.put("appsec", Collections.singletonMap("enabled", appsecEnabled)); + expected.put("profiler", Collections.singletonMap("enabled", profilerEnabled)); + expected.put("dynamic_instrumentation", Collections.singletonMap("enabled", dynamicInstrumentationEnabled)); + assertEquals(expected, payload.get("products")); + return this; + } - PayloadAssertions dependencies(List dependencies) { - List> expected = new ArrayList<>(); - for (Dependency dependency : dependencies) { - Map item = new HashMap<>(); - item.put("hash", dependency.hash); - item.put("name", dependency.name); - item.put("version", dependency.version); - expected.add(item); - } - assertEquals(expected, payload.get("dependencies")); - return this; - } + PayloadAssertions dependencies(List dependencies) { + List> expected = new ArrayList<>(); + for (Dependency dependency : dependencies) { + Map item = new HashMap<>(); + item.put("hash", dependency.hash); + item.put("name", dependency.name); + item.put("version", dependency.version); + expected.add(item); + } + assertEquals(expected, payload.get("dependencies")); + return this; + } - PayloadAssertions integrations(List integrations) { - List> expected = new ArrayList<>(); - for (Integration integration : integrations) { - Map item = new HashMap<>(); - item.put("enabled", integration.enabled); - item.put("name", integration.name); - expected.add(item); - } - assertEquals(expected, payload.get("integrations")); - return this; - } + PayloadAssertions integrations(List integrations) { + List> expected = new ArrayList<>(); + for (Integration integration : integrations) { + Map item = new HashMap<>(); + item.put("enabled", integration.enabled); + item.put("name", integration.name); + expected.add(item); + } + assertEquals(expected, payload.get("integrations")); + return this; + } - PayloadAssertions namespace(String namespace) { - assertEquals(namespace, payload.get("namespace")); - return this; - } + PayloadAssertions namespace(String namespace) { + assertEquals(namespace, payload.get("namespace")); + return this; + } - PayloadAssertions metrics(List metrics) { - List> expected = new ArrayList<>(); - for (Metric metric : metrics) { - Map item = new HashMap<>(); - item.put("namespace", metric.getNamespace()); - if (metric.getCommon() != null) { - item.put("common", metric.getCommon()); - } - item.put("metric", metric.getMetric()); - List> points = new ArrayList<>(); - for (List point : metric.getPoints()) { - points.add(toDoubles(point)); - } - item.put("points", points); - if (metric.getType() != null) { - item.put("type", metric.getType()); - } - item.put("tags", metric.getTags()); - expected.add(item); - } - assertEquals(expected, payload.get("series")); - return this; - } + PayloadAssertions metrics(List metrics) { + List> expected = new ArrayList<>(); + for (Metric metric : metrics) { + Map item = new HashMap<>(); + item.put("namespace", metric.getNamespace()); + if (metric.getCommon() != null) { + item.put("common", metric.getCommon()); + } + item.put("metric", metric.getMetric()); + List> points = new ArrayList<>(); + for (List point : metric.getPoints()) { + points.add(toDoubles(point)); + } + item.put("points", points); + if (metric.getType() != null) { + item.put("type", metric.getType()); + } + item.put("tags", metric.getTags()); + expected.add(item); + } + assertEquals(expected, payload.get("series")); + return this; + } - PayloadAssertions distributionSeries(List distributionSeriesList) { - List> expected = new ArrayList<>(); - for (DistributionSeries distribution : distributionSeriesList) { - Map item = new HashMap<>(); - item.put("namespace", distribution.getNamespace()); - if (distribution.getCommon() != null) { - item.put("common", distribution.getCommon()); - } - item.put("metric", distribution.getMetric()); - item.put("points", toDoubles(distribution.getPoints())); - item.put("tags", distribution.getTags()); - expected.add(item); - } - assertEquals(expected, payload.get("series")); - return this; - } + PayloadAssertions distributionSeries(List distributionSeriesList) { + List> expected = new ArrayList<>(); + for (DistributionSeries distribution : distributionSeriesList) { + Map item = new HashMap<>(); + item.put("namespace", distribution.getNamespace()); + if (distribution.getCommon() != null) { + item.put("common", distribution.getCommon()); + } + item.put("metric", distribution.getMetric()); + item.put("points", toDoubles(distribution.getPoints())); + item.put("tags", distribution.getTags()); + expected.add(item); + } + assertEquals(expected, payload.get("series")); + return this; + } - PayloadAssertions logs(List logMessages) { - List> expected = new ArrayList<>(); - for (LogMessage logMessage : logMessages) { - Map item = new HashMap<>(); - item.put("message", logMessage.getMessage()); - item.put("level", logMessage.getLevel().toString()); - item.put("tags", logMessage.getTags()); - if (logMessage.getStackTrace() != null) { - item.put("stack_trace", logMessage.getStackTrace()); - } - if (logMessage.getTracerTime() != null) { - item.put("tracer_time", logMessage.getTracerTime().doubleValue()); - } - item.put("count", (double) logMessage.getCount()); - expected.add(item); - } - assertEquals(expected, payload.get("logs")); - return this; - } + PayloadAssertions logs(List logMessages) { + List> expected = new ArrayList<>(); + for (LogMessage logMessage : logMessages) { + Map item = new HashMap<>(); + item.put("message", logMessage.getMessage()); + item.put("level", logMessage.getLevel().toString()); + item.put("tags", logMessage.getTags()); + if (logMessage.getStackTrace() != null) { + item.put("stack_trace", logMessage.getStackTrace()); + } + if (logMessage.getTracerTime() != null) { + item.put("tracer_time", logMessage.getTracerTime().doubleValue()); + } + item.put("count", (double) logMessage.getCount()); + expected.add(item); + } + assertEquals(expected, payload.get("logs")); + return this; + } - BatchMessageAssertions assertNextMessage(RequestType requestType) { - return batch.assertNextMessage(requestType); - } + BatchMessageAssertions assertNextMessage(RequestType requestType) { + return batch.assertNextMessage(requestType); + } - void assertNoMoreMessages() { - batch.assertNoMoreMessages(); - } + void assertNoMoreMessages() { + batch.assertNoMoreMessages(); + } - void installSignature(String installId, String installType, String installTime) { - if (installId == null && installType == null && installTime == null) { - assertNull(payload.get("install_signature")); - return; - } - Map expected = new HashMap<>(); - if (installId != null) { - expected.put("install_id", installId); - } - if (installType != null) { - expected.put("install_type", installType); - } - if (installTime != null) { - expected.put("install_time", installTime); - } - assertEquals(expected, payload.get("install_signature")); + void installSignature(String installId, String installType, String installTime) { + if (installId == null && installType == null && installTime == null) { + assertNull(payload.get("install_signature")); + return; + } + Map expected = new HashMap<>(); + if (installId != null) { + expected.put("install_id", installId); + } + if (installType != null) { + expected.put("install_type", installType); + } + if (installTime != null) { + expected.put("install_time", installTime); + } + assertEquals(expected, payload.get("install_signature")); + } } - } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyPeriodicActionTest.java index 4190c2edf76..d9f4be9a09d 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyPeriodicActionTest.java @@ -14,26 +14,26 @@ class DependencyPeriodicActionTest { - private final DependencyService depService = mock(DependencyService.class); - private final DependencyPeriodicAction periodicAction = new DependencyPeriodicAction(depService); - private final TelemetryService telemetryService = mock(TelemetryService.class); + private final DependencyService depService = mock(DependencyService.class); + private final DependencyPeriodicAction periodicAction = new DependencyPeriodicAction(depService); + private final TelemetryService telemetryService = mock(TelemetryService.class); - @Test - void transformsDependenciesAndPushesThemToTheTelemetryService() { - Dependency dependency = new Dependency("name", "1.2.3", "name-1.2.3.jar", "DEADBEEF"); - when(depService.drainDeterminedDependencies()).thenReturn(singletonList(dependency)); + @Test + void transformsDependenciesAndPushesThemToTheTelemetryService() { + Dependency dependency = new Dependency("name", "1.2.3", "name-1.2.3.jar", "DEADBEEF"); + when(depService.drainDeterminedDependencies()).thenReturn(singletonList(dependency)); - periodicAction.doIteration(telemetryService); + periodicAction.doIteration(telemetryService); - verify(depService).drainDeterminedDependencies(); - verifyNoMoreInteractions(depService); - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telemetryService).addDependency(captor.capture()); - verifyNoMoreInteractions(telemetryService); - Dependency addedDependency = captor.getValue(); - assertEquals("name", addedDependency.name); - assertEquals("1.2.3", addedDependency.version); - assertEquals("DEADBEEF", addedDependency.hash); - assertNull(addedDependency.reachabilityMetadata); - } + verify(depService).drainDeterminedDependencies(); + verifyNoMoreInteractions(depService); + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telemetryService).addDependency(captor.capture()); + verifyNoMoreInteractions(telemetryService); + Dependency addedDependency = captor.getValue(); + assertEquals("name", addedDependency.name); + assertEquals("1.2.3", addedDependency.version); + assertEquals("DEADBEEF", addedDependency.hash); + assertNull(addedDependency.reachabilityMetadata); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverQueueTest.java b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverQueueTest.java index 96989675f18..00f314bf8f9 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverQueueTest.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverQueueTest.java @@ -10,75 +10,75 @@ class DependencyResolverQueueTest { - private DependencyResolverQueue resolverQueue = new DependencyResolverQueue(); + private DependencyResolverQueue resolverQueue = new DependencyResolverQueue(); - @Test - void resolveSetOfDependencies() { - resolverQueue.queueURI(getJar("junit-4.12.jar").toURI()); - resolverQueue.queueURI(getJar("asm-util-9.2.jar").toURI()); - resolverQueue.queueURI(getJar("bson-4.2.0.jar").toURI()); + @Test + void resolveSetOfDependencies() { + resolverQueue.queueURI(getJar("junit-4.12.jar").toURI()); + resolverQueue.queueURI(getJar("asm-util-9.2.jar").toURI()); + resolverQueue.queueURI(getJar("bson-4.2.0.jar").toURI()); - Dependency dep = resolverQueue.pollDependency().get(0); - assertNotNull(dep); - assertEquals("junit", dep.name); - assertEquals("4.12", dep.version); - assertEquals("junit-4.12.jar", dep.source); - assertEquals("4376590587C49AC6DA6935564233F36B092412AE", dep.hash); + Dependency dep = resolverQueue.pollDependency().get(0); + assertNotNull(dep); + assertEquals("junit", dep.name); + assertEquals("4.12", dep.version); + assertEquals("junit-4.12.jar", dep.source); + assertEquals("4376590587C49AC6DA6935564233F36B092412AE", dep.hash); - dep = resolverQueue.pollDependency().get(0); - assertNotNull(dep); - assertEquals("asm-util", dep.name); - assertEquals("9.2", dep.version); - assertEquals("asm-util-9.2.jar", dep.source); - assertEquals("9A5AEC2CB852B8BD20DAF5D2CE9174891267FE27", dep.hash); + dep = resolverQueue.pollDependency().get(0); + assertNotNull(dep); + assertEquals("asm-util", dep.name); + assertEquals("9.2", dep.version); + assertEquals("asm-util-9.2.jar", dep.source); + assertEquals("9A5AEC2CB852B8BD20DAF5D2CE9174891267FE27", dep.hash); - dep = resolverQueue.pollDependency().get(0); - assertNotNull(dep); - assertEquals("org.mongodb:bson", dep.name); - assertEquals("4.2.0", dep.version); - assertEquals("bson-4.2.0.jar", dep.source); - assertEquals("F87C3A90DA4BB1DA6D3A73CA18004545AD2EF06A", dep.hash); + dep = resolverQueue.pollDependency().get(0); + assertNotNull(dep); + assertEquals("org.mongodb:bson", dep.name); + assertEquals("4.2.0", dep.version); + assertEquals("bson-4.2.0.jar", dep.source); + assertEquals("F87C3A90DA4BB1DA6D3A73CA18004545AD2EF06A", dep.hash); - List deps = resolverQueue.pollDependency(); - assertTrue(deps.isEmpty()); + List deps = resolverQueue.pollDependency(); + assertTrue(deps.isEmpty()); - // a repeated dependency is added: it has no effect - resolverQueue.queueURI(getJar("junit-4.12.jar").toURI()); - deps = resolverQueue.pollDependency(); - assertTrue(deps.isEmpty()); - } + // a repeated dependency is added: it has no effect + resolverQueue.queueURI(getJar("junit-4.12.jar").toURI()); + deps = resolverQueue.pollDependency(); + assertTrue(deps.isEmpty()); + } - @Test - void resolveSetOfDependenciesWithQueueLimit() { - resolverQueue = new DependencyResolverQueue(3); - resolverQueue.queueURI(getJar("junit-4.12.jar").toURI()); - resolverQueue.queueURI(getJar("asm-util-9.2.jar").toURI()); - resolverQueue.queueURI(getJar("bson-4.2.0.jar").toURI()); - // this dependency should be dropped - resolverQueue.queueURI(getJar("commons-logging-1.2.jar").toURI()); + @Test + void resolveSetOfDependenciesWithQueueLimit() { + resolverQueue = new DependencyResolverQueue(3); + resolverQueue.queueURI(getJar("junit-4.12.jar").toURI()); + resolverQueue.queueURI(getJar("asm-util-9.2.jar").toURI()); + resolverQueue.queueURI(getJar("bson-4.2.0.jar").toURI()); + // this dependency should be dropped + resolverQueue.queueURI(getJar("commons-logging-1.2.jar").toURI()); - Dependency dep = resolverQueue.pollDependency().get(0); - assertNotNull(dep); - assertEquals("junit", dep.name); - assertEquals("4.12", dep.version); - assertEquals("junit-4.12.jar", dep.source); - assertEquals("4376590587C49AC6DA6935564233F36B092412AE", dep.hash); + Dependency dep = resolverQueue.pollDependency().get(0); + assertNotNull(dep); + assertEquals("junit", dep.name); + assertEquals("4.12", dep.version); + assertEquals("junit-4.12.jar", dep.source); + assertEquals("4376590587C49AC6DA6935564233F36B092412AE", dep.hash); - dep = resolverQueue.pollDependency().get(0); - assertNotNull(dep); - assertEquals("asm-util", dep.name); - assertEquals("9.2", dep.version); - assertEquals("asm-util-9.2.jar", dep.source); - assertEquals("9A5AEC2CB852B8BD20DAF5D2CE9174891267FE27", dep.hash); + dep = resolverQueue.pollDependency().get(0); + assertNotNull(dep); + assertEquals("asm-util", dep.name); + assertEquals("9.2", dep.version); + assertEquals("asm-util-9.2.jar", dep.source); + assertEquals("9A5AEC2CB852B8BD20DAF5D2CE9174891267FE27", dep.hash); - dep = resolverQueue.pollDependency().get(0); - assertNotNull(dep); - assertEquals("org.mongodb:bson", dep.name); - assertEquals("4.2.0", dep.version); - assertEquals("bson-4.2.0.jar", dep.source); - assertEquals("F87C3A90DA4BB1DA6D3A73CA18004545AD2EF06A", dep.hash); + dep = resolverQueue.pollDependency().get(0); + assertNotNull(dep); + assertEquals("org.mongodb:bson", dep.name); + assertEquals("4.2.0", dep.version); + assertEquals("bson-4.2.0.jar", dep.source); + assertEquals("F87C3A90DA4BB1DA6D3A73CA18004545AD2EF06A", dep.hash); - List deps = resolverQueue.pollDependency(); - assertTrue(deps.isEmpty()); - } + List deps = resolverQueue.pollDependency(); + assertTrue(deps.isEmpty()); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverTest.java b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverTest.java index 26b8c57408f..a5fc47ea479 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverTest.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyResolverTest.java @@ -25,376 +25,366 @@ class DependencyResolverTest { - @TempDir File testDir; - - @TableTest({ - "scenario | bundleSymbolicName | bundleName | bundleVersion | implementationTitle | implementationVersion | filename | expectedName | expectedVersion ", - "agrona | 'org.agrona.core' | 'org.agrona.core' | '1.7.2' | 'Agrona' | '1.7.2' | 'agrona-1.7.2.jar' | 'agrona' | '1.7.2' ", - "asm-util | 'org.objectweb.asm.util' | 'org.objectweb.asm.util' | '9.2.0' | 'Utilities for ASM' | '9.2' | 'asm-util-9.2.jar' | 'asm-util' | '9.2' ", - "bson | 'org.mongodb.bson' | 'bson' | '4.2.0' | | | 'bson-4.2.0.jar' | 'org.mongodb:bson' | '4.2.0' ", - "bson4jackson | 'de.undercouch.bson4jackson' | 'bson4jackson' | '2.11.0' | | | 'bson4jackson-2.11.0.jar' | 'de.undercouch:bson4jackson' | '2.11.0' ", - "caffeine | 'com.github.ben-manes.caffeine' | 'com.github.ben-manes.caffeine' | '2.8.5' | | | 'caffeine-2.8.5.jar' | 'com.github.ben-manes:caffeine' | '2.8.5' ", - "commons logging | 'org.apache.commons.logging' | 'Apache Commons Logging' | '1.2.0' | 'Apache Commons Logging' | '1.2' | 'commons-logging-1.2.jar' | 'commons-logging' | '1.2' ", - "freemarker | 'org.freemarker.freemarker' | 'org.freemarker.freemarker' | '2.3.27.stable-incubating' | 'FreeMarker' | '2.3.27' | 'freemarker-2.3.27-incubating.jar' | 'org.freemarker:freemarker' | '2.3.27-incubating'", - "groovy with implementation version | 'groovy' | 'Groovy Runtime' | '2.4.12' | | | 'groovy-2.4.12.jar' | 'groovy' | '2.4.12' ", - "groovy without version in filename | 'groovy' | 'Groovy Runtime' | '2.4.12' | | | 'groovy.jar' | 'groovy' | '' ", - "hsqldb | 'org.hsqldb.hsqldb' | 'HSQLDB' | '2.3.5' | 'Standard runtime' | '2.3.5' | 'hsqldb-2.3.5-jdk6debug.jar' | 'org.hsqldb:hsqldb' | '2.3.5-jdk6debug' ", - "jakarta inject | 'org.glassfish.hk2.external.jakarta.inject' | 'javax.inject:1 as OSGi bundle' | '2.6.1' | | | 'jakarta.inject-2.6.1.jar' | 'org.glassfish.hk2.external:jakarta.inject' | '2.6.1' ", - "junit with implementation version | | | | 'JUnit' | '4.12' | 'junit-4.12.jar' | 'junit' | '4.12' ", - "junit without version in filename | | | | 'JUnit' | '4.12' | 'junit.jar' | 'junit' | '' ", - "multiverse-core with version in filename | | | | | | 'multiverse-core-0.7.0.jar' | 'multiverse-core' | '0.7.0' ", - "multiverse-core without version in filename | | | | | | 'multiverse-core.jar' | 'multiverse-core' | '' ", - "spring webmvc | 'org.springframework.web.servlet' | 'Spring Web Servlet' | '3.0.0.RELEASE' | 'org.springframework.web.servlet' | '3.0.0.RELEASE' | 'spring-webmvc.jar' | 'spring-webmvc' | '3.0.0.RELEASE' ", - "opencsv | 'com.opencsv' | 'opencsv' | '4.1.0' | | | 'opencsv-4.1.jar' | 'opencsv' | '4.1' ", - "liquibase core | 'org.liquibase.core' | 'liquibase-core' | '0.0.0.SNAPSHOT' | | | 'liquibase-core-4.6.2.jar' | 'liquibase-core' | '4.6.2' ", - "roaring bitmap | 'org.roaringbitmap.RoaringBitmap' | 'RoaringBitmap' | '0.0.1' | | | 'RoaringBitmap-0.0.1.jar' | 'org.roaringbitmap:RoaringBitmap' | '0.0.1' ", - "jmustache | 'com.samskivert.jmustache' | 'jmustache' | '1.14.0' | | | 'jmustache-1.14.jar' | 'com.samskivert:jmustache' | '1.14' " - }) - @ParameterizedTest(name = "resolve from manifest and filename fallbacks [{index}] {0}") - void resolveFromManifestAndFilenameFallbacks( - String bundleSymbolicName, - String bundleName, - String bundleVersion, - String implementationTitle, - String implementationVersion, - String filename, - String expectedName, - String expectedVersion) - throws IOException { - File file = - prepareJar( - filename, - bundleSymbolicName, - bundleName, - bundleVersion, - implementationTitle, - implementationVersion); - - List dependencies = DependencyResolver.resolve(file.toURI()); - - assertEquals(1, dependencies.size()); - Dependency dependency = dependencies.get(0); - assertEquals(expectedName, dependency.name); - assertEquals(expectedVersion, dependency.version); - assertEquals(filename, dependency.source); - assertNotNull(dependency.hash); - assertFalse(dependency.hash.isEmpty()); - } - - private File prepareJar( - String filename, - String bundleSymbolicName, - String bundleName, - String bundleVersion, - String implementationTitle, - String implementationVersion) - throws IOException { - File file = new File(testDir, filename); - StringBuilder manifest = new StringBuilder(); - appendManifestLine(manifest, "Bundle-SymbolicName", bundleSymbolicName); - appendManifestLine(manifest, "Bundle-Name", bundleName); - appendManifestLine(manifest, "Bundle-Version", bundleVersion); - appendManifestLine(manifest, "Implementation-Title", implementationTitle); - appendManifestLine(manifest, "Implementation-Version", implementationVersion); - try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { - out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); - out.write(manifest.toString().getBytes(UTF_8)); - out.closeEntry(); + @TempDir + File testDir; + + @TableTest({ + "scenario | bundleSymbolicName | bundleName | bundleVersion | implementationTitle | implementationVersion | filename | expectedName | expectedVersion ", + "agrona | 'org.agrona.core' | 'org.agrona.core' | '1.7.2' | 'Agrona' | '1.7.2' | 'agrona-1.7.2.jar' | 'agrona' | '1.7.2' ", + "asm-util | 'org.objectweb.asm.util' | 'org.objectweb.asm.util' | '9.2.0' | 'Utilities for ASM' | '9.2' | 'asm-util-9.2.jar' | 'asm-util' | '9.2' ", + "bson | 'org.mongodb.bson' | 'bson' | '4.2.0' | | | 'bson-4.2.0.jar' | 'org.mongodb:bson' | '4.2.0' ", + "bson4jackson | 'de.undercouch.bson4jackson' | 'bson4jackson' | '2.11.0' | | | 'bson4jackson-2.11.0.jar' | 'de.undercouch:bson4jackson' | '2.11.0' ", + "caffeine | 'com.github.ben-manes.caffeine' | 'com.github.ben-manes.caffeine' | '2.8.5' | | | 'caffeine-2.8.5.jar' | 'com.github.ben-manes:caffeine' | '2.8.5' ", + "commons logging | 'org.apache.commons.logging' | 'Apache Commons Logging' | '1.2.0' | 'Apache Commons Logging' | '1.2' | 'commons-logging-1.2.jar' | 'commons-logging' | '1.2' ", + "freemarker | 'org.freemarker.freemarker' | 'org.freemarker.freemarker' | '2.3.27.stable-incubating' | 'FreeMarker' | '2.3.27' | 'freemarker-2.3.27-incubating.jar' | 'org.freemarker:freemarker' | '2.3.27-incubating'", + "groovy with implementation version | 'groovy' | 'Groovy Runtime' | '2.4.12' | | | 'groovy-2.4.12.jar' | 'groovy' | '2.4.12' ", + "groovy without version in filename | 'groovy' | 'Groovy Runtime' | '2.4.12' | | | 'groovy.jar' | 'groovy' | '' ", + "hsqldb | 'org.hsqldb.hsqldb' | 'HSQLDB' | '2.3.5' | 'Standard runtime' | '2.3.5' | 'hsqldb-2.3.5-jdk6debug.jar' | 'org.hsqldb:hsqldb' | '2.3.5-jdk6debug' ", + "jakarta inject | 'org.glassfish.hk2.external.jakarta.inject' | 'javax.inject:1 as OSGi bundle' | '2.6.1' | | | 'jakarta.inject-2.6.1.jar' | 'org.glassfish.hk2.external:jakarta.inject' | '2.6.1' ", + "junit with implementation version | | | | 'JUnit' | '4.12' | 'junit-4.12.jar' | 'junit' | '4.12' ", + "junit without version in filename | | | | 'JUnit' | '4.12' | 'junit.jar' | 'junit' | '' ", + "multiverse-core with version in filename | | | | | | 'multiverse-core-0.7.0.jar' | 'multiverse-core' | '0.7.0' ", + "multiverse-core without version in filename | | | | | | 'multiverse-core.jar' | 'multiverse-core' | '' ", + "spring webmvc | 'org.springframework.web.servlet' | 'Spring Web Servlet' | '3.0.0.RELEASE' | 'org.springframework.web.servlet' | '3.0.0.RELEASE' | 'spring-webmvc.jar' | 'spring-webmvc' | '3.0.0.RELEASE' ", + "opencsv | 'com.opencsv' | 'opencsv' | '4.1.0' | | | 'opencsv-4.1.jar' | 'opencsv' | '4.1' ", + "liquibase core | 'org.liquibase.core' | 'liquibase-core' | '0.0.0.SNAPSHOT' | | | 'liquibase-core-4.6.2.jar' | 'liquibase-core' | '4.6.2' ", + "roaring bitmap | 'org.roaringbitmap.RoaringBitmap' | 'RoaringBitmap' | '0.0.1' | | | 'RoaringBitmap-0.0.1.jar' | 'org.roaringbitmap:RoaringBitmap' | '0.0.1' ", + "jmustache | 'com.samskivert.jmustache' | 'jmustache' | '1.14.0' | | | 'jmustache-1.14.jar' | 'com.samskivert:jmustache' | '1.14' " + }) + @ParameterizedTest(name = "resolve from manifest and filename fallbacks [{index}] {0}") + void resolveFromManifestAndFilenameFallbacks( + String bundleSymbolicName, + String bundleName, + String bundleVersion, + String implementationTitle, + String implementationVersion, + String filename, + String expectedName, + String expectedVersion) + throws IOException { + File file = prepareJar( + filename, bundleSymbolicName, bundleName, bundleVersion, implementationTitle, implementationVersion); + + List dependencies = DependencyResolver.resolve(file.toURI()); + + assertEquals(1, dependencies.size()); + Dependency dependency = dependencies.get(0); + assertEquals(expectedName, dependency.name); + assertEquals(expectedVersion, dependency.version); + assertEquals(filename, dependency.source); + assertNotNull(dependency.hash); + assertFalse(dependency.hash.isEmpty()); } - return file; - } - private static void appendManifestLine(StringBuilder manifest, String key, String value) { - if (value == null) { - return; + private File prepareJar( + String filename, + String bundleSymbolicName, + String bundleName, + String bundleVersion, + String implementationTitle, + String implementationVersion) + throws IOException { + File file = new File(testDir, filename); + StringBuilder manifest = new StringBuilder(); + appendManifestLine(manifest, "Bundle-SymbolicName", bundleSymbolicName); + appendManifestLine(manifest, "Bundle-Name", bundleName); + appendManifestLine(manifest, "Bundle-Version", bundleVersion); + appendManifestLine(manifest, "Implementation-Title", implementationTitle); + appendManifestLine(manifest, "Implementation-Version", implementationVersion); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { + out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + out.write(manifest.toString().getBytes(UTF_8)); + out.closeEntry(); + } + return file; } - if (manifest.length() > 0) { - manifest.append('\n'); + + private static void appendManifestLine(StringBuilder manifest, String key, String value) { + if (value == null) { + return; + } + if (manifest.length() > 0) { + manifest.append('\n'); + } + manifest.append(key).append(": ").append(value); + } + + @TableTest({ + "scenario | jar | expectedName | expectedHash | expectedVersion", + "bson4jackson | 'bson4jackson-2.11.0.jar' | 'de.undercouch:bson4jackson' | '428A23E33D19DACD6E04CA7DD746206849861A95' | '2.11.0' ", + "bson | 'bson-4.2.0.jar' | 'org.mongodb:bson' | 'F87C3A90DA4BB1DA6D3A73CA18004545AD2EF06A' | '4.2.0' " + }) + @ParameterizedTest(name = "jar without pom.properties get resolved with hash [{index}] {0}") + void jarWithoutPomPropertiesGetResolvedWithHash( + String jar, String expectedName, String expectedHash, String expectedVersion) throws IOException { + knownJarCheck(jar, expectedName, expectedVersion, expectedHash); } - manifest.append(key).append(": ").append(value); - } - - @TableTest({ - "scenario | jar | expectedName | expectedHash | expectedVersion", - "bson4jackson | 'bson4jackson-2.11.0.jar' | 'de.undercouch:bson4jackson' | '428A23E33D19DACD6E04CA7DD746206849861A95' | '2.11.0' ", - "bson | 'bson-4.2.0.jar' | 'org.mongodb:bson' | 'F87C3A90DA4BB1DA6D3A73CA18004545AD2EF06A' | '4.2.0' " - }) - @ParameterizedTest(name = "jar without pom.properties get resolved with hash [{index}] {0}") - void jarWithoutPomPropertiesGetResolvedWithHash( - String jar, String expectedName, String expectedHash, String expectedVersion) - throws IOException { - knownJarCheck(jar, expectedName, expectedVersion, expectedHash); - } - - @Test - void jarWithPomPropertiesIsResolved() throws IOException { - knownJarCheck("commons-logging-1.2.jar", "commons-logging:commons-logging", "1.2", null); - } - - @Test - void jarWithDdJavaAgentPomPropertiesResolvesToComDatadoghqDdJavaAgent() throws IOException { - // a jar containing META-INF/maven/com.datadoghq/dd-java-agent/pom.properties - File file = new File(testDir, "dd-java-agent.jar"); - try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { - out.putNextEntry(new ZipEntry("META-INF/maven/com.datadoghq/dd-java-agent/pom.properties")); - out.write("groupId=com.datadoghq\nartifactId=dd-java-agent\nversion=1.0.0\n".getBytes(UTF_8)); - out.closeEntry(); + + @Test + void jarWithPomPropertiesIsResolved() throws IOException { + knownJarCheck("commons-logging-1.2.jar", "commons-logging:commons-logging", "1.2", null); + } + + @Test + void jarWithDdJavaAgentPomPropertiesResolvesToComDatadoghqDdJavaAgent() throws IOException { + // a jar containing META-INF/maven/com.datadoghq/dd-java-agent/pom.properties + File file = new File(testDir, "dd-java-agent.jar"); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { + out.putNextEntry(new ZipEntry("META-INF/maven/com.datadoghq/dd-java-agent/pom.properties")); + out.write("groupId=com.datadoghq\nartifactId=dd-java-agent\nversion=1.0.0\n".getBytes(UTF_8)); + out.closeEntry(); + } + + List deps = DependencyResolver.resolve(file.toURI()); + + assertEquals(1, deps.size()); + assertEquals("com.datadoghq:dd-java-agent", deps.get(0).name); + assertEquals("1.0.0", deps.get(0).version); + assertNull(deps.get(0).hash); } - List deps = DependencyResolver.resolve(file.toURI()); - - assertEquals(1, deps.size()); - assertEquals("com.datadoghq:dd-java-agent", deps.get(0).name); - assertEquals("1.0.0", deps.get(0).version); - assertNull(deps.get(0).hash); - } - - @Test - void jarWithoutManifestAndNoVersionInFilenameGetsResolved() throws IOException { - knownJarCheck( - "groovy-no-manifest-info.jar", - "groovy-no-manifest-info", - "", - "1C1C8E5547A54F593B97584D45F3636F479B9498"); - } - - @Test - void tryToDetermineLibName() throws IOException { - File temp = File.createTempFile("temp", ".zip"); - try { - assertTrue(DependencyResolver.resolve(temp.toURI()).isEmpty()); - } finally { - temp.delete(); + @Test + void jarWithoutManifestAndNoVersionInFilenameGetsResolved() throws IOException { + knownJarCheck( + "groovy-no-manifest-info.jar", + "groovy-no-manifest-info", + "", + "1C1C8E5547A54F593B97584D45F3636F479B9498"); } - } - @Test - void tryToDetermineNonExistingLibName() throws IOException { - File temp = File.createTempFile("temp", ".zip"); - temp.delete(); + @Test + void tryToDetermineLibName() throws IOException { + File temp = File.createTempFile("temp", ".zip"); + try { + assertTrue(DependencyResolver.resolve(temp.toURI()).isEmpty()); + } finally { + temp.delete(); + } + } - assertTrue(DependencyResolver.resolve(temp.toURI()).isEmpty()); - } + @Test + void tryToDetermineNonExistingLibName() throws IOException { + File temp = File.createTempFile("temp", ".zip"); + temp.delete(); - @Test - void tryToDetermineInvalidJarLib() throws IOException { - File temp = File.createTempFile("temp", ".jar"); - try (FileOutputStream out = new FileOutputStream(temp)) { - out.write("just a text file".getBytes(UTF_8)); + assertTrue(DependencyResolver.resolve(temp.toURI()).isEmpty()); } - assertTrue(DependencyResolver.resolve(temp.toURI()).isEmpty()); - } + @Test + void tryToDetermineInvalidJarLib() throws IOException { + File temp = File.createTempFile("temp", ".jar"); + try (FileOutputStream out = new FileOutputStream(temp)) { + out.write("just a text file".getBytes(UTF_8)); + } - @Test - void springBootDependency() throws URISyntaxException { - URI zipPath = zipPath("datadog/telemetry/dependencies/spring-boot-app.jar"); - URI uri = new URI("jar:" + zipPath + "!/BOOT-INF/lib/opentracing-util-0.33.0.jar!/"); + assertTrue(DependencyResolver.resolve(temp.toURI()).isEmpty()); + } - Dependency dep = DependencyResolver.resolve(uri).get(0); + @Test + void springBootDependency() throws URISyntaxException { + URI zipPath = zipPath("datadog/telemetry/dependencies/spring-boot-app.jar"); + URI uri = new URI("jar:" + zipPath + "!/BOOT-INF/lib/opentracing-util-0.33.0.jar!/"); - assertNotNull(dep); - assertEquals("io.opentracing:opentracing-util", dep.name); - assertEquals("0.33.0", dep.version); - assertNull(dep.hash); - assertEquals("opentracing-util-0.33.0.jar", dep.source); - } + Dependency dep = DependencyResolver.resolve(uri).get(0); - @Test - void springBootDependencyWithoutTrailingSlash() throws URISyntaxException { - URI zipPath = zipPath("datadog/telemetry/dependencies/spring-boot-app.jar"); - URI uri = new URI("jar:" + zipPath + "!/BOOT-INF/lib/opentracing-util-0.33.0.jar!"); + assertNotNull(dep); + assertEquals("io.opentracing:opentracing-util", dep.name); + assertEquals("0.33.0", dep.version); + assertNull(dep.hash); + assertEquals("opentracing-util-0.33.0.jar", dep.source); + } - Dependency dep = DependencyResolver.resolve(uri).get(0); + @Test + void springBootDependencyWithoutTrailingSlash() throws URISyntaxException { + URI zipPath = zipPath("datadog/telemetry/dependencies/spring-boot-app.jar"); + URI uri = new URI("jar:" + zipPath + "!/BOOT-INF/lib/opentracing-util-0.33.0.jar!"); - assertNotNull(dep); - assertEquals("io.opentracing:opentracing-util", dep.name); - assertEquals("0.33.0", dep.version); - assertNull(dep.hash); - assertEquals("opentracing-util-0.33.0.jar", dep.source); - } + Dependency dep = DependencyResolver.resolve(uri).get(0); - @Test - void springBootDependencyNewStyle() throws URISyntaxException { - String zipPath = - zipPath("datadog/telemetry/dependencies/spring-boot-app.jar") - .toString() - .replace("file:", "nested:"); - URI uri = new URI("jar:" + zipPath + "/!BOOT-INF/lib/opentracing-util-0.33.0.jar!/"); + assertNotNull(dep); + assertEquals("io.opentracing:opentracing-util", dep.name); + assertEquals("0.33.0", dep.version); + assertNull(dep.hash); + assertEquals("opentracing-util-0.33.0.jar", dep.source); + } - Dependency dep = DependencyResolver.resolve(uri).get(0); + @Test + void springBootDependencyNewStyle() throws URISyntaxException { + String zipPath = zipPath("datadog/telemetry/dependencies/spring-boot-app.jar") + .toString() + .replace("file:", "nested:"); + URI uri = new URI("jar:" + zipPath + "/!BOOT-INF/lib/opentracing-util-0.33.0.jar!/"); - assertNotNull(dep); - assertEquals("io.opentracing:opentracing-util", dep.name); - assertEquals("0.33.0", dep.version); - assertNull(dep.hash); - assertEquals("opentracing-util-0.33.0.jar", dep.source); - } + Dependency dep = DependencyResolver.resolve(uri).get(0); - @Test - void springBootDependencyNewStyleEmptyPath() throws URISyntaxException { - URI uri = new URI("jar:nested:"); + assertNotNull(dep); + assertEquals("io.opentracing:opentracing-util", dep.name); + assertEquals("0.33.0", dep.version); + assertNull(dep.hash); + assertEquals("opentracing-util-0.33.0.jar", dep.source); + } - List deps = DependencyResolver.resolve(uri); + @Test + void springBootDependencyNewStyleEmptyPath() throws URISyntaxException { + URI uri = new URI("jar:nested:"); - assertTrue(deps.isEmpty()); - } + List deps = DependencyResolver.resolve(uri); - @Test - void springBootDependencyOldStyleEmptyPath() throws URISyntaxException { - URI uri = new URI("jar:file:"); + assertTrue(deps.isEmpty()); + } - List deps = DependencyResolver.resolve(uri); + @Test + void springBootDependencyOldStyleEmptyPath() throws URISyntaxException { + URI uri = new URI("jar:file:"); - assertTrue(deps.isEmpty()); - } + List deps = DependencyResolver.resolve(uri); - @Test - void jarUnknown() throws URISyntaxException { - URI uri = new URI("jar:unknown"); + assertTrue(deps.isEmpty()); + } - List deps = DependencyResolver.resolve(uri); + @Test + void jarUnknown() throws URISyntaxException { + URI uri = new URI("jar:unknown"); - assertTrue(deps.isEmpty()); - } + List deps = DependencyResolver.resolve(uri); - @Test - void springBootDependencyWithoutMavenMetadata() throws IOException, URISyntaxException { - ByteArrayOutputStream innerJarData = new ByteArrayOutputStream(); - try (ZipOutputStream out = new ZipOutputStream(innerJarData)) { - out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); - out.closeEntry(); + assertTrue(deps.isEmpty()); } - File file = new File(testDir, "app.jar"); - try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { - out.putNextEntry(new ZipEntry("BOOT-INF/lib/lib-1.0.jar")); - out.write(innerJarData.toByteArray()); - out.closeEntry(); + @Test + void springBootDependencyWithoutMavenMetadata() throws IOException, URISyntaxException { + ByteArrayOutputStream innerJarData = new ByteArrayOutputStream(); + try (ZipOutputStream out = new ZipOutputStream(innerJarData)) { + out.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + out.closeEntry(); + } + + File file = new File(testDir, "app.jar"); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { + out.putNextEntry(new ZipEntry("BOOT-INF/lib/lib-1.0.jar")); + out.write(innerJarData.toByteArray()); + out.closeEntry(); + } + + URI uri = new URI("jar:file:" + file.getAbsolutePath() + "!/BOOT-INF/lib/lib-1.0.jar!/"); + List deps = DependencyResolver.resolve(uri); + + assertEquals(1, deps.size()); + assertEquals("lib-1.0.jar", deps.get(0).source); + assertNotNull(deps.get(0).hash); } - URI uri = new URI("jar:file:" + file.getAbsolutePath() + "!/BOOT-INF/lib/lib-1.0.jar!/"); - List deps = DependencyResolver.resolve(uri); + @Test + void fatJarWithMultiplePomProperties() throws URISyntaxException { + URI uri = zipPath("datadog/telemetry/dependencies/budgetapp.jar"); + + List deps = DependencyResolver.resolve(uri); - assertEquals(1, deps.size()); - assertEquals("lib-1.0.jar", deps.get(0).source); - assertNotNull(deps.get(0).hash); - } + assertEquals(105, deps.size()); + for (Dependency dep : deps) { + assertNull(dep.hash); + } + } - @Test - void fatJarWithMultiplePomProperties() throws URISyntaxException { - URI uri = zipPath("datadog/telemetry/dependencies/budgetapp.jar"); + @Test + void fatJarWithTwoPomProperties() throws URISyntaxException { + URI uri = zipPath("datadog/telemetry/dependencies/budgetappreduced.jar"); - List deps = DependencyResolver.resolve(uri); + List deps = DependencyResolver.resolve(uri); + Dependency dep1 = deps.get(0); + Dependency dep2 = deps.get(1); - assertEquals(105, deps.size()); - for (Dependency dep : deps) { - assertNull(dep.hash); + assertEquals(2, deps.size()); + assertTrue(dep1.name.equals("org.yaml:snakeyaml") || dep2.name.equals("org.yaml:snakeyaml")); + for (Dependency dep : deps) { + assertNull(dep.hash); + } } - } - @Test - void fatJarWithTwoPomProperties() throws URISyntaxException { - URI uri = zipPath("datadog/telemetry/dependencies/budgetappreduced.jar"); + @Test + void fatJarWithTwoPomPropertiesOneOfThemBad() throws URISyntaxException { + URI uri = zipPath("datadog/telemetry/dependencies/budgetappreducedbadproperties.jar"); - List deps = DependencyResolver.resolve(uri); - Dependency dep1 = deps.get(0); - Dependency dep2 = deps.get(1); + List deps = DependencyResolver.resolve(uri); + Dependency dep1 = deps.get(0); - assertEquals(2, deps.size()); - assertTrue(dep1.name.equals("org.yaml:snakeyaml") || dep2.name.equals("org.yaml:snakeyaml")); - for (Dependency dep : deps) { - assertNull(dep.hash); + assertEquals(1, deps.size()); + assertEquals("org.yaml:snakeyaml", dep1.name); + for (Dependency dep : deps) { + assertNull(dep.hash); + } } - } - @Test - void fatJarWithTwoPomPropertiesOneOfThemBad() throws URISyntaxException { - URI uri = zipPath("datadog/telemetry/dependencies/budgetappreducedbadproperties.jar"); + @Test + void invalidPomPropertiesResultsInFallback() throws IOException { + // this jar has an invalid pom.properties and it should be resolved with its file name + knownJarCheck( + "invalidpomproperties.jar", "invalidpomproperties", "", "6438819DAB9C9AC18D8A6922C8A923C2ADAEA85D"); + } - List deps = DependencyResolver.resolve(uri); - Dependency dep1 = deps.get(0); + @Test + void attemptToExtractDependenciesFromDirectory() throws IOException { + File dir = new File(testDir, "dir"); + dir.mkdirs(); - assertEquals(1, deps.size()); - assertEquals("org.yaml:snakeyaml", dep1.name); - for (Dependency dep : deps) { - assertNull(dep.hash); - } - } - - @Test - void invalidPomPropertiesResultsInFallback() throws IOException { - // this jar has an invalid pom.properties and it should be resolved with its file name - knownJarCheck( - "invalidpomproperties.jar", - "invalidpomproperties", - "", - "6438819DAB9C9AC18D8A6922C8A923C2ADAEA85D"); - } - - @Test - void attemptToExtractDependenciesFromDirectory() throws IOException { - File dir = new File(testDir, "dir"); - dir.mkdirs(); - - List deps = DependencyResolver.resolve(dir.toURI()); - - assertTrue(deps.isEmpty()); - - // resolve without catching exceptions: it does not throw - deps = DependencyResolver.internalResolve(dir.toURI()); - - assertTrue(deps.isEmpty()); - } - - @Test - void attemptToExtractDependenciesFromDirectoryWithinJar() throws IOException { - File file = new File(testDir, "app.jar"); - try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { - out.putNextEntry(new ZipEntry("classes/")); - out.closeEntry(); + List deps = DependencyResolver.resolve(dir.toURI()); + + assertTrue(deps.isEmpty()); + + // resolve without catching exceptions: it does not throw + deps = DependencyResolver.internalResolve(dir.toURI()); + + assertTrue(deps.isEmpty()); } - URI uri = URI.create("jar:file:" + file.getAbsolutePath() + "!/classes!/"); - List deps = DependencyResolver.resolve(uri); + @Test + void attemptToExtractDependenciesFromDirectoryWithinJar() throws IOException { + File file = new File(testDir, "app.jar"); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(file))) { + out.putNextEntry(new ZipEntry("classes/")); + out.closeEntry(); + } - assertTrue(deps.isEmpty()); + URI uri = URI.create("jar:file:" + file.getAbsolutePath() + "!/classes!/"); + List deps = DependencyResolver.resolve(uri); - // resolve without catching exceptions: it does not throw - deps = DependencyResolver.internalResolve(uri); + assertTrue(deps.isEmpty()); - assertTrue(deps.isEmpty()); - } + // resolve without catching exceptions: it does not throw + deps = DependencyResolver.internalResolve(uri); - private static void knownJarCheck( - String jarName, String expectedName, String expectedVersion, String expectedHash) { - File jarFile = getJar(jarName); - List deps = DependencyResolver.resolve(jarFile.toURI()); + assertTrue(deps.isEmpty()); + } - assertEquals(1, deps.size()); - Dependency dep = deps.get(0); - assertNotNull(dep); - assertEquals(jarName, dep.source); - assertEquals(expectedName, dep.name); - assertEquals(expectedVersion, dep.version); - assertEquals(expectedHash, dep.hash); - } + private static void knownJarCheck( + String jarName, String expectedName, String expectedVersion, String expectedHash) { + File jarFile = getJar(jarName); + List deps = DependencyResolver.resolve(jarFile.toURI()); + + assertEquals(1, deps.size()); + Dependency dep = deps.get(0); + assertNotNull(dep); + assertEquals(jarName, dep.source); + assertEquals(expectedName, dep.name); + assertEquals(expectedVersion, dep.version); + assertEquals(expectedHash, dep.hash); + } - @Test - void jbossMayUseTheJarFileFormatToReferenceJarFilesInsteadOfNestedJars() throws IOException { - File file = prepareJar("junit-4.12.jar", null, null, null, "JUnit", "4.12"); - URI uri = URI.create("jar:" + file.toURI() + "!/"); + @Test + void jbossMayUseTheJarFileFormatToReferenceJarFilesInsteadOfNestedJars() throws IOException { + File file = prepareJar("junit-4.12.jar", null, null, null, "JUnit", "4.12"); + URI uri = URI.create("jar:" + file.toURI() + "!/"); - List deps = DependencyResolver.resolve(uri); + List deps = DependencyResolver.resolve(uri); - assertEquals(1, deps.size()); - } + assertEquals(1, deps.size()); + } - private static URI zipPath(String outerJar) throws URISyntaxException { - URL outerJarUrl = Thread.currentThread().getContextClassLoader().getResource(outerJar); + private static URI zipPath(String outerJar) throws URISyntaxException { + URL outerJarUrl = Thread.currentThread().getContextClassLoader().getResource(outerJar); - assertNotNull(outerJarUrl, "Resource not found: " + outerJar); + assertNotNull(outerJarUrl, "Resource not found: " + outerJar); - return outerJarUrl.toURI(); - } + return outerJarUrl.toURI(); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTest.java b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTest.java index b94bb187f85..a9af587b9b2 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTest.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTest.java @@ -28,172 +28,163 @@ class DependencyServiceTest { - private final DependencyService depService = new DependencyService(); - - @Test - void noUrisPushedShouldResultInEmptyList() { - depService.resolveOneDependency(); - - Collection dependencies = depService.drainDeterminedDependencies(); - - assertTrue(dependencies.isEmpty()); - } - - @Test - void nullUriResultsInNPE() { - assertThrows(NullPointerException.class, () -> depService.addURL(null)); - } - - @Test - void classFilesAreIgnoredAsDependencies() throws MalformedURLException { - depService.addURL(new URL("file:///tmp/toto.class")); - depService.resolveOneDependency(); - - assertTrue(depService.drainDeterminedDependencies().isEmpty()); - } - - @Test - void addMissingJarUrlDependency() throws MalformedURLException { - // this URI comes from a spring-boot application - URL url = - new URL( - "jar:file:/tmp//spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-2.1.0.BUILD-SNAPSHOT.jar!//"); - depService.addURL(url); - depService.resolveOneDependency(); - - assertTrue(depService.drainDeterminedDependencies().isEmpty()); - } - - @Test - void invalidJarNamesAreIgnored() throws IOException { - depService.addURL(new File(".zip").toURI().toURL()); - depService.resolveOneDependency(); - - assertTrue(depService.drainDeterminedDependencies().isEmpty()); - } - - @Test - void buildDependencySetFromKnownJar() throws IOException { - File junitJar = getJar("junit-4.12.jar"); - - depService.addURL(junitJar.toURI().toURL()); - depService.resolveOneDependency(); - - Collection dependencies = depService.drainDeterminedDependencies(); - assertEquals(1, dependencies.size()); - Dependency dependency = dependencies.iterator().next(); - assertEquals("junit", dependency.name); - assertEquals("4.12", dependency.version); - } - - @Test - void convertToURIWorksWithASpacedURL() throws MalformedURLException { - URI uri = - depService.convertToURI( - new URL( - "file:/C:/Program Files/IBM/WebSphere/AppServer_1/plugins/com.ibm.cds_2.1.0.jar")); - - assertEquals( - "/C:/Program Files/IBM/WebSphere/AppServer_1/plugins/com.ibm.cds_2.1.0.jar", uri.getPath()); - } - - @Test - void convertToURIWorksWithANestedURL() throws MalformedURLException { - URI uri = - depService.convertToURI( - new URL("jar:file:/Users/test/spring-petclinic.jar!/BOOT-INF/classes!/")); - - assertEquals( - "file:/Users/test/spring-petclinic.jar!/BOOT-INF/classes!/", uri.getSchemeSpecificPart()); - } - - @Test - void buildDependencySetFromAFatJar() throws IOException { - File budgetappJar = getJar("budgetapp.jar"); - - depService.addURL(budgetappJar.toURI().toURL()); - depService.resolveOneDependency(); - - Collection dependencies = depService.drainDeterminedDependencies(); - assertEquals(105, dependencies.size()); - } - - @Test - void buildDependencySetFromASmallFatJar() throws IOException { - File budgetappJar = getJar("budgetappreduced.jar"); - - depService.addURL(budgetappJar.toURI().toURL()); - depService.resolveOneDependency(); - - Collection dependencies = depService.drainDeterminedDependencies(); - assertEquals(2, dependencies.size()); - Map dependenciesByName = dependenciesByName(dependencies); - assertEquals("3.2.4", dependenciesByName.get("cglib:cglib").version); - assertEquals("1.17", dependenciesByName.get("org.yaml:snakeyaml").version); - } - - @Test - void buildDependencySetFromASmallFatJarWithOneIncorrectPomProperties() throws IOException { - File budgetappJar = getJar("budgetappreducedbadproperties.jar"); - - depService.addURL(budgetappJar.toURI().toURL()); - depService.resolveOneDependency(); - - Collection dependencies = depService.drainDeterminedDependencies(); - assertEquals(1, dependencies.size()); - Dependency dependency = dependencies.iterator().next(); - assertEquals("org.yaml:snakeyaml", dependency.name); - assertEquals("1.17", dependency.version); - } - - @Test - void transformerInvalidCodeSource() throws MalformedURLException, IllegalClassFormatException { - Instrumentation instrumentation = mock(Instrumentation.class); - - depService.installOn(instrumentation); - - ArgumentCaptor transformerCaptor = - ArgumentCaptor.forClass(ClassFileTransformer.class); - verify(instrumentation).addTransformer(transformerCaptor.capture()); - ClassFileTransformer transformer = transformerCaptor.getValue(); - assertNotNull(transformer); - - // null protection domain - transformer.transform(null, null, null, null, null); - depService.resolveOneDependency(); - assertTrue(depService.drainDeterminedDependencies().isEmpty()); - - // null code source - ProtectionDomain protectionDomainWithoutCodeSource = - new ProtectionDomain(null, null, null, null); - transformer.transform(null, null, null, protectionDomainWithoutCodeSource, null); - depService.resolveOneDependency(); - assertTrue(depService.drainDeterminedDependencies().isEmpty()); - - // null code source location - CodeSource codeSourceWithoutLocation = new CodeSource(null, (CodeSigner[]) null); - ProtectionDomain protectionDomainWithoutLocation = - new ProtectionDomain(codeSourceWithoutLocation, null, null, null); - transformer.transform(null, null, null, protectionDomainWithoutLocation, null); - depService.resolveOneDependency(); - assertNull(codeSourceWithoutLocation.getLocation()); - assertTrue(depService.drainDeterminedDependencies().isEmpty()); - - // null or invalid URI syntax - URL invalidUrl = new URL("http:// "); // this url is known to not be a valid URI - CodeSource codeSourceWithInvalidUrl = new CodeSource(invalidUrl, (CodeSigner[]) null); - ProtectionDomain protectionDomainWithInvalidUrl = - new ProtectionDomain(codeSourceWithInvalidUrl, null, null, null); - transformer.transform(null, null, null, protectionDomainWithInvalidUrl, null); - depService.resolveOneDependency(); - assertTrue(depService.drainDeterminedDependencies().isEmpty()); - } - - private static Map dependenciesByName(Collection dependencies) { - Map dependenciesByName = new HashMap<>(); - for (Dependency dependency : dependencies) { - dependenciesByName.put(dependency.name, dependency); + private final DependencyService depService = new DependencyService(); + + @Test + void noUrisPushedShouldResultInEmptyList() { + depService.resolveOneDependency(); + + Collection dependencies = depService.drainDeterminedDependencies(); + + assertTrue(dependencies.isEmpty()); + } + + @Test + void nullUriResultsInNPE() { + assertThrows(NullPointerException.class, () -> depService.addURL(null)); + } + + @Test + void classFilesAreIgnoredAsDependencies() throws MalformedURLException { + depService.addURL(new URL("file:///tmp/toto.class")); + depService.resolveOneDependency(); + + assertTrue(depService.drainDeterminedDependencies().isEmpty()); + } + + @Test + void addMissingJarUrlDependency() throws MalformedURLException { + // this URI comes from a spring-boot application + URL url = new URL( + "jar:file:/tmp//spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-2.1.0.BUILD-SNAPSHOT.jar!//"); + depService.addURL(url); + depService.resolveOneDependency(); + + assertTrue(depService.drainDeterminedDependencies().isEmpty()); + } + + @Test + void invalidJarNamesAreIgnored() throws IOException { + depService.addURL(new File(".zip").toURI().toURL()); + depService.resolveOneDependency(); + + assertTrue(depService.drainDeterminedDependencies().isEmpty()); + } + + @Test + void buildDependencySetFromKnownJar() throws IOException { + File junitJar = getJar("junit-4.12.jar"); + + depService.addURL(junitJar.toURI().toURL()); + depService.resolveOneDependency(); + + Collection dependencies = depService.drainDeterminedDependencies(); + assertEquals(1, dependencies.size()); + Dependency dependency = dependencies.iterator().next(); + assertEquals("junit", dependency.name); + assertEquals("4.12", dependency.version); + } + + @Test + void convertToURIWorksWithASpacedURL() throws MalformedURLException { + URI uri = depService.convertToURI( + new URL("file:/C:/Program Files/IBM/WebSphere/AppServer_1/plugins/com.ibm.cds_2.1.0.jar")); + + assertEquals("/C:/Program Files/IBM/WebSphere/AppServer_1/plugins/com.ibm.cds_2.1.0.jar", uri.getPath()); + } + + @Test + void convertToURIWorksWithANestedURL() throws MalformedURLException { + URI uri = depService.convertToURI(new URL("jar:file:/Users/test/spring-petclinic.jar!/BOOT-INF/classes!/")); + + assertEquals("file:/Users/test/spring-petclinic.jar!/BOOT-INF/classes!/", uri.getSchemeSpecificPart()); + } + + @Test + void buildDependencySetFromAFatJar() throws IOException { + File budgetappJar = getJar("budgetapp.jar"); + + depService.addURL(budgetappJar.toURI().toURL()); + depService.resolveOneDependency(); + + Collection dependencies = depService.drainDeterminedDependencies(); + assertEquals(105, dependencies.size()); + } + + @Test + void buildDependencySetFromASmallFatJar() throws IOException { + File budgetappJar = getJar("budgetappreduced.jar"); + + depService.addURL(budgetappJar.toURI().toURL()); + depService.resolveOneDependency(); + + Collection dependencies = depService.drainDeterminedDependencies(); + assertEquals(2, dependencies.size()); + Map dependenciesByName = dependenciesByName(dependencies); + assertEquals("3.2.4", dependenciesByName.get("cglib:cglib").version); + assertEquals("1.17", dependenciesByName.get("org.yaml:snakeyaml").version); + } + + @Test + void buildDependencySetFromASmallFatJarWithOneIncorrectPomProperties() throws IOException { + File budgetappJar = getJar("budgetappreducedbadproperties.jar"); + + depService.addURL(budgetappJar.toURI().toURL()); + depService.resolveOneDependency(); + + Collection dependencies = depService.drainDeterminedDependencies(); + assertEquals(1, dependencies.size()); + Dependency dependency = dependencies.iterator().next(); + assertEquals("org.yaml:snakeyaml", dependency.name); + assertEquals("1.17", dependency.version); + } + + @Test + void transformerInvalidCodeSource() throws MalformedURLException, IllegalClassFormatException { + Instrumentation instrumentation = mock(Instrumentation.class); + + depService.installOn(instrumentation); + + ArgumentCaptor transformerCaptor = ArgumentCaptor.forClass(ClassFileTransformer.class); + verify(instrumentation).addTransformer(transformerCaptor.capture()); + ClassFileTransformer transformer = transformerCaptor.getValue(); + assertNotNull(transformer); + + // null protection domain + transformer.transform(null, null, null, null, null); + depService.resolveOneDependency(); + assertTrue(depService.drainDeterminedDependencies().isEmpty()); + + // null code source + ProtectionDomain protectionDomainWithoutCodeSource = new ProtectionDomain(null, null, null, null); + transformer.transform(null, null, null, protectionDomainWithoutCodeSource, null); + depService.resolveOneDependency(); + assertTrue(depService.drainDeterminedDependencies().isEmpty()); + + // null code source location + CodeSource codeSourceWithoutLocation = new CodeSource(null, (CodeSigner[]) null); + ProtectionDomain protectionDomainWithoutLocation = + new ProtectionDomain(codeSourceWithoutLocation, null, null, null); + transformer.transform(null, null, null, protectionDomainWithoutLocation, null); + depService.resolveOneDependency(); + assertNull(codeSourceWithoutLocation.getLocation()); + assertTrue(depService.drainDeterminedDependencies().isEmpty()); + + // null or invalid URI syntax + URL invalidUrl = new URL("http:// "); // this url is known to not be a valid URI + CodeSource codeSourceWithInvalidUrl = new CodeSource(invalidUrl, (CodeSigner[]) null); + ProtectionDomain protectionDomainWithInvalidUrl = + new ProtectionDomain(codeSourceWithInvalidUrl, null, null, null); + transformer.transform(null, null, null, protectionDomainWithInvalidUrl, null); + depService.resolveOneDependency(); + assertTrue(depService.drainDeterminedDependencies().isEmpty()); + } + + private static Map dependenciesByName(Collection dependencies) { + Map dependenciesByName = new HashMap<>(); + for (Dependency dependency : dependencies) { + dependenciesByName.put(dependency.name, dependency); + } + return dependenciesByName; } - return dependenciesByName; - } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTests.java b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTests.java index 63d327b4aae..a2e73a2e61b 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTests.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyServiceTests.java @@ -25,116 +25,110 @@ import org.junit.jupiter.api.Test; public class DependencyServiceTests { - DependencyService depService = new DependencyService(); - Closeable assemblyHandle; - TempFileProvider tempFileProvider; - - @AfterEach - public void teardown() throws IOException { - if (assemblyHandle != null) { - assemblyHandle.close(); + DependencyService depService = new DependencyService(); + Closeable assemblyHandle; + TempFileProvider tempFileProvider; + + @AfterEach + public void teardown() throws IOException { + if (assemblyHandle != null) { + assemblyHandle.close(); + } + if (tempFileProvider != null) { + tempFileProvider.close(); + } } - if (tempFileProvider != null) { - tempFileProvider.close(); - } - } - private ClassFileTransformer captureTransformer() { - final ClassFileTransformer[] t = {null}; - Instrumentation instrumentation = - (Instrumentation) - Proxy.newProxyInstance( + private ClassFileTransformer captureTransformer() { + final ClassFileTransformer[] t = {null}; + Instrumentation instrumentation = (Instrumentation) Proxy.newProxyInstance( DependencyServiceTests.class.getClassLoader(), new Class[] {Instrumentation.class}, (proxy, method, args) -> { - if (method.getName().equals("addTransformer")) { - t[0] = (ClassFileTransformer) args[0]; - } else { - throw new UnsupportedOperationException(); - } - return null; + if (method.getName().equals("addTransformer")) { + t[0] = (ClassFileTransformer) args[0]; + } else { + throw new UnsupportedOperationException(); + } + return null; }); - depService.installOn(instrumentation); - assert t[0] != null; - return t[0]; - } + depService.installOn(instrumentation); + assert t[0] != null; + return t[0]; + } + + private Dependency identifyDependency(ClassFileTransformer t, String url) + throws MalformedURLException, IllegalClassFormatException { + VirtualFile virtualGroovyJar = VFS.getChild(url); + URL groovyJarURL = virtualGroovyJar.toURL(); - private Dependency identifyDependency(ClassFileTransformer t, String url) - throws MalformedURLException, IllegalClassFormatException { - VirtualFile virtualGroovyJar = VFS.getChild(url); - URL groovyJarURL = virtualGroovyJar.toURL(); + CodeSource codeSource = new CodeSource(groovyJarURL, (Certificate[]) null); + ProtectionDomain domain = new ProtectionDomain(codeSource, null); + t.transform(getClass().getClassLoader(), "class.name", Object.class, domain, new byte[0]); + depService.run(); // for test enforce instant dependency resolution + Collection deps = depService.drainDeterminedDependencies(); - CodeSource codeSource = new CodeSource(groovyJarURL, (Certificate[]) null); - ProtectionDomain domain = new ProtectionDomain(codeSource, null); - t.transform(getClass().getClassLoader(), "class.name", Object.class, domain, new byte[0]); - depService.run(); // for test enforce instant dependency resolution - Collection deps = depService.drainDeterminedDependencies(); + if (deps.isEmpty()) { + return null; + } - if (deps.isEmpty()) { - return null; + return deps.iterator().next(); } - return deps.iterator().next(); - } - - @Test - public void jboss_vfs_url() throws IOException, IllegalClassFormatException { - ClassFileTransformer t = captureTransformer(); + @Test + public void jboss_vfs_url() throws IOException, IllegalClassFormatException { + ClassFileTransformer t = captureTransformer(); - VirtualFileAssembly assembly = new VirtualFileAssembly(); - VirtualFile assemblyLocation = VFS.getChild("assembly.jar"); - assemblyHandle = VFS.mountAssembly(assembly, assemblyLocation); + VirtualFileAssembly assembly = new VirtualFileAssembly(); + VirtualFile assemblyLocation = VFS.getChild("assembly.jar"); + assemblyHandle = VFS.mountAssembly(assembly, assemblyLocation); - VirtualFile virtualDir = - VFS.getChild( - ClassLoader.getSystemClassLoader() + VirtualFile virtualDir = VFS.getChild(ClassLoader.getSystemClassLoader() .getResource("datadog/telemetry/dependencies/") .getPath()); - assembly.add("/groovy.jar", virtualDir.getChild("groovy-manifest.jar")); - - Dependency dep = identifyDependency(t, "assembly.jar/groovy.jar"); - assertNotNull(dep); - - // XXX: This should be `groovy` instead of `groovy-manifest` (ideally). However, we only - // have the bundle symbolic name with `groovy` and it is not always reliable. - assertEquals("groovy-manifest", dep.name); - assertEquals("2.4.12", dep.version); - assertEquals("groovy-manifest.jar", dep.source); - assertEquals("04DF0875A66F111880217FE1C5C59CA877403239", dep.hash); - } - - @Test - public void jboss_vfs_zip_url() throws IOException, IllegalClassFormatException { - ClassFileTransformer t = captureTransformer(); - - VirtualFile zipFile = - VFS.getChild( - ClassLoader.getSystemClassLoader() + assembly.add("/groovy.jar", virtualDir.getChild("groovy-manifest.jar")); + + Dependency dep = identifyDependency(t, "assembly.jar/groovy.jar"); + assertNotNull(dep); + + // XXX: This should be `groovy` instead of `groovy-manifest` (ideally). However, we only + // have the bundle symbolic name with `groovy` and it is not always reliable. + assertEquals("groovy-manifest", dep.name); + assertEquals("2.4.12", dep.version); + assertEquals("groovy-manifest.jar", dep.source); + assertEquals("04DF0875A66F111880217FE1C5C59CA877403239", dep.hash); + } + + @Test + public void jboss_vfs_zip_url() throws IOException, IllegalClassFormatException { + ClassFileTransformer t = captureTransformer(); + + VirtualFile zipFile = VFS.getChild(ClassLoader.getSystemClassLoader() .getResource("datadog/telemetry/dependencies/junit.zip") .getPath()); - VirtualFile mountPoint = VFS.getChild("foo.zip"); - tempFileProvider = TempFileProvider.create("test", new ScheduledThreadPoolExecutor(2)); - assemblyHandle = VFS.mountZip(zipFile, mountPoint, tempFileProvider); - - Dependency dep = identifyDependency(t, "foo.zip/junit-4.12.jar"); - assertNotNull(dep); - - assertEquals("junit", dep.name); - assertEquals("4.12", dep.version); - assertEquals("junit-4.12.jar", dep.source); - assertEquals("4376590587C49AC6DA6935564233F36B092412AE", dep.hash); - } - - @Test - public void jboss_nonexistent_file() throws IOException, IllegalClassFormatException { - ClassFileTransformer t = captureTransformer(); - - VirtualFileAssembly assembly = new VirtualFileAssembly(); - VirtualFile assemblyLocation = VFS.getChild("assembly.jar"); - assemblyHandle = VFS.mountAssembly(assembly, assemblyLocation); - - Dependency dep = identifyDependency(t, "assembly.jar/nonexistent-1.2.3.jar"); - assertNull(dep); - } + VirtualFile mountPoint = VFS.getChild("foo.zip"); + tempFileProvider = TempFileProvider.create("test", new ScheduledThreadPoolExecutor(2)); + assemblyHandle = VFS.mountZip(zipFile, mountPoint, tempFileProvider); + + Dependency dep = identifyDependency(t, "foo.zip/junit-4.12.jar"); + assertNotNull(dep); + + assertEquals("junit", dep.name); + assertEquals("4.12", dep.version); + assertEquals("junit-4.12.jar", dep.source); + assertEquals("4376590587C49AC6DA6935564233F36B092412AE", dep.hash); + } + + @Test + public void jboss_nonexistent_file() throws IOException, IllegalClassFormatException { + ClassFileTransformer t = captureTransformer(); + + VirtualFileAssembly assembly = new VirtualFileAssembly(); + VirtualFile assemblyLocation = VFS.getChild("assembly.jar"); + assemblyHandle = VFS.mountAssembly(assembly, assemblyLocation); + + Dependency dep = identifyDependency(t, "assembly.jar/nonexistent-1.2.3.jar"); + assertNull(dep); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyTestHelper.java b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyTestHelper.java index 009b00e5012..a60a749ad8e 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/DependencyTestHelper.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/DependencyTestHelper.java @@ -6,15 +6,14 @@ class DependencyTestHelper { - private DependencyTestHelper() {} + private DependencyTestHelper() {} - static File getJar(String jarName) { - String path = - ClassLoader.getSystemClassLoader() - .getResource("datadog/telemetry/dependencies/" + jarName) - .getPath(); - File jarFile = new File(path); - assertTrue(jarFile.isFile()); - return jarFile; - } + static File getJar(String jarName) { + String path = ClassLoader.getSystemClassLoader() + .getResource("datadog/telemetry/dependencies/" + jarName) + .getPath(); + File jarFile = new File(path); + assertTrue(jarFile.isFile()); + return jarFile; + } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/JarReaderTest.java b/telemetry/src/test/java/datadog/telemetry/dependency/JarReaderTest.java index 391a8590d87..6ad2cfc62a2 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/JarReaderTest.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/JarReaderTest.java @@ -12,101 +12,95 @@ class JarReaderTest { - @Test - void readPlainJarWithManifestAndNoPomProperties() throws IOException { - String jarPath = getJar("bson-4.2.0.jar").getAbsolutePath(); - - JarReader.Extracted result = JarReader.readJarFile(jarPath); - - assertEquals("bson-4.2.0.jar", result.jarName); - assertTrue(result.pomProperties.isEmpty()); - assertNotNull(result.manifest); - assertEquals("bson", result.manifest.getValue("Bundle-Name")); - } - - @Test - void readJarWithoutManifest() throws IOException { - String jarPath = getJar("groovy-no-manifest-info.jar").getAbsolutePath(); - - JarReader.Extracted result = JarReader.readJarFile(jarPath); - - assertEquals("groovy-no-manifest-info.jar", result.jarName); - assertTrue(result.pomProperties.isEmpty()); - assertNotNull(result.manifest); - assertTrue(result.manifest.isEmpty()); - } - - @Test - void readPlainJarWithManifestAndPomProperties() throws IOException { - String jarPath = getJar("commons-logging-1.2.jar").getAbsolutePath(); - - JarReader.Extracted result = JarReader.readJarFile(jarPath); - - assertEquals("commons-logging-1.2.jar", result.jarName); - assertEquals(1, result.pomProperties.size()); - Properties properties = - result.pomProperties.get("META-INF/maven/commons-logging/commons-logging/pom.properties"); - assertNotNull(properties); - assertEquals("commons-logging", properties.getProperty("groupId")); - assertEquals("commons-logging", properties.getProperty("artifactId")); - assertEquals("1.2", properties.getProperty("version")); - assertNotNull(result.manifest); - assertEquals("Apache Commons Logging", result.manifest.getValue("Bundle-Name")); - } - - @Test - void readNestedJar() throws IOException { - String outerPath = getJar("spring-boot-app.jar").getAbsolutePath(); - - JarReader.Extracted result = - JarReader.readNestedJarFile(outerPath, "BOOT-INF/lib/opentracing-util-0.33.0.jar"); - - assertEquals("opentracing-util-0.33.0.jar", result.jarName); - assertEquals(1, result.pomProperties.size()); - Properties properties = - result.pomProperties.get("META-INF/maven/io.opentracing/opentracing-util/pom.properties"); - assertNotNull(properties); - assertEquals("io.opentracing", properties.getProperty("groupId")); - assertEquals("opentracing-util", properties.getProperty("artifactId")); - assertEquals("0.33.0", properties.getProperty("version")); - assertNotNull(result.manifest); - assertEquals("io.opentracing.util", result.manifest.getValue("Automatic-Module-Name")); - } - - @Test - void nonExistentSimpleJar() { - assertThrows(IOException.class, () -> JarReader.readJarFile("non-existent.jar")); - } - - @Test - void nonExistentOuterJarForNestedJar() { - assertThrows( - IOException.class, - () -> - JarReader.readNestedJarFile( - "non-existent.jar", "BOOT-INF/lib/opentracing-util-0.33.0.jar")); - } - - @Test - void nonExistentInnerJarForNestedJar() throws IOException { - String outerPath = getJar("spring-boot-app.jar").getAbsolutePath(); - - assertThrows( - IOException.class, - () -> JarReader.readNestedJarFile(outerPath, "BOOT-INF/lib/non-existent.jar")); - } - - @Test - void doublyNestedJarPath() { - assertThrows( - IOException.class, - () -> - JarReader.readNestedJarFile( - "non-existent.jar", "BOOT-INF/lib/opentracing-util-0.33.0.jar/third")); - } - - @Test - void emptyNestedJarPath() { - assertThrows(IOException.class, () -> JarReader.readNestedJarFile("non-existent.jar", "")); - } + @Test + void readPlainJarWithManifestAndNoPomProperties() throws IOException { + String jarPath = getJar("bson-4.2.0.jar").getAbsolutePath(); + + JarReader.Extracted result = JarReader.readJarFile(jarPath); + + assertEquals("bson-4.2.0.jar", result.jarName); + assertTrue(result.pomProperties.isEmpty()); + assertNotNull(result.manifest); + assertEquals("bson", result.manifest.getValue("Bundle-Name")); + } + + @Test + void readJarWithoutManifest() throws IOException { + String jarPath = getJar("groovy-no-manifest-info.jar").getAbsolutePath(); + + JarReader.Extracted result = JarReader.readJarFile(jarPath); + + assertEquals("groovy-no-manifest-info.jar", result.jarName); + assertTrue(result.pomProperties.isEmpty()); + assertNotNull(result.manifest); + assertTrue(result.manifest.isEmpty()); + } + + @Test + void readPlainJarWithManifestAndPomProperties() throws IOException { + String jarPath = getJar("commons-logging-1.2.jar").getAbsolutePath(); + + JarReader.Extracted result = JarReader.readJarFile(jarPath); + + assertEquals("commons-logging-1.2.jar", result.jarName); + assertEquals(1, result.pomProperties.size()); + Properties properties = + result.pomProperties.get("META-INF/maven/commons-logging/commons-logging/pom.properties"); + assertNotNull(properties); + assertEquals("commons-logging", properties.getProperty("groupId")); + assertEquals("commons-logging", properties.getProperty("artifactId")); + assertEquals("1.2", properties.getProperty("version")); + assertNotNull(result.manifest); + assertEquals("Apache Commons Logging", result.manifest.getValue("Bundle-Name")); + } + + @Test + void readNestedJar() throws IOException { + String outerPath = getJar("spring-boot-app.jar").getAbsolutePath(); + + JarReader.Extracted result = JarReader.readNestedJarFile(outerPath, "BOOT-INF/lib/opentracing-util-0.33.0.jar"); + + assertEquals("opentracing-util-0.33.0.jar", result.jarName); + assertEquals(1, result.pomProperties.size()); + Properties properties = + result.pomProperties.get("META-INF/maven/io.opentracing/opentracing-util/pom.properties"); + assertNotNull(properties); + assertEquals("io.opentracing", properties.getProperty("groupId")); + assertEquals("opentracing-util", properties.getProperty("artifactId")); + assertEquals("0.33.0", properties.getProperty("version")); + assertNotNull(result.manifest); + assertEquals("io.opentracing.util", result.manifest.getValue("Automatic-Module-Name")); + } + + @Test + void nonExistentSimpleJar() { + assertThrows(IOException.class, () -> JarReader.readJarFile("non-existent.jar")); + } + + @Test + void nonExistentOuterJarForNestedJar() { + assertThrows( + IOException.class, + () -> JarReader.readNestedJarFile("non-existent.jar", "BOOT-INF/lib/opentracing-util-0.33.0.jar")); + } + + @Test + void nonExistentInnerJarForNestedJar() throws IOException { + String outerPath = getJar("spring-boot-app.jar").getAbsolutePath(); + + assertThrows(IOException.class, () -> JarReader.readNestedJarFile(outerPath, "BOOT-INF/lib/non-existent.jar")); + } + + @Test + void doublyNestedJarPath() { + assertThrows( + IOException.class, + () -> JarReader.readNestedJarFile( + "non-existent.jar", "BOOT-INF/lib/opentracing-util-0.33.0.jar/third")); + } + + @Test + void emptyNestedJarPath() { + assertThrows(IOException.class, () -> JarReader.readNestedJarFile("non-existent.jar", "")); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/dependency/LocationsCollectingTransformerTest.java b/telemetry/src/test/java/datadog/telemetry/dependency/LocationsCollectingTransformerTest.java index 32cebec980f..f8c42c29d0c 100644 --- a/telemetry/src/test/java/datadog/telemetry/dependency/LocationsCollectingTransformerTest.java +++ b/telemetry/src/test/java/datadog/telemetry/dependency/LocationsCollectingTransformerTest.java @@ -31,181 +31,169 @@ class LocationsCollectingTransformerTest { - private final DependencyService depService = new DependencyService(); - private final LocationsCollectingTransformer transformer = - new LocationsCollectingTransformer(depService); - - @Test - void noDependencyIfNullProtectionDomain() { - transformer.transform(null, null, null, null, null); - - depService.resolveOneDependency(); - Collection dependencies = depService.drainDeterminedDependencies(); - - assertTrue(dependencies.isEmpty()); - } - - @Test - void noDependencyIfNullCodeSource() { - ProtectionDomain domain = new ProtectionDomain(null, null); - transformer.transform(null, null, null, domain, null); - - depService.resolveOneDependency(); - Collection dependencies = depService.drainDeterminedDependencies(); - - assertTrue(dependencies.isEmpty()); - } - - @Test - void noDependencyIfNullLocation() { - CodeSource source = new CodeSource(null, (Certificate[]) null); - ProtectionDomain domain = new ProtectionDomain(source, null); - transformer.transform(null, null, null, domain, null); - - depService.resolveOneDependency(); - Collection dependencies = depService.drainDeterminedDependencies(); - - assertTrue(dependencies.isEmpty()); - } - - @Test - void oneDependencyIfNormalUrl() throws IOException { - CodeSource source = - new CodeSource(getJar("bson-4.2.0.jar").toURI().toURL(), (Certificate[]) null); - ProtectionDomain domain = new ProtectionDomain(source, null); - transformer.transform(null, null, null, domain, null); - - depService.resolveOneDependency(); - Collection dependencies = depService.drainDeterminedDependencies(); - - assertEquals(1, dependencies.size()); - } - - @Test - void singleDependencyIfRepeatedProtectionDomain() throws IOException { - CodeSource source = - new CodeSource(getJar("bson-4.2.0.jar").toURI().toURL(), (Certificate[]) null); - ProtectionDomain domain = new ProtectionDomain(source, null); - transformer.transform(null, null, null, domain, null); - transformer.transform(null, null, null, domain, null); - - depService.resolveOneDependency(); - Collection dependencies = depService.drainDeterminedDependencies(); - - assertEquals(1, dependencies.size()); - } - - @Test - void multipleDependencies() throws MalformedURLException { - int domainCount = 1000; - List domains = new ArrayList<>(); - for (int i = 1; i <= domainCount; i++) { - CodeSource source = - new CodeSource(new URL("file:///bson-" + i + ".jar"), (Certificate[]) null); - domains.add(new ProtectionDomain(source, null)); + private final DependencyService depService = new DependencyService(); + private final LocationsCollectingTransformer transformer = new LocationsCollectingTransformer(depService); + + @Test + void noDependencyIfNullProtectionDomain() { + transformer.transform(null, null, null, null, null); + + depService.resolveOneDependency(); + Collection dependencies = depService.drainDeterminedDependencies(); + + assertTrue(dependencies.isEmpty()); + } + + @Test + void noDependencyIfNullCodeSource() { + ProtectionDomain domain = new ProtectionDomain(null, null); + transformer.transform(null, null, null, domain, null); + + depService.resolveOneDependency(); + Collection dependencies = depService.drainDeterminedDependencies(); + + assertTrue(dependencies.isEmpty()); + } + + @Test + void noDependencyIfNullLocation() { + CodeSource source = new CodeSource(null, (Certificate[]) null); + ProtectionDomain domain = new ProtectionDomain(source, null); + transformer.transform(null, null, null, domain, null); + + depService.resolveOneDependency(); + Collection dependencies = depService.drainDeterminedDependencies(); + + assertTrue(dependencies.isEmpty()); } - DependencyService mockDepService = mock(DependencyService.class); - LocationsCollectingTransformer mockTransformer = - new LocationsCollectingTransformer(mockDepService); + @Test + void oneDependencyIfNormalUrl() throws IOException { + CodeSource source = new CodeSource(getJar("bson-4.2.0.jar").toURI().toURL(), (Certificate[]) null); + ProtectionDomain domain = new ProtectionDomain(source, null); + transformer.transform(null, null, null, domain, null); - for (ProtectionDomain domain : domains) { - mockTransformer.transform(null, null, null, domain, null); + depService.resolveOneDependency(); + Collection dependencies = depService.drainDeterminedDependencies(); + + assertEquals(1, dependencies.size()); + } + + @Test + void singleDependencyIfRepeatedProtectionDomain() throws IOException { + CodeSource source = new CodeSource(getJar("bson-4.2.0.jar").toURI().toURL(), (Certificate[]) null); + ProtectionDomain domain = new ProtectionDomain(source, null); + transformer.transform(null, null, null, domain, null); + transformer.transform(null, null, null, domain, null); + + depService.resolveOneDependency(); + Collection dependencies = depService.drainDeterminedDependencies(); + + assertEquals(1, dependencies.size()); } - verify(mockDepService, times(domainCount)).addURL(any(URL.class)); - verifyNoMoreInteractions(mockDepService); - } - - @Test - @Timeout(10) - void multipleDependenciesWithConcurrency() throws Exception { - int threads = 16; - ExecutorService executor = Executors.newFixedThreadPool(threads); - CountDownLatch latch = new CountDownLatch(threads); - try { - int domainCount = 3000; - BlockingQueue domains = new ArrayBlockingQueue<>(domainCount); - for (int i = 1; i <= domainCount; i++) { - CodeSource source = - new CodeSource(new URL("file:///bson-" + i + ".jar"), (Certificate[]) null); - domains.add(new ProtectionDomain(source, null)); - } - - DependencyService mockDepService = mock(DependencyService.class); - LocationsCollectingTransformer mockTransformer = - new LocationsCollectingTransformer(mockDepService); - - List> futures = new ArrayList<>(); - for (int i = 0; i < threads; i++) { - futures.add( - executor.submit( - () -> { - latch.countDown(); - awaitUninterruptibly(latch); - ProtectionDomain domain; - while ((domain = domains.poll()) != null) { - mockTransformer.transform(null, null, null, domain, null); - } + @Test + void multipleDependencies() throws MalformedURLException { + int domainCount = 1000; + List domains = new ArrayList<>(); + for (int i = 1; i <= domainCount; i++) { + CodeSource source = new CodeSource(new URL("file:///bson-" + i + ".jar"), (Certificate[]) null); + domains.add(new ProtectionDomain(source, null)); + } + + DependencyService mockDepService = mock(DependencyService.class); + LocationsCollectingTransformer mockTransformer = new LocationsCollectingTransformer(mockDepService); + + for (ProtectionDomain domain : domains) { + mockTransformer.transform(null, null, null, domain, null); + } + + verify(mockDepService, times(domainCount)).addURL(any(URL.class)); + verifyNoMoreInteractions(mockDepService); + } + + @Test + @Timeout(10) + void multipleDependenciesWithConcurrency() throws Exception { + int threads = 16; + ExecutorService executor = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads); + try { + int domainCount = 3000; + BlockingQueue domains = new ArrayBlockingQueue<>(domainCount); + for (int i = 1; i <= domainCount; i++) { + CodeSource source = new CodeSource(new URL("file:///bson-" + i + ".jar"), (Certificate[]) null); + domains.add(new ProtectionDomain(source, null)); + } + + DependencyService mockDepService = mock(DependencyService.class); + LocationsCollectingTransformer mockTransformer = new LocationsCollectingTransformer(mockDepService); + + List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + futures.add(executor.submit(() -> { + latch.countDown(); + awaitUninterruptibly(latch); + ProtectionDomain domain; + while ((domain = domains.poll()) != null) { + mockTransformer.transform(null, null, null, domain, null); + } })); - } - for (Future future : futures) { - future.get(); - } - - verify(mockDepService, times(domainCount)).addURL(any(URL.class)); - verifyNoMoreInteractions(mockDepService); - } finally { - executor.shutdownNow(); + } + for (Future future : futures) { + future.get(); + } + + verify(mockDepService, times(domainCount)).addURL(any(URL.class)); + verifyNoMoreInteractions(mockDepService); + } finally { + executor.shutdownNow(); + } } - } - - @Test - @Timeout(10) - void singleDependenciesWithConcurrency() throws Exception { - int threads = 16; - ExecutorService executor = Executors.newFixedThreadPool(threads); - CountDownLatch latch = new CountDownLatch(threads); - try { - int iterationsPerThread = 3000; - CodeSource source = new CodeSource(new URL("file:///bson-1.jar"), (Certificate[]) null); - ProtectionDomain domain = new ProtectionDomain(source, null); - - DependencyService mockDepService = mock(DependencyService.class); - LocationsCollectingTransformer mockTransformer = - new LocationsCollectingTransformer(mockDepService); - - List> futures = new ArrayList<>(); - for (int i = 0; i < threads; i++) { - futures.add( - executor.submit( - () -> { - latch.countDown(); - awaitUninterruptibly(latch); - for (int j = 0; j < iterationsPerThread; j++) { - mockTransformer.transform(null, null, null, domain, null); - } + + @Test + @Timeout(10) + void singleDependenciesWithConcurrency() throws Exception { + int threads = 16; + ExecutorService executor = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads); + try { + int iterationsPerThread = 3000; + CodeSource source = new CodeSource(new URL("file:///bson-1.jar"), (Certificate[]) null); + ProtectionDomain domain = new ProtectionDomain(source, null); + + DependencyService mockDepService = mock(DependencyService.class); + LocationsCollectingTransformer mockTransformer = new LocationsCollectingTransformer(mockDepService); + + List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + futures.add(executor.submit(() -> { + latch.countDown(); + awaitUninterruptibly(latch); + for (int j = 0; j < iterationsPerThread; j++) { + mockTransformer.transform(null, null, null, domain, null); + } })); - } - for (Future future : futures) { - future.get(); - } - - // the shared protection domain is cached, so addURL should be called only once, but the - // cache population race allows up to one call per racing thread - verify(mockDepService, atLeastOnce()).addURL(any(URL.class)); - verify(mockDepService, atMost(threads)).addURL(any(URL.class)); - verifyNoMoreInteractions(mockDepService); - } finally { - executor.shutdownNow(); + } + for (Future future : futures) { + future.get(); + } + + // the shared protection domain is cached, so addURL should be called only once, but the + // cache population race allows up to one call per racing thread + verify(mockDepService, atLeastOnce()).addURL(any(URL.class)); + verify(mockDepService, atMost(threads)).addURL(any(URL.class)); + verifyNoMoreInteractions(mockDepService); + } finally { + executor.shutdownNow(); + } } - } - private static void awaitUninterruptibly(CountDownLatch latch) { - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + private static void awaitUninterruptibly(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } - } } diff --git a/telemetry/src/test/java/datadog/telemetry/endpoint/EndpointPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/endpoint/EndpointPeriodicActionTest.java index f04bfeec3d6..e6a5077115e 100644 --- a/telemetry/src/test/java/datadog/telemetry/endpoint/EndpointPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/endpoint/EndpointPeriodicActionTest.java @@ -19,56 +19,59 @@ class EndpointPeriodicActionTest { - @Test - void testThatEndpointsAreCapturedByThePeriodicAction() { - Endpoint endpoint = - new Endpoint().first(true).type(REST).method(GET).operation(HTTP_REQUEST).path("/test"); - TelemetryService service = mock(TelemetryService.class); - EndpointCollector endpointCollector = new EndpointCollector(); - endpointCollector.supplier(singletonList(endpoint).iterator()); - EndpointPeriodicAction action = new EndpointPeriodicAction(endpointCollector); - - action.doIteration(service); - - verify(service).addEndpoint(endpoint); - verifyNoMoreInteractions(service); - } - - @Test - void testThatEndpointsAreNotLostIfTheServiceIsAtCapacity() { - List endpoints = - asList( - new Endpoint() + @Test + void testThatEndpointsAreCapturedByThePeriodicAction() { + Endpoint endpoint = new Endpoint() .first(true) .type(REST) .method(GET) .operation(HTTP_REQUEST) - .path("/test1"), - new Endpoint().type(REST).method(GET).operation(HTTP_REQUEST).path("/test2"), - new Endpoint().type(REST).method(GET).operation(HTTP_REQUEST).path("/test3")); - TelemetryService service = mock(TelemetryService.class); - EndpointCollector endpointCollector = new EndpointCollector(); - endpointCollector.supplier(endpoints.iterator()); - EndpointPeriodicAction action = new EndpointPeriodicAction(endpointCollector); + .path("/test"); + TelemetryService service = mock(TelemetryService.class); + EndpointCollector endpointCollector = new EndpointCollector(); + endpointCollector.supplier(singletonList(endpoint).iterator()); + EndpointPeriodicAction action = new EndpointPeriodicAction(endpointCollector); + + action.doIteration(service); + + verify(service).addEndpoint(endpoint); + verifyNoMoreInteractions(service); + } + + @Test + void testThatEndpointsAreNotLostIfTheServiceIsAtCapacity() { + List endpoints = asList( + new Endpoint() + .first(true) + .type(REST) + .method(GET) + .operation(HTTP_REQUEST) + .path("/test1"), + new Endpoint().type(REST).method(GET).operation(HTTP_REQUEST).path("/test2"), + new Endpoint().type(REST).method(GET).operation(HTTP_REQUEST).path("/test3")); + TelemetryService service = mock(TelemetryService.class); + EndpointCollector endpointCollector = new EndpointCollector(); + endpointCollector.supplier(endpoints.iterator()); + EndpointPeriodicAction action = new EndpointPeriodicAction(endpointCollector); - // first iteration: the service accepts endpoints[0] but rejects endpoints[1] (at capacity) - when(service.addEndpoint(endpoints.get(0))).thenReturn(true); - when(service.addEndpoint(endpoints.get(1))).thenReturn(false); + // first iteration: the service accepts endpoints[0] but rejects endpoints[1] (at capacity) + when(service.addEndpoint(endpoints.get(0))).thenReturn(true); + when(service.addEndpoint(endpoints.get(1))).thenReturn(false); - action.doIteration(service); + action.doIteration(service); - verify(service).addEndpoint(endpoints.get(0)); - verify(service).addEndpoint(endpoints.get(1)); - verifyNoMoreInteractions(service); + verify(service).addEndpoint(endpoints.get(0)); + verify(service).addEndpoint(endpoints.get(1)); + verifyNoMoreInteractions(service); - // second iteration: the rejected endpoints[1] is retried first, then endpoints[2] follows - when(service.addEndpoint(endpoints.get(1))).thenReturn(true); - when(service.addEndpoint(endpoints.get(2))).thenReturn(true); + // second iteration: the rejected endpoints[1] is retried first, then endpoints[2] follows + when(service.addEndpoint(endpoints.get(1))).thenReturn(true); + when(service.addEndpoint(endpoints.get(2))).thenReturn(true); - action.doIteration(service); + action.doIteration(service); - verify(service, times(2)).addEndpoint(endpoints.get(1)); - verify(service).addEndpoint(endpoints.get(2)); - verifyNoMoreInteractions(service); - } + verify(service, times(2)).addEndpoint(endpoints.get(1)); + verify(service).addEndpoint(endpoints.get(2)); + verifyNoMoreInteractions(service); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/integration/IntegrationPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/integration/IntegrationPeriodicActionTest.java index 91fc3fe4904..dfb2b05a6f2 100644 --- a/telemetry/src/test/java/datadog/telemetry/integration/IntegrationPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/integration/IntegrationPeriodicActionTest.java @@ -19,30 +19,29 @@ class IntegrationPeriodicActionTest { - private final IntegrationPeriodicAction periodicAction = new IntegrationPeriodicAction(); - private final TelemetryService telemetryService = mock(TelemetryService.class); - - @Test - void pushIntegrationsIntoTheTelemetryService() { - IntegrationsCollector.get().update(Arrays.asList("web", "jdbc"), true); - - periodicAction.doIteration(telemetryService); - - ArgumentCaptor captor = forClass(Integration.class); - verify(telemetryService, times(2)).addIntegration(captor.capture()); - verifyNoMoreInteractions(telemetryService); - - List integrations = captor.getAllValues(); - assertMatchingIntegration(integrations, "web"); - assertMatchingIntegration(integrations, "jdbc"); - } - - private void assertMatchingIntegration(List integrations, String name) { - List matches = - integrations.stream() - .filter(integration -> integration.name.equals(name)) - .collect(Collectors.toList()); - assertEquals(1, matches.size(), "expected exactly one match for " + name); - assertTrue(matches.get(0).enabled); - } + private final IntegrationPeriodicAction periodicAction = new IntegrationPeriodicAction(); + private final TelemetryService telemetryService = mock(TelemetryService.class); + + @Test + void pushIntegrationsIntoTheTelemetryService() { + IntegrationsCollector.get().update(Arrays.asList("web", "jdbc"), true); + + periodicAction.doIteration(telemetryService); + + ArgumentCaptor captor = forClass(Integration.class); + verify(telemetryService, times(2)).addIntegration(captor.capture()); + verifyNoMoreInteractions(telemetryService); + + List integrations = captor.getAllValues(); + assertMatchingIntegration(integrations, "web"); + assertMatchingIntegration(integrations, "jdbc"); + } + + private void assertMatchingIntegration(List integrations, String name) { + List matches = integrations.stream() + .filter(integration -> integration.name.equals(name)) + .collect(Collectors.toList()); + assertEquals(1, matches.size(), "expected exactly one match for " + name); + assertTrue(matches.get(0).enabled); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/log/LogPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/log/LogPeriodicActionTest.java index 8104a5d679e..b5bf1b4ff86 100644 --- a/telemetry/src/test/java/datadog/telemetry/log/LogPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/log/LogPeriodicActionTest.java @@ -20,293 +20,277 @@ @WithConfig(key = "instrumentation.telemetry.debug", value = "true") class LogPeriodicActionTest { - private final LogPeriodicAction periodicAction = new LogPeriodicAction(); - private final TelemetryService telemetryService = mock(TelemetryService.class); - - @BeforeEach - void setup() { - LogCollector.get().drain(); - } - - @AfterEach - void cleanup() { - LogCollector.get().drain(); - } - - @Test - void logWithTags() { - LogCollector.get().addLogMessage("ERROR", "test", null, "tag1:value1,tag2:value2"); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals(ERROR, logMessage.getLevel()); - assertEquals("test", logMessage.getMessage()); - assertEquals("tag1:value1,tag2:value2", logMessage.getTags()); - } - - @Test - void logWithDatadogThrowable() { - Throwable throwable = throwable("exception", stacktrace(frame("datadog.MyClass"))); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals( - MutableException.class.getCanonicalName() + "\n" + " at datadog.MyClass.method(file:42)\n", - logMessage.getStackTrace()); - } - - @Test - void logWithNonDatadogThrowable() { - Throwable throwable = throwable("exception", stacktrace(frame("java.MyClass"))); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals( - MutableException.class.getCanonicalName() + "\n" + " at java.MyClass.method(file:42)\n", - logMessage.getStackTrace()); - } - - @Test - void logWithDatadogThrowableWithoutStacktrace() { - Throwable throwable = throwable("exception", new StackTraceElement[0]); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals(MutableException.class.getCanonicalName() + "\n", logMessage.getStackTrace()); - } - - @Test - void deduplicationOfLogMessagesWithoutException() { - LogCollector.get().addLogMessage(ERROR.toString(), "test", null); - LogCollector.get().addLogMessage(ERROR.toString(), "test", null); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals(2, logMessage.getCount()); - } - - @Test - void deduplicationOfLogMessagesWithException() { - Throwable throwable1 = throwable("exception", stacktrace(frame("datadog.MyClass"))); - Throwable throwable2 = throwable("exception", stacktrace(frame("datadog.MyClass"))); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable1); - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable2); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertNotNull(logMessage.getStackTrace()); - assertEquals(2, logMessage.getCount()); - } - - @Test - void stacktraceRedaction() { - Throwable throwable = - throwable( - "exception", - stacktrace( - frame(""), - frame("java.MyClass"), - frame("mycorp.MyClass"), - frame("datadog.MyClass"), - frame("mycorp.MyClass"), - frame("mycorp.MyClass"))); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals( - MutableException.class.getCanonicalName() - + "\n" - + " at (redacted)\n" - + " at java.MyClass.method(file:42)\n" - + " at (redacted)\n" - + " at datadog.MyClass.method(file:42)\n" - + " at (redacted: 2 frames)\n", - logMessage.getStackTrace()); - } - - @Test - void stacktraceWithMultipleFramesAndCommonFrames() { - Throwable cause = - throwable( - "exception 2", - stacktrace( - frame("java.MyClass"), - frame("mycorp.MyClass"), - frame("datadog.MyClass"), - frame("mycorp.MyClass"), - frame("mycorp.MyClass"))); - Throwable throwable = - throwable( - "exception", - stacktrace( - frame(""), - frame("datadog.MyClass"), - frame("mycorp.MyClass"), - frame("mycorp.MyClass")), - cause); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals( - MutableException.class.getCanonicalName() - + "\n" - + " at (redacted)\n" - + " at datadog.MyClass.method(file:42)\n" - + " at (redacted: 2 frames)\n" - + "Caused by: " - + MutableException.class.getCanonicalName() - + "\n" - + " at java.MyClass.method(file:42)\n" - + " at (redacted)\n" - + " ... 3 more\n", - logMessage.getStackTrace()); - } - - @Test - void stacktraceWithCommonFramesOnly() { - Throwable innerCause = - throwable( - "exception 3", - stacktrace( - frame("java.MyClass"), - frame("mycorp.MyClass"), - frame("datadog.MyClass"), - frame("mycorp.MyClass"), - frame("mycorp.MyClass"))); - Throwable cause = - throwable( - "exception 2", - stacktrace( - frame("java.MyClass"), - frame("mycorp.MyClass"), - frame("datadog.MyClass"), - frame("mycorp.MyClass"), - frame("mycorp.MyClass")), - innerCause); - Throwable throwable = - throwable( - "exception", - stacktrace( - frame("java.MyClass"), - frame("mycorp.MyClass"), - frame("datadog.MyClass"), - frame("mycorp.MyClass"), - frame("mycorp.MyClass")), - cause); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals( - MutableException.class.getCanonicalName() - + "\n" - + " at java.MyClass.method(file:42)\n" - + " at (redacted)\n" - + " at datadog.MyClass.method(file:42)\n" - + " at (redacted: 2 frames)\n" - + "Caused by: " - + MutableException.class.getCanonicalName() - + "\n" - + " ... 5 more\n" - + "Caused by: " - + MutableException.class.getCanonicalName() - + "\n" - + " ... 5 more\n", - logMessage.getStackTrace()); - } - - @Test - void stacktraceWithoutCommonFrames() { - Throwable cause = - throwable( - "exception 2", - stacktrace( - frame("java.MyClass"), - frame("org.datadog.Test"), - frame("io.DataTest"), - frame("dd.MainClass"))); - Throwable throwable = - throwable( - "exception", - stacktrace( - frame("java.MyClass"), - frame("mycorp.MyClass"), - frame("datadog.MyClass"), - frame("mycorp.MyClass"), - frame("mycorp.MyClass")), - cause); - - LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); - periodicAction.doIteration(telemetryService); - - LogMessage logMessage = captureSingleLogMessage(); - assertEquals("test", logMessage.getMessage()); - assertEquals( - MutableException.class.getCanonicalName() - + "\n" - + " at java.MyClass.method(file:42)\n" - + " at (redacted)\n" - + " at datadog.MyClass.method(file:42)\n" - + " at (redacted: 2 frames)\n" - + "Caused by: " - + MutableException.class.getCanonicalName() - + "\n" - + " at java.MyClass.method(file:42)\n" - + " at (redacted: 3 frames)\n", - logMessage.getStackTrace()); - } - - private LogMessage captureSingleLogMessage() { - ArgumentCaptor captor = ArgumentCaptor.forClass(LogMessage.class); - verify(telemetryService, times(1)).addLogMessage(captor.capture()); - verifyNoMoreInteractions(telemetryService); - return captor.getValue(); - } - - private static Throwable throwable(String message, StackTraceElement[] stacktrace) { - MutableException exception = new MutableException(message, null); - exception.setStackTrace(stacktrace); - return exception; - } - - private static Throwable throwable( - String message, StackTraceElement[] stacktrace, Throwable cause) { - MutableException exception = new MutableException(message, cause); - exception.setStackTrace(stacktrace); - return exception; - } - - private static StackTraceElement[] stacktrace(StackTraceElement... frames) { - return frames; - } - - private static StackTraceElement frame(String className) { - return new StackTraceElement(className, "method", "file", 42); - } - - private static final class MutableException extends Exception { - MutableException(String message, Throwable cause) { - super(message, cause, true, true); + private final LogPeriodicAction periodicAction = new LogPeriodicAction(); + private final TelemetryService telemetryService = mock(TelemetryService.class); + + @BeforeEach + void setup() { + LogCollector.get().drain(); + } + + @AfterEach + void cleanup() { + LogCollector.get().drain(); + } + + @Test + void logWithTags() { + LogCollector.get().addLogMessage("ERROR", "test", null, "tag1:value1,tag2:value2"); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals(ERROR, logMessage.getLevel()); + assertEquals("test", logMessage.getMessage()); + assertEquals("tag1:value1,tag2:value2", logMessage.getTags()); + } + + @Test + void logWithDatadogThrowable() { + Throwable throwable = throwable("exception", stacktrace(frame("datadog.MyClass"))); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals( + MutableException.class.getCanonicalName() + "\n" + " at datadog.MyClass.method(file:42)\n", + logMessage.getStackTrace()); + } + + @Test + void logWithNonDatadogThrowable() { + Throwable throwable = throwable("exception", stacktrace(frame("java.MyClass"))); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals( + MutableException.class.getCanonicalName() + "\n" + " at java.MyClass.method(file:42)\n", + logMessage.getStackTrace()); + } + + @Test + void logWithDatadogThrowableWithoutStacktrace() { + Throwable throwable = throwable("exception", new StackTraceElement[0]); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals(MutableException.class.getCanonicalName() + "\n", logMessage.getStackTrace()); + } + + @Test + void deduplicationOfLogMessagesWithoutException() { + LogCollector.get().addLogMessage(ERROR.toString(), "test", null); + LogCollector.get().addLogMessage(ERROR.toString(), "test", null); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals(2, logMessage.getCount()); + } + + @Test + void deduplicationOfLogMessagesWithException() { + Throwable throwable1 = throwable("exception", stacktrace(frame("datadog.MyClass"))); + Throwable throwable2 = throwable("exception", stacktrace(frame("datadog.MyClass"))); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable1); + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable2); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertNotNull(logMessage.getStackTrace()); + assertEquals(2, logMessage.getCount()); + } + + @Test + void stacktraceRedaction() { + Throwable throwable = throwable( + "exception", + stacktrace( + frame(""), + frame("java.MyClass"), + frame("mycorp.MyClass"), + frame("datadog.MyClass"), + frame("mycorp.MyClass"), + frame("mycorp.MyClass"))); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals( + MutableException.class.getCanonicalName() + + "\n" + + " at (redacted)\n" + + " at java.MyClass.method(file:42)\n" + + " at (redacted)\n" + + " at datadog.MyClass.method(file:42)\n" + + " at (redacted: 2 frames)\n", + logMessage.getStackTrace()); + } + + @Test + void stacktraceWithMultipleFramesAndCommonFrames() { + Throwable cause = throwable( + "exception 2", + stacktrace( + frame("java.MyClass"), + frame("mycorp.MyClass"), + frame("datadog.MyClass"), + frame("mycorp.MyClass"), + frame("mycorp.MyClass"))); + Throwable throwable = throwable( + "exception", + stacktrace(frame(""), frame("datadog.MyClass"), frame("mycorp.MyClass"), frame("mycorp.MyClass")), + cause); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals( + MutableException.class.getCanonicalName() + + "\n" + + " at (redacted)\n" + + " at datadog.MyClass.method(file:42)\n" + + " at (redacted: 2 frames)\n" + + "Caused by: " + + MutableException.class.getCanonicalName() + + "\n" + + " at java.MyClass.method(file:42)\n" + + " at (redacted)\n" + + " ... 3 more\n", + logMessage.getStackTrace()); + } + + @Test + void stacktraceWithCommonFramesOnly() { + Throwable innerCause = throwable( + "exception 3", + stacktrace( + frame("java.MyClass"), + frame("mycorp.MyClass"), + frame("datadog.MyClass"), + frame("mycorp.MyClass"), + frame("mycorp.MyClass"))); + Throwable cause = throwable( + "exception 2", + stacktrace( + frame("java.MyClass"), + frame("mycorp.MyClass"), + frame("datadog.MyClass"), + frame("mycorp.MyClass"), + frame("mycorp.MyClass")), + innerCause); + Throwable throwable = throwable( + "exception", + stacktrace( + frame("java.MyClass"), + frame("mycorp.MyClass"), + frame("datadog.MyClass"), + frame("mycorp.MyClass"), + frame("mycorp.MyClass")), + cause); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals( + MutableException.class.getCanonicalName() + + "\n" + + " at java.MyClass.method(file:42)\n" + + " at (redacted)\n" + + " at datadog.MyClass.method(file:42)\n" + + " at (redacted: 2 frames)\n" + + "Caused by: " + + MutableException.class.getCanonicalName() + + "\n" + + " ... 5 more\n" + + "Caused by: " + + MutableException.class.getCanonicalName() + + "\n" + + " ... 5 more\n", + logMessage.getStackTrace()); + } + + @Test + void stacktraceWithoutCommonFrames() { + Throwable cause = throwable( + "exception 2", + stacktrace( + frame("java.MyClass"), frame("org.datadog.Test"), frame("io.DataTest"), frame("dd.MainClass"))); + Throwable throwable = throwable( + "exception", + stacktrace( + frame("java.MyClass"), + frame("mycorp.MyClass"), + frame("datadog.MyClass"), + frame("mycorp.MyClass"), + frame("mycorp.MyClass")), + cause); + + LogCollector.get().addLogMessage(ERROR.toString(), "test", throwable); + periodicAction.doIteration(telemetryService); + + LogMessage logMessage = captureSingleLogMessage(); + assertEquals("test", logMessage.getMessage()); + assertEquals( + MutableException.class.getCanonicalName() + + "\n" + + " at java.MyClass.method(file:42)\n" + + " at (redacted)\n" + + " at datadog.MyClass.method(file:42)\n" + + " at (redacted: 2 frames)\n" + + "Caused by: " + + MutableException.class.getCanonicalName() + + "\n" + + " at java.MyClass.method(file:42)\n" + + " at (redacted: 3 frames)\n", + logMessage.getStackTrace()); + } + + private LogMessage captureSingleLogMessage() { + ArgumentCaptor captor = ArgumentCaptor.forClass(LogMessage.class); + verify(telemetryService, times(1)).addLogMessage(captor.capture()); + verifyNoMoreInteractions(telemetryService); + return captor.getValue(); + } + + private static Throwable throwable(String message, StackTraceElement[] stacktrace) { + MutableException exception = new MutableException(message, null); + exception.setStackTrace(stacktrace); + return exception; + } + + private static Throwable throwable(String message, StackTraceElement[] stacktrace, Throwable cause) { + MutableException exception = new MutableException(message, cause); + exception.setStackTrace(stacktrace); + return exception; + } + + private static StackTraceElement[] stacktrace(StackTraceElement... frames) { + return frames; + } + + private static StackTraceElement frame(String className) { + return new StackTraceElement(className, "method", "file", 42); + } + + private static final class MutableException extends Exception { + MutableException(String message, Throwable cause) { + super(message, cause, true, true); + } } - } } diff --git a/telemetry/src/test/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicActionTest.java index 56a2ec80d52..18ae5501189 100644 --- a/telemetry/src/test/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/metric/ConfigInversionMetricPeriodicActionTest.java @@ -16,27 +16,25 @@ class ConfigInversionMetricPeriodicActionTest { - private final TelemetryService telemetryService = mock(TelemetryService.class); - private final ConfigInversionMetricPeriodicAction action = - new ConfigInversionMetricPeriodicAction(); - private final ConfigInversionMetricCollectorImpl collector = - ConfigInversionMetricCollectorImpl.getInstance(); + private final TelemetryService telemetryService = mock(TelemetryService.class); + private final ConfigInversionMetricPeriodicAction action = new ConfigInversionMetricPeriodicAction(); + private final ConfigInversionMetricCollectorImpl collector = ConfigInversionMetricCollectorImpl.getInstance(); - @Test - void testUndocumentedEnvVarMetric() { - collector.setUndocumentedEnvVarMetric("DD_ENV_VAR"); - collector.prepareMetrics(); - action.doIteration(telemetryService); + @Test + void testUndocumentedEnvVarMetric() { + collector.setUndocumentedEnvVarMetric("DD_ENV_VAR"); + collector.prepareMetrics(); + action.doIteration(telemetryService); - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(1)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(1)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); - Metric metric = captor.getValue(); - assertEquals("tracers", metric.getNamespace()); - assertEquals("untracked.config.detected", metric.getMetric()); - assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); - assertEquals(Arrays.asList("config_name:DD_ENV_VAR"), metric.getTags()); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - } + Metric metric = captor.getValue(); + assertEquals("tracers", metric.getNamespace()); + assertEquals("untracked.config.detected", metric.getMetric()); + assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); + assertEquals(Arrays.asList("config_name:DD_ENV_VAR"), metric.getTags()); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/metric/CoreMetricsPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/metric/CoreMetricsPeriodicActionTest.java index fff8fc144c7..a620934adb9 100644 --- a/telemetry/src/test/java/datadog/telemetry/metric/CoreMetricsPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/metric/CoreMetricsPeriodicActionTest.java @@ -19,138 +19,135 @@ class CoreMetricsPeriodicActionTest { - private final TelemetryService telemetryService = mock(TelemetryService.class); - private final CoreMetricsPeriodicAction action = new CoreMetricsPeriodicAction(); - private final SpanMetricRegistry spanMetricRegistry = SpanMetricRegistry.getInstance(); - - @Test - void testSpanMetricsWithMultipleOccurrenceEvents() { - SpanMetrics instr1SpanMetric = spanMetricRegistry.get("instr-1"); - SpanMetrics instr2SpanMetric = spanMetricRegistry.get("instr-2"); - - instr1SpanMetric.onSpanCreated(); - instr2SpanMetric.onSpanCreated(); - instr1SpanMetric.onSpanCreated(); - instr2SpanMetric.onSpanCreated(); - instr2SpanMetric.onSpanCreated(); - instr2SpanMetric.onSpanFinished(); - instr2SpanMetric.onSpanFinished(); - instr2SpanMetric.onSpanFinished(); - instr2SpanMetric.onSpanFinished(); - - action.collector().prepareMetrics(); - action.doIteration(telemetryService); - - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(3)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); - - List metrics = captor.getAllValues(); - assertMetric(metrics, "spans_created", "instr-1", 2); - assertMetric(metrics, "spans_created", "instr-2", 3); - assertMetric(metrics, "spans_finished", "instr-2", 4); - } - - @Test - void testSpanMetricsWithInterleavedEvents() { - SpanMetrics instr1SpanMetric = spanMetricRegistry.get("instr-1"); - SpanMetrics instr2SpanMetric = spanMetricRegistry.get("instr-2"); - SpanMetrics instr3SpanMetric = spanMetricRegistry.get("instr-3"); - SpanMetrics instrASpanMetric = spanMetricRegistry.get("instr-a"); - SpanMetrics instrBSpanMetric = spanMetricRegistry.get("instr-b"); - - instr1SpanMetric.onSpanCreated(); - instr2SpanMetric.onSpanCreated(); - instr3SpanMetric.onSpanCreated(); - instr3SpanMetric.onSpanFinished(); - instrASpanMetric.onSpanCreated(); - instrBSpanMetric.onSpanCreated(); - instrBSpanMetric.onSpanFinished(); - instr2SpanMetric.onSpanFinished(); - instrASpanMetric.onSpanFinished(); - instr1SpanMetric.onSpanFinished(); - - action.collector().prepareMetrics(); - action.doIteration(telemetryService); - - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(10)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); - - List metrics = captor.getAllValues(); - assertMetric(metrics, "spans_created", "instr-1", 1); - assertMetric(metrics, "spans_created", "instr-2", 1); - assertMetric(metrics, "spans_created", "instr-3", 1); - assertMetric(metrics, "spans_finished", "instr-3", 1); - assertMetric(metrics, "spans_created", "instr-a", 1); - assertMetric(metrics, "spans_created", "instr-b", 1); - assertMetric(metrics, "spans_finished", "instr-b", 1); - assertMetric(metrics, "spans_finished", "instr-2", 1); - assertMetric(metrics, "spans_finished", "instr-a", 1); - assertMetric(metrics, "spans_finished", "instr-1", 1); - } - - @Test - void testSpanMetrics() { - SpanMetrics instr1SpanMetric = spanMetricRegistry.get("instr-1"); - SpanMetrics instr2SpanMetric = spanMetricRegistry.get("instr-2"); - SpanMetrics instr3SpanMetric = spanMetricRegistry.get("instr-3"); - SpanMetrics instrASpanMetric = spanMetricRegistry.get("instr-a"); - SpanMetrics instrBSpanMetric = spanMetricRegistry.get("instr-b"); - - instr1SpanMetric.onSpanCreated(); - instr2SpanMetric.onSpanCreated(); - instr3SpanMetric.onSpanCreated(); - instr3SpanMetric.onSpanFinished(); - instrASpanMetric.onSpanCreated(); - instrBSpanMetric.onSpanCreated(); - instrBSpanMetric.onSpanFinished(); - instr2SpanMetric.onSpanFinished(); - instrASpanMetric.onSpanFinished(); - instr1SpanMetric.onSpanFinished(); - - action.collector().prepareMetrics(); - action.doIteration(telemetryService); - - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(10)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); - - List metrics = captor.getAllValues(); - assertMetric(metrics, "spans_created", "instr-1", 1); - assertMetric(metrics, "spans_created", "instr-2", 1); - assertMetric(metrics, "spans_created", "instr-3", 1); - assertMetric(metrics, "spans_finished", "instr-3", 1); - assertMetric(metrics, "spans_created", "instr-a", 1); - assertMetric(metrics, "spans_created", "instr-b", 1); - assertMetric(metrics, "spans_finished", "instr-b", 1); - assertMetric(metrics, "spans_finished", "instr-2", 1); - assertMetric(metrics, "spans_finished", "instr-a", 1); - assertMetric(metrics, "spans_finished", "instr-1", 1); - } - - /** - * MetricPeriodicAction aggregates metrics through a HashMap, so the emission order is not - * guaranteed; match each expected metric by its metric name and instrumentation tag rather than - * by capture position. - */ - private void assertMetric( - List metrics, String metricName, String instrumentationName, long count) { - List expectedTags = Arrays.asList("integration_name:" + instrumentationName); - List matches = - metrics.stream() - .filter(metric -> metric.getMetric().equals(metricName)) - .filter(metric -> expectedTags.equals(metric.getTags())) - .collect(Collectors.toList()); - assertEquals( - 1, matches.size(), "expected exactly one match for " + metricName + " " + expectedTags); - - Metric metric = matches.get(0); - assertEquals("tracers", metric.getNamespace()); - assertEquals(true, metric.getCommon()); - assertEquals(1, metric.getPoints().size()); - assertEquals(2, metric.getPoints().get(0).size()); - assertEquals(count, metric.getPoints().get(0).get(1).longValue()); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - } + private final TelemetryService telemetryService = mock(TelemetryService.class); + private final CoreMetricsPeriodicAction action = new CoreMetricsPeriodicAction(); + private final SpanMetricRegistry spanMetricRegistry = SpanMetricRegistry.getInstance(); + + @Test + void testSpanMetricsWithMultipleOccurrenceEvents() { + SpanMetrics instr1SpanMetric = spanMetricRegistry.get("instr-1"); + SpanMetrics instr2SpanMetric = spanMetricRegistry.get("instr-2"); + + instr1SpanMetric.onSpanCreated(); + instr2SpanMetric.onSpanCreated(); + instr1SpanMetric.onSpanCreated(); + instr2SpanMetric.onSpanCreated(); + instr2SpanMetric.onSpanCreated(); + instr2SpanMetric.onSpanFinished(); + instr2SpanMetric.onSpanFinished(); + instr2SpanMetric.onSpanFinished(); + instr2SpanMetric.onSpanFinished(); + + action.collector().prepareMetrics(); + action.doIteration(telemetryService); + + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(3)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); + + List metrics = captor.getAllValues(); + assertMetric(metrics, "spans_created", "instr-1", 2); + assertMetric(metrics, "spans_created", "instr-2", 3); + assertMetric(metrics, "spans_finished", "instr-2", 4); + } + + @Test + void testSpanMetricsWithInterleavedEvents() { + SpanMetrics instr1SpanMetric = spanMetricRegistry.get("instr-1"); + SpanMetrics instr2SpanMetric = spanMetricRegistry.get("instr-2"); + SpanMetrics instr3SpanMetric = spanMetricRegistry.get("instr-3"); + SpanMetrics instrASpanMetric = spanMetricRegistry.get("instr-a"); + SpanMetrics instrBSpanMetric = spanMetricRegistry.get("instr-b"); + + instr1SpanMetric.onSpanCreated(); + instr2SpanMetric.onSpanCreated(); + instr3SpanMetric.onSpanCreated(); + instr3SpanMetric.onSpanFinished(); + instrASpanMetric.onSpanCreated(); + instrBSpanMetric.onSpanCreated(); + instrBSpanMetric.onSpanFinished(); + instr2SpanMetric.onSpanFinished(); + instrASpanMetric.onSpanFinished(); + instr1SpanMetric.onSpanFinished(); + + action.collector().prepareMetrics(); + action.doIteration(telemetryService); + + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(10)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); + + List metrics = captor.getAllValues(); + assertMetric(metrics, "spans_created", "instr-1", 1); + assertMetric(metrics, "spans_created", "instr-2", 1); + assertMetric(metrics, "spans_created", "instr-3", 1); + assertMetric(metrics, "spans_finished", "instr-3", 1); + assertMetric(metrics, "spans_created", "instr-a", 1); + assertMetric(metrics, "spans_created", "instr-b", 1); + assertMetric(metrics, "spans_finished", "instr-b", 1); + assertMetric(metrics, "spans_finished", "instr-2", 1); + assertMetric(metrics, "spans_finished", "instr-a", 1); + assertMetric(metrics, "spans_finished", "instr-1", 1); + } + + @Test + void testSpanMetrics() { + SpanMetrics instr1SpanMetric = spanMetricRegistry.get("instr-1"); + SpanMetrics instr2SpanMetric = spanMetricRegistry.get("instr-2"); + SpanMetrics instr3SpanMetric = spanMetricRegistry.get("instr-3"); + SpanMetrics instrASpanMetric = spanMetricRegistry.get("instr-a"); + SpanMetrics instrBSpanMetric = spanMetricRegistry.get("instr-b"); + + instr1SpanMetric.onSpanCreated(); + instr2SpanMetric.onSpanCreated(); + instr3SpanMetric.onSpanCreated(); + instr3SpanMetric.onSpanFinished(); + instrASpanMetric.onSpanCreated(); + instrBSpanMetric.onSpanCreated(); + instrBSpanMetric.onSpanFinished(); + instr2SpanMetric.onSpanFinished(); + instrASpanMetric.onSpanFinished(); + instr1SpanMetric.onSpanFinished(); + + action.collector().prepareMetrics(); + action.doIteration(telemetryService); + + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(10)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); + + List metrics = captor.getAllValues(); + assertMetric(metrics, "spans_created", "instr-1", 1); + assertMetric(metrics, "spans_created", "instr-2", 1); + assertMetric(metrics, "spans_created", "instr-3", 1); + assertMetric(metrics, "spans_finished", "instr-3", 1); + assertMetric(metrics, "spans_created", "instr-a", 1); + assertMetric(metrics, "spans_created", "instr-b", 1); + assertMetric(metrics, "spans_finished", "instr-b", 1); + assertMetric(metrics, "spans_finished", "instr-2", 1); + assertMetric(metrics, "spans_finished", "instr-a", 1); + assertMetric(metrics, "spans_finished", "instr-1", 1); + } + + /** + * MetricPeriodicAction aggregates metrics through a HashMap, so the emission order is not + * guaranteed; match each expected metric by its metric name and instrumentation tag rather than + * by capture position. + */ + private void assertMetric(List metrics, String metricName, String instrumentationName, long count) { + List expectedTags = Arrays.asList("integration_name:" + instrumentationName); + List matches = metrics.stream() + .filter(metric -> metric.getMetric().equals(metricName)) + .filter(metric -> expectedTags.equals(metric.getTags())) + .collect(Collectors.toList()); + assertEquals(1, matches.size(), "expected exactly one match for " + metricName + " " + expectedTags); + + Metric metric = matches.get(0); + assertEquals("tracers", metric.getNamespace()); + assertEquals(true, metric.getCommon()); + assertEquals(1, metric.getPoints().size()); + assertEquals(2, metric.getPoints().get(0).size()); + assertEquals(count, metric.getPoints().get(0).get(1).longValue()); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/metric/IastMetricPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/metric/IastMetricPeriodicActionTest.java index 2189035dd78..55254a27910 100644 --- a/telemetry/src/test/java/datadog/telemetry/metric/IastMetricPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/metric/IastMetricPeriodicActionTest.java @@ -22,70 +22,70 @@ class IastMetricPeriodicActionTest { - private static final IastMetricCollector ORIGINAL_COLLECTOR = IastMetricCollector.get(); - - private final IastMetricPeriodicAction action = new IastMetricPeriodicAction(); - private final TelemetryService telemetryService = mock(TelemetryService.class); - - @BeforeEach - void setUp() { - IastMetricCollector.register(new IastMetricCollector()); - } - - @AfterEach - void tearDown() { - IastMetricCollector.register(ORIGINAL_COLLECTOR); - } - - @Test - void testMetric() { - IastMetric iastMetric = IastMetric.EXECUTED_TAINTED; - int value = 23; - - IastMetricCollector.add(iastMetric, value); - IastMetricCollector.get().prepareMetrics(); - action.doIteration(telemetryService); - - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(1)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); - - assertMetric(captor.getValue(), iastMetric, value, Collections.emptyList()); - } - - @Test - void testTaggedMetric() { - IastMetric iastMetric = IastMetric.INSTRUMENTED_SOURCE; - byte tag = SourceTypes.REQUEST_PARAMETER_VALUE; - String tagString = SourceTypes.toString(tag); - int value = 23; - - IastMetricCollector.add(iastMetric, tag, value); - IastMetricCollector.get().prepareMetrics(); - action.doIteration(telemetryService); - - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(1)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); - - assertMetric( - captor.getValue(), - iastMetric, - value, - Arrays.asList(iastMetric.getTag().getName() + ":" + tagString)); - } - - @Test - void testWithNoMetrics() { - action.doIteration(telemetryService); - - verifyNoMoreInteractions(telemetryService); - } - - private void assertMetric(Metric metric, IastMetric iastMetric, long value, List tags) { - assertEquals("iast", metric.getNamespace()); - assertEquals(iastMetric.getName(), metric.getMetric()); - assertEquals(tags, metric.getTags()); - assertEquals(value, metric.getPoints().get(0).get(1).longValue()); - } + private static final IastMetricCollector ORIGINAL_COLLECTOR = IastMetricCollector.get(); + + private final IastMetricPeriodicAction action = new IastMetricPeriodicAction(); + private final TelemetryService telemetryService = mock(TelemetryService.class); + + @BeforeEach + void setUp() { + IastMetricCollector.register(new IastMetricCollector()); + } + + @AfterEach + void tearDown() { + IastMetricCollector.register(ORIGINAL_COLLECTOR); + } + + @Test + void testMetric() { + IastMetric iastMetric = IastMetric.EXECUTED_TAINTED; + int value = 23; + + IastMetricCollector.add(iastMetric, value); + IastMetricCollector.get().prepareMetrics(); + action.doIteration(telemetryService); + + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(1)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); + + assertMetric(captor.getValue(), iastMetric, value, Collections.emptyList()); + } + + @Test + void testTaggedMetric() { + IastMetric iastMetric = IastMetric.INSTRUMENTED_SOURCE; + byte tag = SourceTypes.REQUEST_PARAMETER_VALUE; + String tagString = SourceTypes.toString(tag); + int value = 23; + + IastMetricCollector.add(iastMetric, tag, value); + IastMetricCollector.get().prepareMetrics(); + action.doIteration(telemetryService); + + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(1)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); + + assertMetric( + captor.getValue(), + iastMetric, + value, + Arrays.asList(iastMetric.getTag().getName() + ":" + tagString)); + } + + @Test + void testWithNoMetrics() { + action.doIteration(telemetryService); + + verifyNoMoreInteractions(telemetryService); + } + + private void assertMetric(Metric metric, IastMetric iastMetric, long value, List tags) { + assertEquals("iast", metric.getNamespace()); + assertEquals(iastMetric.getName(), metric.getMetric()); + assertEquals(tags, metric.getTags()); + assertEquals(value, metric.getPoints().get(0).get(1).longValue()); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/metric/LLMObsMetricPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/metric/LLMObsMetricPeriodicActionTest.java index 271bd4a2cd8..b0ae736bf90 100644 --- a/telemetry/src/test/java/datadog/telemetry/metric/LLMObsMetricPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/metric/LLMObsMetricPeriodicActionTest.java @@ -19,102 +19,98 @@ import org.mockito.ArgumentCaptor; class LLMObsMetricPeriodicActionTest { - private final LLMObsMetricPeriodicAction periodicAction = new LLMObsMetricPeriodicAction(); - private final LLMObsMetricCollector collector = LLMObsMetricCollector.get(); - private TelemetryService telemetryService; + private final LLMObsMetricPeriodicAction periodicAction = new LLMObsMetricPeriodicAction(); + private final LLMObsMetricCollector collector = LLMObsMetricCollector.get(); + private TelemetryService telemetryService; - @BeforeEach - void setUp() { - collector.resetForTesting(); - telemetryService = mock(TelemetryService.class); - } + @BeforeEach + void setUp() { + collector.resetForTesting(); + telemetryService = mock(TelemetryService.class); + } - @AfterEach - void tearDown() { - collector.resetForTesting(); - } + @AfterEach + void tearDown() { + collector.resetForTesting(); + } - @Test - void emitsOneMetricPerDistinctTagCombination() { - collector.recordSpanFinished("openai", "llm", true, true, false, true); - collector.recordSpanFinished("openai", "llm", false, true, false, false); - collector.recordSpanFinished("anthropic", "embedding", true, false, true, false); + @Test + void emitsOneMetricPerDistinctTagCombination() { + collector.recordSpanFinished("openai", "llm", true, true, false, true); + collector.recordSpanFinished("openai", "llm", false, true, false, false); + collector.recordSpanFinished("anthropic", "embedding", true, false, true, false); - collector.prepareMetrics(); - periodicAction.doIteration(telemetryService); + collector.prepareMetrics(); + periodicAction.doIteration(telemetryService); - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(3)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(3)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); - HashSet> tagSets = new HashSet<>(); - for (Metric metric : captor.getAllValues()) { - assertEquals("mlobs", metric.getNamespace()); - assertEquals("span.finished", metric.getMetric()); - assertEquals(1, metric.getPoints().size()); - assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); - tagSets.add(new HashSet<>(metric.getTags())); + HashSet> tagSets = new HashSet<>(); + for (Metric metric : captor.getAllValues()) { + assertEquals("mlobs", metric.getNamespace()); + assertEquals("span.finished", metric.getMetric()); + assertEquals(1, metric.getPoints().size()); + assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); + tagSets.add(new HashSet<>(metric.getTags())); + } + assertEquals( + new HashSet<>(Arrays.asList( + new HashSet<>(Arrays.asList( + "integration:openai", + "span_kind:llm", + "is_root_span:1", + "autoinstrumented:1", + "error:0", + "has_session_id:1")), + new HashSet<>(Arrays.asList( + "integration:openai", + "span_kind:llm", + "is_root_span:0", + "autoinstrumented:1", + "error:0", + "has_session_id:0")), + new HashSet<>(Arrays.asList( + "integration:anthropic", + "span_kind:embedding", + "is_root_span:1", + "autoinstrumented:0", + "error:1", + "has_session_id:0")))), + tagSets); } - assertEquals( - new HashSet<>( - Arrays.asList( - new HashSet<>( - Arrays.asList( - "integration:openai", - "span_kind:llm", - "is_root_span:1", - "autoinstrumented:1", - "error:0", - "has_session_id:1")), - new HashSet<>( - Arrays.asList( - "integration:openai", - "span_kind:llm", - "is_root_span:0", - "autoinstrumented:1", - "error:0", - "has_session_id:0")), - new HashSet<>( - Arrays.asList( - "integration:anthropic", - "span_kind:embedding", - "is_root_span:1", - "autoinstrumented:0", - "error:1", - "has_session_id:0")))), - tagSets); - } - /** - * Identical spans must produce a single point carrying the summed count. Emitting one point of - * value 1 per span instead loses all but one of the points that share a second-granularity - * timestamp once they reach the metrics intake. - */ - @Test - void emitsASinglePointCarryingTheAggregatedCount() { - for (int i = 0; i < 5000; i++) { - collector.recordSpanFinished("openai", "llm", true, true, false, false); - } + /** + * Identical spans must produce a single point carrying the summed count. Emitting one point of + * value 1 per span instead loses all but one of the points that share a second-granularity + * timestamp once they reach the metrics intake. + */ + @Test + void emitsASinglePointCarryingTheAggregatedCount() { + for (int i = 0; i < 5000; i++) { + collector.recordSpanFinished("openai", "llm", true, true, false, false); + } - collector.prepareMetrics(); - periodicAction.doIteration(telemetryService); + collector.prepareMetrics(); + periodicAction.doIteration(telemetryService); - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); - Metric metric = captor.getValue(); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - List> points = metric.getPoints(); - assertEquals(1, points.size()); - assertEquals(5000L, points.get(0).get(1).longValue()); - } + Metric metric = captor.getValue(); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + List> points = metric.getPoints(); + assertEquals(1, points.size()); + assertEquals(5000L, points.get(0).get(1).longValue()); + } - @Test - void emitsNothingWhenNoSpansFinished() { - collector.prepareMetrics(); - periodicAction.doIteration(telemetryService); + @Test + void emitsNothingWhenNoSpansFinished() { + collector.prepareMetrics(); + periodicAction.doIteration(telemetryService); - verifyNoMoreInteractions(telemetryService); - } + verifyNoMoreInteractions(telemetryService); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/metric/MetricPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/metric/MetricPeriodicActionTest.java index bd90a582f65..2a20ecdfd0c 100644 --- a/telemetry/src/test/java/datadog/telemetry/metric/MetricPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/metric/MetricPeriodicActionTest.java @@ -26,296 +26,286 @@ public class MetricPeriodicActionTest { - private ArgumentCaptor metricCaptor = forClass(Metric.class); - private ArgumentCaptor distributionSeriesCaptor = - forClass(DistributionSeries.class); - - @TableTest({ - "scenario | metrics | expected ", - "single raw metric produces a single telemetry point | ['col(counter: 2)'] | ['tel(points: [2])'] ", - "two raw metrics with the same identity are joined | ['col(counter: 2)', 'col(counter: 6)'] | ['tel(points: [2, 6])'] ", - "different namespace produces a distinct telemetry metric | ['col(counter: 2)', 'col(namespace: \"other\", counter: 6)'] | ['tel(points: [2])', 'tel(namespace: \"other\", points: [6])'] ", - "different common flag produces a distinct telemetry metric | ['col(counter: 2)', 'col(common: false, counter: 6)'] | ['tel(points: [2])', 'tel(common: false, points: [6])'] ", - "different metric name produces a distinct telemetry metric | ['col(counter: 2)', 'col(metric: \"other\", counter: 6)'] | ['tel(points: [2])', 'tel(metric: \"other\", points: [6])'] ", - "different tags produce a distinct telemetry metric | ['col(counter: 2)', 'col(tags: [a:b], counter: 6)'] | ['tel(points: [2])', 'tel(tags: [a:b], points: [6])'] ", - "distinct tags on both raw metrics stay distinct | ['col(counter: 2, tags: [a:b])', 'col(counter: 6, tags: [c:d])'] | ['tel(points: [2], tags: [a:b])', 'tel(points: [6], tags: [c:d])']", - "same tags on both raw metrics are joined | ['col(counter: 2, tags: [a:b, c:d])', 'col(counter: 6, tags: [a:b, c:d])'] | ['tel(points: [2, 6], tags: [a:b, c:d])'] " - }) - void testCommonMetricsAreJoinedBeforeBeingSentToTelemetry( - List metrics, List expected) { - TelemetryService telemetryService = mock(TelemetryService.class); - MetricCollector metricCollector = mock(MetricCollector.class); - when(metricCollector.drain()).thenReturn(metrics); - when(metricCollector.drainDistributionSeries()).thenReturn(emptyList()); - DefaultMetricPeriodicAction action = new DefaultMetricPeriodicAction(metricCollector); - - action.doIteration(telemetryService); - - verify(telemetryService, times(expected.size())).addMetric(metricCaptor.capture()); - verifyNoMoreInteractions(ignoreStubs(telemetryService, metricCollector)); - - List actualMetrics = metricCaptor.getAllValues(); - for (ExpectedMetric expectedMetric : expected) { - assertMatchingMetric(actualMetrics, expectedMetric); + private ArgumentCaptor metricCaptor = forClass(Metric.class); + private ArgumentCaptor distributionSeriesCaptor = forClass(DistributionSeries.class); + + @TableTest({ + "scenario | metrics | expected ", + "single raw metric produces a single telemetry point | ['col(counter: 2)'] | ['tel(points: [2])'] ", + "two raw metrics with the same identity are joined | ['col(counter: 2)', 'col(counter: 6)'] | ['tel(points: [2, 6])'] ", + "different namespace produces a distinct telemetry metric | ['col(counter: 2)', 'col(namespace: \"other\", counter: 6)'] | ['tel(points: [2])', 'tel(namespace: \"other\", points: [6])'] ", + "different common flag produces a distinct telemetry metric | ['col(counter: 2)', 'col(common: false, counter: 6)'] | ['tel(points: [2])', 'tel(common: false, points: [6])'] ", + "different metric name produces a distinct telemetry metric | ['col(counter: 2)', 'col(metric: \"other\", counter: 6)'] | ['tel(points: [2])', 'tel(metric: \"other\", points: [6])'] ", + "different tags produce a distinct telemetry metric | ['col(counter: 2)', 'col(tags: [a:b], counter: 6)'] | ['tel(points: [2])', 'tel(tags: [a:b], points: [6])'] ", + "distinct tags on both raw metrics stay distinct | ['col(counter: 2, tags: [a:b])', 'col(counter: 6, tags: [c:d])'] | ['tel(points: [2], tags: [a:b])', 'tel(points: [6], tags: [c:d])']", + "same tags on both raw metrics are joined | ['col(counter: 2, tags: [a:b, c:d])', 'col(counter: 6, tags: [a:b, c:d])'] | ['tel(points: [2, 6], tags: [a:b, c:d])'] " + }) + void testCommonMetricsAreJoinedBeforeBeingSentToTelemetry( + List metrics, List expected) { + TelemetryService telemetryService = mock(TelemetryService.class); + MetricCollector metricCollector = mock(MetricCollector.class); + when(metricCollector.drain()).thenReturn(metrics); + when(metricCollector.drainDistributionSeries()).thenReturn(emptyList()); + DefaultMetricPeriodicAction action = new DefaultMetricPeriodicAction(metricCollector); + + action.doIteration(telemetryService); + + verify(telemetryService, times(expected.size())).addMetric(metricCaptor.capture()); + verifyNoMoreInteractions(ignoreStubs(telemetryService, metricCollector)); + + List actualMetrics = metricCaptor.getAllValues(); + for (ExpectedMetric expectedMetric : expected) { + assertMatchingMetric(actualMetrics, expectedMetric); + } } - } - - @TableTest({ - "scenario | distributionSeries | expected ", - "single raw series point produces a single telemetry series | ['rawSeries(value: 2)'] | ['series(points: [2])'] ", - "two raw series points with the same identity are joined | ['rawSeries(value: 2)', 'rawSeries(value: 6)'] | ['series(points: [2, 6])'] ", - "different namespace produces a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(namespace: \"other\", value: 6)'] | ['series(points: [2])', 'series(namespace: \"other\", points: [6])'] ", - "different common flag produces a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(common: false, value: 6)'] | ['series(points: [2])', 'series(common: false, points: [6])'] ", - "different metric name produces a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(metric: \"other\", value: 6)'] | ['series(points: [2])', 'series(metric: \"other\", points: [6])'] ", - "different tags produce a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(tags: [a:b], value: 6)'] | ['series(points: [2])', 'series(tags: [a:b], points: [6])'] ", - "distinct tags on both raw series points stay distinct | ['rawSeries(value: 2, tags: [a:b])', 'rawSeries(value: 6, tags: [c:d])'] | ['series(points: [2], tags: [a:b])', 'series(points: [6], tags: [c:d])']", - "same tags on both raw series points are joined | ['rawSeries(value: 2, tags: [a:b, c:d])', 'rawSeries(value: 6, tags: [a:b, c:d])'] | ['series(points: [2, 6], tags: [a:b, c:d])'] " - }) - void testCommonDistributionSeriesAreJoinedBeforeBeingSentToTelemetry( - List distributionSeries, - List expected) { - TelemetryService telemetryService = mock(TelemetryService.class); - MetricCollector metricCollector = mock(MetricCollector.class); - when(metricCollector.drain()).thenReturn(emptyList()); - when(metricCollector.drainDistributionSeries()).thenReturn(distributionSeries); - DefaultMetricPeriodicAction action = new DefaultMetricPeriodicAction(metricCollector); - - action.doIteration(telemetryService); - - verify(telemetryService, times(expected.size())) - .addDistributionSeries(distributionSeriesCaptor.capture()); - verifyNoMoreInteractions(ignoreStubs(telemetryService, metricCollector)); - - List actualSeries = distributionSeriesCaptor.getAllValues(); - for (ExpectedSeries expectedSeries : expected) { - assertMatchingSeries(actualSeries, expectedSeries); + + @TableTest({ + "scenario | distributionSeries | expected ", + "single raw series point produces a single telemetry series | ['rawSeries(value: 2)'] | ['series(points: [2])'] ", + "two raw series points with the same identity are joined | ['rawSeries(value: 2)', 'rawSeries(value: 6)'] | ['series(points: [2, 6])'] ", + "different namespace produces a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(namespace: \"other\", value: 6)'] | ['series(points: [2])', 'series(namespace: \"other\", points: [6])'] ", + "different common flag produces a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(common: false, value: 6)'] | ['series(points: [2])', 'series(common: false, points: [6])'] ", + "different metric name produces a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(metric: \"other\", value: 6)'] | ['series(points: [2])', 'series(metric: \"other\", points: [6])'] ", + "different tags produce a distinct telemetry series | ['rawSeries(value: 2)', 'rawSeries(tags: [a:b], value: 6)'] | ['series(points: [2])', 'series(tags: [a:b], points: [6])'] ", + "distinct tags on both raw series points stay distinct | ['rawSeries(value: 2, tags: [a:b])', 'rawSeries(value: 6, tags: [c:d])'] | ['series(points: [2], tags: [a:b])', 'series(points: [6], tags: [c:d])']", + "same tags on both raw series points are joined | ['rawSeries(value: 2, tags: [a:b, c:d])', 'rawSeries(value: 6, tags: [a:b, c:d])'] | ['series(points: [2, 6], tags: [a:b, c:d])'] " + }) + void testCommonDistributionSeriesAreJoinedBeforeBeingSentToTelemetry( + List distributionSeries, List expected) { + TelemetryService telemetryService = mock(TelemetryService.class); + MetricCollector metricCollector = mock(MetricCollector.class); + when(metricCollector.drain()).thenReturn(emptyList()); + when(metricCollector.drainDistributionSeries()).thenReturn(distributionSeries); + DefaultMetricPeriodicAction action = new DefaultMetricPeriodicAction(metricCollector); + + action.doIteration(telemetryService); + + verify(telemetryService, times(expected.size())).addDistributionSeries(distributionSeriesCaptor.capture()); + verifyNoMoreInteractions(ignoreStubs(telemetryService, metricCollector)); + + List actualSeries = distributionSeriesCaptor.getAllValues(); + for (ExpectedSeries expectedSeries : expected) { + assertMatchingSeries(actualSeries, expectedSeries); + } + } + + /** Parses a {@code col(key: value, ...)} cell into the raw metric it describes. */ + @TypeConverter + public static MetricCollector.Metric col(String token) { + Map args = parseArgs(token); + String namespace = stringArg(args, "namespace", "namespace"); + String metricName = stringArg(args, "metric", "metric"); + boolean common = booleanArg(args, "common", true); + List tags = tagsArg(args); + long counter = Long.parseLong(args.get("counter")); + return new MetricCollector.Metric(namespace, common, metricName, "count", counter, tags); + } + + /** Parses a {@code tel(key: value, ...)} cell into the expected telemetry metric. */ + @TypeConverter + public static ExpectedMetric tel(String token) { + Map args = parseArgs(token); + String namespace = stringArg(args, "namespace", "namespace"); + String metricName = stringArg(args, "metric", "metric"); + boolean common = booleanArg(args, "common", true); + List tags = tagsArg(args); + List points = longListArg(args, "points"); + return new ExpectedMetric(namespace, metricName, common, tags, points); + } + + /** Parses a {@code rawSeries(key: value, ...)} cell into the raw distribution series point. */ + @TypeConverter + public static MetricCollector.DistributionSeriesPoint rawSeries(String token) { + Map args = parseArgs(token); + String namespace = stringArg(args, "namespace", "namespace"); + String metricName = stringArg(args, "metric", "metric"); + boolean common = booleanArg(args, "common", true); + List tags = tagsArg(args); + int value = Integer.parseInt(args.get("value")); + return new MetricCollector.DistributionSeriesPoint(metricName, common, namespace, value, tags); + } + + /** Parses a {@code series(key: value, ...)} cell into the expected telemetry series. */ + @TypeConverter + public static ExpectedSeries series(String token) { + Map args = parseArgs(token); + String namespace = stringArg(args, "namespace", "namespace"); + String metricName = stringArg(args, "metric", "metric"); + boolean common = booleanArg(args, "common", true); + List tags = tagsArg(args); + List points = intListArg(args, "points"); + return new ExpectedSeries(namespace, metricName, common, tags, points); + } + + /** + * Splits the {@code (key: value, ...)} argument list of a builder cell such as {@code col(...)} + * into a name-to-raw-value map, honouring nested {@code [...]} tag/point lists. + */ + private static Map parseArgs(String token) { + String content = + token.substring(token.indexOf('(') + 1, token.lastIndexOf(')')).trim(); + Map args = new LinkedHashMap<>(); + if (content.isEmpty()) { + return args; + } + int depth = 0; + int start = 0; + for (int i = 0; i <= content.length(); i++) { + char c = i == content.length() ? ',' : content.charAt(i); + if (c == '[') { + depth++; + } else if (c == ']') { + depth--; + } else if (c == ',' && depth == 0) { + String pair = content.substring(start, i).trim(); + int colon = pair.indexOf(':'); + args.put( + pair.substring(0, colon).trim(), + pair.substring(colon + 1).trim()); + start = i + 1; + } + } + return args; } - } - - /** Parses a {@code col(key: value, ...)} cell into the raw metric it describes. */ - @TypeConverter - public static MetricCollector.Metric col(String token) { - Map args = parseArgs(token); - String namespace = stringArg(args, "namespace", "namespace"); - String metricName = stringArg(args, "metric", "metric"); - boolean common = booleanArg(args, "common", true); - List tags = tagsArg(args); - long counter = Long.parseLong(args.get("counter")); - return new MetricCollector.Metric(namespace, common, metricName, "count", counter, tags); - } - - /** Parses a {@code tel(key: value, ...)} cell into the expected telemetry metric. */ - @TypeConverter - public static ExpectedMetric tel(String token) { - Map args = parseArgs(token); - String namespace = stringArg(args, "namespace", "namespace"); - String metricName = stringArg(args, "metric", "metric"); - boolean common = booleanArg(args, "common", true); - List tags = tagsArg(args); - List points = longListArg(args, "points"); - return new ExpectedMetric(namespace, metricName, common, tags, points); - } - - /** Parses a {@code rawSeries(key: value, ...)} cell into the raw distribution series point. */ - @TypeConverter - public static MetricCollector.DistributionSeriesPoint rawSeries(String token) { - Map args = parseArgs(token); - String namespace = stringArg(args, "namespace", "namespace"); - String metricName = stringArg(args, "metric", "metric"); - boolean common = booleanArg(args, "common", true); - List tags = tagsArg(args); - int value = Integer.parseInt(args.get("value")); - return new MetricCollector.DistributionSeriesPoint(metricName, common, namespace, value, tags); - } - - /** Parses a {@code series(key: value, ...)} cell into the expected telemetry series. */ - @TypeConverter - public static ExpectedSeries series(String token) { - Map args = parseArgs(token); - String namespace = stringArg(args, "namespace", "namespace"); - String metricName = stringArg(args, "metric", "metric"); - boolean common = booleanArg(args, "common", true); - List tags = tagsArg(args); - List points = intListArg(args, "points"); - return new ExpectedSeries(namespace, metricName, common, tags, points); - } - - /** - * Splits the {@code (key: value, ...)} argument list of a builder cell such as {@code col(...)} - * into a name-to-raw-value map, honouring nested {@code [...]} tag/point lists. - */ - private static Map parseArgs(String token) { - String content = token.substring(token.indexOf('(') + 1, token.lastIndexOf(')')).trim(); - Map args = new LinkedHashMap<>(); - if (content.isEmpty()) { - return args; + + private static String stringArg(Map args, String key, String defaultValue) { + String raw = args.get(key); + return raw == null ? defaultValue : unquote(raw); } - int depth = 0; - int start = 0; - for (int i = 0; i <= content.length(); i++) { - char c = i == content.length() ? ',' : content.charAt(i); - if (c == '[') { - depth++; - } else if (c == ']') { - depth--; - } else if (c == ',' && depth == 0) { - String pair = content.substring(start, i).trim(); - int colon = pair.indexOf(':'); - args.put(pair.substring(0, colon).trim(), pair.substring(colon + 1).trim()); - start = i + 1; - } + + private static String unquote(String value) { + return value.charAt(0) == '"' ? value.substring(1, value.length() - 1) : value; } - return args; - } - - private static String stringArg(Map args, String key, String defaultValue) { - String raw = args.get(key); - return raw == null ? defaultValue : unquote(raw); - } - - private static String unquote(String value) { - return value.charAt(0) == '"' ? value.substring(1, value.length() - 1) : value; - } - - private static boolean booleanArg(Map args, String key, boolean defaultValue) { - String raw = args.get(key); - return raw == null ? defaultValue : Boolean.parseBoolean(raw); - } - - private static List tagsArg(Map args) { - String raw = args.get("tags"); - return raw == null ? emptyList() : splitBracketedList(raw); - } - - private static List splitBracketedList(String raw) { - String inner = raw.substring(raw.indexOf('[') + 1, raw.lastIndexOf(']')).trim(); - if (inner.isEmpty()) { - return emptyList(); + + private static boolean booleanArg(Map args, String key, boolean defaultValue) { + String raw = args.get(key); + return raw == null ? defaultValue : Boolean.parseBoolean(raw); } - List values = new ArrayList<>(); - for (String part : inner.split(",")) { - values.add(part.trim()); + + private static List tagsArg(Map args) { + String raw = args.get("tags"); + return raw == null ? emptyList() : splitBracketedList(raw); } - return values; - } - private static List longListArg(Map args, String key) { - List values = new ArrayList<>(); - for (String value : splitBracketedList(args.get(key))) { - values.add(Long.parseLong(value)); + private static List splitBracketedList(String raw) { + String inner = raw.substring(raw.indexOf('[') + 1, raw.lastIndexOf(']')).trim(); + if (inner.isEmpty()) { + return emptyList(); + } + List values = new ArrayList<>(); + for (String part : inner.split(",")) { + values.add(part.trim()); + } + return values; } - return values; - } - private static List intListArg(Map args, String key) { - List values = new ArrayList<>(); - for (String value : splitBracketedList(args.get(key))) { - values.add(Integer.parseInt(value)); + private static List longListArg(Map args, String key) { + List values = new ArrayList<>(); + for (String value : splitBracketedList(args.get(key))) { + values.add(Long.parseLong(value)); + } + return values; } - return values; - } - - private static void assertMatchingMetric(List actualMetrics, ExpectedMetric expected) { - List matches = - actualMetrics.stream() - .filter(metric -> metric.getNamespace().equals(expected.namespace)) - .filter(metric -> metric.getMetric().equals(expected.metricName)) - .filter(metric -> metric.getCommon().equals(expected.common)) - .filter(metric -> metric.getTags().equals(expected.tags)) - .collect(Collectors.toList()); - assertEquals( - 1, - matches.size(), - "expected exactly one match for " - + expected.namespace - + "/" - + expected.metricName - + " " - + expected.tags); - - Metric metric = matches.get(0); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - List actualPoints = - metric.getPoints().stream() - .map(point -> point.get(1).longValue()) - .collect(Collectors.toList()); - assertEquals(expected.points.size(), actualPoints.size()); - assertEquals(true, actualPoints.containsAll(expected.points)); - } - - private static void assertMatchingSeries( - List actualSeries, ExpectedSeries expected) { - List matches = - actualSeries.stream() - .filter(series -> series.getNamespace().equals(expected.namespace)) - .filter(series -> series.getMetric().equals(expected.metricName)) - .filter(series -> series.getCommon().equals(expected.common)) - .filter(series -> series.getTags().equals(expected.tags)) - .collect(Collectors.toList()); - assertEquals( - 1, - matches.size(), - "expected exactly one match for " - + expected.namespace - + "/" - + expected.metricName - + " " - + expected.tags); - - DistributionSeries series = matches.get(0); - assertEquals(expected.points.size(), series.getPoints().size()); - assertEquals(true, series.getPoints().containsAll(expected.points)); - } - - private static class ExpectedMetric { - private final String namespace; - private final String metricName; - private final boolean common; - private final List tags; - private final List points; - - ExpectedMetric( - String namespace, String metricName, boolean common, List tags, List points) { - this.namespace = namespace; - this.metricName = metricName; - this.common = common; - this.tags = tags; - this.points = points; + + private static List intListArg(Map args, String key) { + List values = new ArrayList<>(); + for (String value : splitBracketedList(args.get(key))) { + values.add(Integer.parseInt(value)); + } + return values; } - } - - private static class ExpectedSeries { - private final String namespace; - private final String metricName; - private final boolean common; - private final List tags; - private final List points; - - ExpectedSeries( - String namespace, - String metricName, - boolean common, - List tags, - List points) { - this.namespace = namespace; - this.metricName = metricName; - this.common = common; - this.tags = tags; - this.points = points; + + private static void assertMatchingMetric(List actualMetrics, ExpectedMetric expected) { + List matches = actualMetrics.stream() + .filter(metric -> metric.getNamespace().equals(expected.namespace)) + .filter(metric -> metric.getMetric().equals(expected.metricName)) + .filter(metric -> metric.getCommon().equals(expected.common)) + .filter(metric -> metric.getTags().equals(expected.tags)) + .collect(Collectors.toList()); + assertEquals( + 1, + matches.size(), + "expected exactly one match for " + + expected.namespace + + "/" + + expected.metricName + + " " + + expected.tags); + + Metric metric = matches.get(0); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + List actualPoints = metric.getPoints().stream() + .map(point -> point.get(1).longValue()) + .collect(Collectors.toList()); + assertEquals(expected.points.size(), actualPoints.size()); + assertEquals(true, actualPoints.containsAll(expected.points)); } - } - private static class DefaultMetricPeriodicAction extends MetricPeriodicAction { - private final MetricCollector collector; + private static void assertMatchingSeries(List actualSeries, ExpectedSeries expected) { + List matches = actualSeries.stream() + .filter(series -> series.getNamespace().equals(expected.namespace)) + .filter(series -> series.getMetric().equals(expected.metricName)) + .filter(series -> series.getCommon().equals(expected.common)) + .filter(series -> series.getTags().equals(expected.tags)) + .collect(Collectors.toList()); + assertEquals( + 1, + matches.size(), + "expected exactly one match for " + + expected.namespace + + "/" + + expected.metricName + + " " + + expected.tags); + + DistributionSeries series = matches.get(0); + assertEquals(expected.points.size(), series.getPoints().size()); + assertEquals(true, series.getPoints().containsAll(expected.points)); + } + + private static class ExpectedMetric { + private final String namespace; + private final String metricName; + private final boolean common; + private final List tags; + private final List points; + + ExpectedMetric(String namespace, String metricName, boolean common, List tags, List points) { + this.namespace = namespace; + this.metricName = metricName; + this.common = common; + this.tags = tags; + this.points = points; + } + } - DefaultMetricPeriodicAction(@Nonnull MetricCollector collector) { - this.collector = collector; + private static class ExpectedSeries { + private final String namespace; + private final String metricName; + private final boolean common; + private final List tags; + private final List points; + + ExpectedSeries(String namespace, String metricName, boolean common, List tags, List points) { + this.namespace = namespace; + this.metricName = metricName; + this.common = common; + this.tags = tags; + this.points = points; + } } - @Override - @Nonnull - public MetricCollector collector() { - return collector; + private static class DefaultMetricPeriodicAction extends MetricPeriodicAction { + private final MetricCollector collector; + + DefaultMetricPeriodicAction(@Nonnull MetricCollector collector) { + this.collector = collector; + } + + @Override + @Nonnull + public MetricCollector collector() { + return collector; + } } - } } diff --git a/telemetry/src/test/java/datadog/telemetry/metric/OtelEnvMetricPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/metric/OtelEnvMetricPeriodicActionTest.java index dff54f9d7c6..efc5e38dc02 100644 --- a/telemetry/src/test/java/datadog/telemetry/metric/OtelEnvMetricPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/metric/OtelEnvMetricPeriodicActionTest.java @@ -18,108 +18,104 @@ class OtelEnvMetricPeriodicActionTest { - private final TelemetryService telemetryService = mock(TelemetryService.class); - private final OtelEnvMetricPeriodicAction action = new OtelEnvMetricPeriodicAction(); - private final OtelEnvMetricCollectorImpl collector = OtelEnvMetricCollectorImpl.getInstance(); - private ArgumentCaptor metricCaptor = forClass(Metric.class); - - @Test - void testOtelEnvVarHidingMetric() { - collector.setHidingOtelEnvVarMetric("otel_service_name", "dd_service_name"); - collector.prepareMetrics(); - action.doIteration(telemetryService); - - verify(telemetryService, times(1)).addMetric(metricCaptor.capture()); - verifyNoMoreInteractions(telemetryService); - - Metric metric = metricCaptor.getValue(); - assertEquals("tracers", metric.getNamespace()); - assertEquals("otel.env.hiding", metric.getMetric()); - assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); - assertEquals( - Arrays.asList("config_opentelemetry:otel_service_name", "config_datadog:dd_service_name"), - metric.getTags()); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - } - - @Test - void testOtelEnvVarUnsupportedMetric() { - collector.setUnsupportedOtelEnvVarMetric("unsupported_env_var"); - collector.prepareMetrics(); - action.doIteration(telemetryService); - - verify(telemetryService, times(1)).addMetric(metricCaptor.capture()); - verifyNoMoreInteractions(telemetryService); - - Metric metric = metricCaptor.getValue(); - assertEquals("tracers", metric.getNamespace()); - assertEquals("otel.env.unsupported", metric.getMetric()); - assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); - assertEquals(Arrays.asList("config_opentelemetry:unsupported_env_var"), metric.getTags()); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - } - - @Test - void testOtelEnvVarInvalidMetric() { - collector.setInvalidOtelEnvVarMetric("otel_env_var", "dd_env_var"); - collector.prepareMetrics(); - action.doIteration(telemetryService); - - verify(telemetryService, times(1)).addMetric(metricCaptor.capture()); - verifyNoMoreInteractions(telemetryService); - - Metric metric = metricCaptor.getValue(); - assertEquals("tracers", metric.getNamespace()); - assertEquals("otel.env.invalid", metric.getMetric()); - assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); - assertEquals( - Arrays.asList("config_opentelemetry:otel_env_var", "config_datadog:dd_env_var"), - metric.getTags()); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - } - - @Test - void testOtelEnvVarMultipleMetrics() { - collector.setInvalidOtelEnvVarMetric("otel_env_var", "dd_env_var"); - collector.setInvalidOtelEnvVarMetric("otel_env_var2", "dd_env_var2"); - collector.setHidingOtelEnvVarMetric("otel_service_name", "dd_service_name"); - collector.setUnsupportedOtelEnvVarMetric("unsupported_env_var"); - collector.prepareMetrics(); - action.doIteration(telemetryService); - - verify(telemetryService, times(4)).addMetric(metricCaptor.capture()); - verifyNoMoreInteractions(telemetryService); - - // MetricPeriodicAction aggregates metrics through a HashMap, so the emission order is not - // guaranteed; match each expected metric by its tags rather than by capture position. - List metrics = metricCaptor.getAllValues(); - assertMetric( - metrics, - "otel.env.invalid", - Arrays.asList("config_opentelemetry:otel_env_var", "config_datadog:dd_env_var")); - assertMetric( - metrics, - "otel.env.invalid", - Arrays.asList("config_opentelemetry:otel_env_var2", "config_datadog:dd_env_var2")); - assertMetric( - metrics, - "otel.env.hiding", - Arrays.asList("config_opentelemetry:otel_service_name", "config_datadog:dd_service_name")); - assertMetric( - metrics, "otel.env.unsupported", Arrays.asList("config_opentelemetry:unsupported_env_var")); - } - - private void assertMetric(List metrics, String metricName, List tags) { - List matches = - metrics.stream() - .filter(metric -> metric.getMetric().equals(metricName)) - .filter(metric -> tags.equals(metric.getTags())) - .collect(Collectors.toList()); - assertEquals(1, matches.size(), "expected exactly one match for " + metricName + " " + tags); - - Metric metric = matches.get(0); - assertEquals("tracers", metric.getNamespace()); - assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); - assertEquals(Metric.TypeEnum.COUNT, metric.getType()); - } + private final TelemetryService telemetryService = mock(TelemetryService.class); + private final OtelEnvMetricPeriodicAction action = new OtelEnvMetricPeriodicAction(); + private final OtelEnvMetricCollectorImpl collector = OtelEnvMetricCollectorImpl.getInstance(); + private ArgumentCaptor metricCaptor = forClass(Metric.class); + + @Test + void testOtelEnvVarHidingMetric() { + collector.setHidingOtelEnvVarMetric("otel_service_name", "dd_service_name"); + collector.prepareMetrics(); + action.doIteration(telemetryService); + + verify(telemetryService, times(1)).addMetric(metricCaptor.capture()); + verifyNoMoreInteractions(telemetryService); + + Metric metric = metricCaptor.getValue(); + assertEquals("tracers", metric.getNamespace()); + assertEquals("otel.env.hiding", metric.getMetric()); + assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); + assertEquals( + Arrays.asList("config_opentelemetry:otel_service_name", "config_datadog:dd_service_name"), + metric.getTags()); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + } + + @Test + void testOtelEnvVarUnsupportedMetric() { + collector.setUnsupportedOtelEnvVarMetric("unsupported_env_var"); + collector.prepareMetrics(); + action.doIteration(telemetryService); + + verify(telemetryService, times(1)).addMetric(metricCaptor.capture()); + verifyNoMoreInteractions(telemetryService); + + Metric metric = metricCaptor.getValue(); + assertEquals("tracers", metric.getNamespace()); + assertEquals("otel.env.unsupported", metric.getMetric()); + assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); + assertEquals(Arrays.asList("config_opentelemetry:unsupported_env_var"), metric.getTags()); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + } + + @Test + void testOtelEnvVarInvalidMetric() { + collector.setInvalidOtelEnvVarMetric("otel_env_var", "dd_env_var"); + collector.prepareMetrics(); + action.doIteration(telemetryService); + + verify(telemetryService, times(1)).addMetric(metricCaptor.capture()); + verifyNoMoreInteractions(telemetryService); + + Metric metric = metricCaptor.getValue(); + assertEquals("tracers", metric.getNamespace()); + assertEquals("otel.env.invalid", metric.getMetric()); + assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); + assertEquals(Arrays.asList("config_opentelemetry:otel_env_var", "config_datadog:dd_env_var"), metric.getTags()); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + } + + @Test + void testOtelEnvVarMultipleMetrics() { + collector.setInvalidOtelEnvVarMetric("otel_env_var", "dd_env_var"); + collector.setInvalidOtelEnvVarMetric("otel_env_var2", "dd_env_var2"); + collector.setHidingOtelEnvVarMetric("otel_service_name", "dd_service_name"); + collector.setUnsupportedOtelEnvVarMetric("unsupported_env_var"); + collector.prepareMetrics(); + action.doIteration(telemetryService); + + verify(telemetryService, times(4)).addMetric(metricCaptor.capture()); + verifyNoMoreInteractions(telemetryService); + + // MetricPeriodicAction aggregates metrics through a HashMap, so the emission order is not + // guaranteed; match each expected metric by its tags rather than by capture position. + List metrics = metricCaptor.getAllValues(); + assertMetric( + metrics, + "otel.env.invalid", + Arrays.asList("config_opentelemetry:otel_env_var", "config_datadog:dd_env_var")); + assertMetric( + metrics, + "otel.env.invalid", + Arrays.asList("config_opentelemetry:otel_env_var2", "config_datadog:dd_env_var2")); + assertMetric( + metrics, + "otel.env.hiding", + Arrays.asList("config_opentelemetry:otel_service_name", "config_datadog:dd_service_name")); + assertMetric(metrics, "otel.env.unsupported", Arrays.asList("config_opentelemetry:unsupported_env_var")); + } + + private void assertMetric(List metrics, String metricName, List tags) { + List matches = metrics.stream() + .filter(metric -> metric.getMetric().equals(metricName)) + .filter(metric -> tags.equals(metric.getTags())) + .collect(Collectors.toList()); + assertEquals(1, matches.size(), "expected exactly one match for " + metricName + " " + tags); + + Metric metric = matches.get(0); + assertEquals("tracers", metric.getNamespace()); + assertEquals(1L, metric.getPoints().get(0).get(1).longValue()); + assertEquals(Metric.TypeEnum.COUNT, metric.getType()); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/metric/WafMetricPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/metric/WafMetricPeriodicActionTest.java index 6340d95bdc0..b55f6b9a864 100644 --- a/telemetry/src/test/java/datadog/telemetry/metric/WafMetricPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/metric/WafMetricPeriodicActionTest.java @@ -19,178 +19,151 @@ class WafMetricPeriodicActionTest { - private final WafMetricPeriodicAction periodicAction = new WafMetricPeriodicAction(); - private final TelemetryService telemetryService = mock(TelemetryService.class); - - @Test - void pushWafMetricsIntoTheTelemetryService() { - WafMetricCollector.get().wafInit("0.0.0", "rules_ver_1", true); - WafMetricCollector.get().wafUpdates("rules_ver_2", true); - WafMetricCollector.get().wafUpdates("rules_ver_3", true); - - periodicAction.doIteration(telemetryService); - - ArgumentCaptor captor = forClass(Metric.class); - verify(telemetryService, times(3)).addMetric(captor.capture()); - verifyNoMoreInteractions(telemetryService); - - List metrics = captor.getAllValues(); - assertMetric( - metrics, - "waf.init", - 1L, - Arrays.asList("waf_version:0.0.0", "event_rules_version:rules_ver_1", "success:true")); - assertMetric( - metrics, - "waf.updates", - 1L, - Arrays.asList("waf_version:0.0.0", "event_rules_version:rules_ver_2", "success:true")); - assertMetric( - metrics, - "waf.updates", - 1L, - Arrays.asList("waf_version:0.0.0", "event_rules_version:rules_ver_3", "success:true")); - } - - @Test - void pushWafRequestMetricsAndPushIntoTheTelemetry() { - WafMetricCollector.get().wafInit("0.0.0", "rules_ver_1", true); - WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(true, false, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, true, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, true, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, true, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, false, true, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, true, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, false, false, true, false); - WafMetricCollector.get().prepareMetrics(); - periodicAction.doIteration(telemetryService); - - ArgumentCaptor firstBatchCaptor = forClass(Metric.class); - verify(telemetryService, times(9)).addMetric(firstBatchCaptor.capture()); - verifyNoMoreInteractions(telemetryService); - - List firstBatch = firstBatchCaptor.getAllValues(); - assertMetric(firstBatch, "waf.init", 1L, null); - assertRequestMetric( - firstBatch, - 3L, - requestTags("rules_ver_1", false, false, false, false, false, false, false)); - assertRequestMetric( - firstBatch, 1L, requestTags("rules_ver_1", true, false, false, false, false, false, false)); - assertRequestMetric( - firstBatch, 1L, requestTags("rules_ver_1", false, true, false, false, false, false, false)); - assertRequestMetric( - firstBatch, 1L, requestTags("rules_ver_1", false, false, true, false, false, false, false)); - assertRequestMetric( - firstBatch, 1L, requestTags("rules_ver_1", false, false, false, true, false, false, false)); - assertRequestMetric( - firstBatch, 1L, requestTags("rules_ver_1", false, false, false, false, true, false, false)); - assertRequestMetric( - firstBatch, 1L, requestTags("rules_ver_1", false, false, false, false, false, true, false)); - assertRequestMetric( - firstBatch, 1L, requestTags("rules_ver_1", false, false, false, false, false, false, true)); - - clearInvocations(telemetryService); - - // waf.updates happens - WafMetricCollector.get().wafUpdates("rules_ver_2", true); - WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(true, false, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, true, false, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, true, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, true, false, false, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, false, true, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, true, false, false, false); - WafMetricCollector.get().wafRequest(false, false, false, false, false, false, true, false); - WafMetricCollector.get().prepareMetrics(); - periodicAction.doIteration(telemetryService); - - // following waf.request have a new event_rules_version tag - ArgumentCaptor secondBatchCaptor = forClass(Metric.class); - verify(telemetryService, times(9)).addMetric(secondBatchCaptor.capture()); - verifyNoMoreInteractions(telemetryService); - - List secondBatch = secondBatchCaptor.getAllValues(); - assertMetric(secondBatch, "waf.updates", 1L, null); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", false, false, false, false, false, false, false)); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", true, false, false, false, false, false, false)); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", false, true, false, false, false, false, false)); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", false, false, true, false, false, false, false)); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", false, false, false, true, false, false, false)); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", false, false, false, false, true, false, false)); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", false, false, false, false, false, true, false)); - assertRequestMetric( - secondBatch, - 1L, - requestTags("rules_ver_2", false, false, false, false, false, false, true)); - } - - private void assertRequestMetric(List metrics, long expectedCount, List tags) { - assertMetric(metrics, "waf.requests", expectedCount, tags); - } - - /** - * Metrics with equal tags are aggregated into a single point by production code, but ones with - * distinct tags land in a HashMap keyed by content, so their relative order in the captured list - * is not guaranteed. Match by (metric name, tags) rather than by position. - */ - private void assertMetric( - List metrics, String metricName, long expectedCount, List tags) { - List matches = - metrics.stream() - .filter(metric -> metric.getMetric().equals(metricName)) - .filter(metric -> tags == null || tags.equals(metric.getTags())) - .collect(Collectors.toList()); - assertEquals(1, matches.size(), "expected exactly one match for " + metricName + " " + tags); - - Metric metric = matches.get(0); - assertEquals("appsec", metric.getNamespace()); - assertEquals(expectedCount, metric.getPoints().get(0).get(1).longValue()); - } - - private List requestTags( - String rulesVersion, - boolean ruleTriggered, - boolean requestBlocked, - boolean wafError, - boolean wafTimeout, - boolean blockFailure, - boolean rateLimited, - boolean inputTruncated) { - return Arrays.asList( - "waf_version:0.0.0", - "event_rules_version:" + rulesVersion, - "rule_triggered:" + ruleTriggered, - "request_blocked:" + requestBlocked, - "waf_error:" + wafError, - "waf_timeout:" + wafTimeout, - "block_failure:" + blockFailure, - "rate_limited:" + rateLimited, - "input_truncated:" + inputTruncated, - "request_excluded:none"); - } + private final WafMetricPeriodicAction periodicAction = new WafMetricPeriodicAction(); + private final TelemetryService telemetryService = mock(TelemetryService.class); + + @Test + void pushWafMetricsIntoTheTelemetryService() { + WafMetricCollector.get().wafInit("0.0.0", "rules_ver_1", true); + WafMetricCollector.get().wafUpdates("rules_ver_2", true); + WafMetricCollector.get().wafUpdates("rules_ver_3", true); + + periodicAction.doIteration(telemetryService); + + ArgumentCaptor captor = forClass(Metric.class); + verify(telemetryService, times(3)).addMetric(captor.capture()); + verifyNoMoreInteractions(telemetryService); + + List metrics = captor.getAllValues(); + assertMetric( + metrics, + "waf.init", + 1L, + Arrays.asList("waf_version:0.0.0", "event_rules_version:rules_ver_1", "success:true")); + assertMetric( + metrics, + "waf.updates", + 1L, + Arrays.asList("waf_version:0.0.0", "event_rules_version:rules_ver_2", "success:true")); + assertMetric( + metrics, + "waf.updates", + 1L, + Arrays.asList("waf_version:0.0.0", "event_rules_version:rules_ver_3", "success:true")); + } + + @Test + void pushWafRequestMetricsAndPushIntoTheTelemetry() { + WafMetricCollector.get().wafInit("0.0.0", "rules_ver_1", true); + WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(true, false, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, true, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, true, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, true, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, false, true, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, true, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, false, false, true, false); + WafMetricCollector.get().prepareMetrics(); + periodicAction.doIteration(telemetryService); + + ArgumentCaptor firstBatchCaptor = forClass(Metric.class); + verify(telemetryService, times(9)).addMetric(firstBatchCaptor.capture()); + verifyNoMoreInteractions(telemetryService); + + List firstBatch = firstBatchCaptor.getAllValues(); + assertMetric(firstBatch, "waf.init", 1L, null); + assertRequestMetric( + firstBatch, 3L, requestTags("rules_ver_1", false, false, false, false, false, false, false)); + assertRequestMetric(firstBatch, 1L, requestTags("rules_ver_1", true, false, false, false, false, false, false)); + assertRequestMetric(firstBatch, 1L, requestTags("rules_ver_1", false, true, false, false, false, false, false)); + assertRequestMetric(firstBatch, 1L, requestTags("rules_ver_1", false, false, true, false, false, false, false)); + assertRequestMetric(firstBatch, 1L, requestTags("rules_ver_1", false, false, false, true, false, false, false)); + assertRequestMetric(firstBatch, 1L, requestTags("rules_ver_1", false, false, false, false, true, false, false)); + assertRequestMetric(firstBatch, 1L, requestTags("rules_ver_1", false, false, false, false, false, true, false)); + assertRequestMetric(firstBatch, 1L, requestTags("rules_ver_1", false, false, false, false, false, false, true)); + + clearInvocations(telemetryService); + + // waf.updates happens + WafMetricCollector.get().wafUpdates("rules_ver_2", true); + WafMetricCollector.get().wafRequest(false, false, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(true, false, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, true, false, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, true, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, true, false, false, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, false, true, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, true, false, false, false); + WafMetricCollector.get().wafRequest(false, false, false, false, false, false, true, false); + WafMetricCollector.get().prepareMetrics(); + periodicAction.doIteration(telemetryService); + + // following waf.request have a new event_rules_version tag + ArgumentCaptor secondBatchCaptor = forClass(Metric.class); + verify(telemetryService, times(9)).addMetric(secondBatchCaptor.capture()); + verifyNoMoreInteractions(telemetryService); + + List secondBatch = secondBatchCaptor.getAllValues(); + assertMetric(secondBatch, "waf.updates", 1L, null); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", false, false, false, false, false, false, false)); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", true, false, false, false, false, false, false)); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", false, true, false, false, false, false, false)); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", false, false, true, false, false, false, false)); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", false, false, false, true, false, false, false)); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", false, false, false, false, true, false, false)); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", false, false, false, false, false, true, false)); + assertRequestMetric( + secondBatch, 1L, requestTags("rules_ver_2", false, false, false, false, false, false, true)); + } + + private void assertRequestMetric(List metrics, long expectedCount, List tags) { + assertMetric(metrics, "waf.requests", expectedCount, tags); + } + + /** + * Metrics with equal tags are aggregated into a single point by production code, but ones with + * distinct tags land in a HashMap keyed by content, so their relative order in the captured list + * is not guaranteed. Match by (metric name, tags) rather than by position. + */ + private void assertMetric(List metrics, String metricName, long expectedCount, List tags) { + List matches = metrics.stream() + .filter(metric -> metric.getMetric().equals(metricName)) + .filter(metric -> tags == null || tags.equals(metric.getTags())) + .collect(Collectors.toList()); + assertEquals(1, matches.size(), "expected exactly one match for " + metricName + " " + tags); + + Metric metric = matches.get(0); + assertEquals("appsec", metric.getNamespace()); + assertEquals(expectedCount, metric.getPoints().get(0).get(1).longValue()); + } + + private List requestTags( + String rulesVersion, + boolean ruleTriggered, + boolean requestBlocked, + boolean wafError, + boolean wafTimeout, + boolean blockFailure, + boolean rateLimited, + boolean inputTruncated) { + return Arrays.asList( + "waf_version:0.0.0", + "event_rules_version:" + rulesVersion, + "rule_triggered:" + ruleTriggered, + "request_blocked:" + requestBlocked, + "waf_error:" + wafError, + "waf_timeout:" + wafTimeout, + "block_failure:" + blockFailure, + "rate_limited:" + rateLimited, + "input_truncated:" + inputTruncated, + "request_excluded:none"); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/products/ProductChangeActionTest.java b/telemetry/src/test/java/datadog/telemetry/products/ProductChangeActionTest.java index d9fcdfad876..4a4c552964e 100644 --- a/telemetry/src/test/java/datadog/telemetry/products/ProductChangeActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/products/ProductChangeActionTest.java @@ -16,22 +16,22 @@ class ProductChangeActionTest { - private final ProductChangeAction action = new ProductChangeAction(); - private final TelemetryService telemetryService = mock(TelemetryService.class); + private final ProductChangeAction action = new ProductChangeAction(); + private final TelemetryService telemetryService = mock(TelemetryService.class); - @Test - void pushProductChangesIntoTheTelemetryService() { - ProductChangeCollector.get().update(new ProductChange().productType(APPSEC).enabled(true)); + @Test + void pushProductChangesIntoTheTelemetryService() { + ProductChangeCollector.get() + .update(new ProductChange().productType(APPSEC).enabled(true)); - action.doIteration(telemetryService); + action.doIteration(telemetryService); - ArgumentCaptor productChangeCaptor = - ArgumentCaptor.forClass(ProductChange.class); - verify(telemetryService, times(1)).addProductChange(productChangeCaptor.capture()); - ProductChange productChange = productChangeCaptor.getValue(); - assertEquals(APPSEC, productChange.getProductType()); - assertTrue(productChange.isEnabled()); + ArgumentCaptor productChangeCaptor = ArgumentCaptor.forClass(ProductChange.class); + verify(telemetryService, times(1)).addProductChange(productChangeCaptor.capture()); + ProductChange productChange = productChangeCaptor.getValue(); + assertEquals(APPSEC, productChange.getProductType()); + assertTrue(productChange.isEnabled()); - verifyNoMoreInteractions(telemetryService); - } + verifyNoMoreInteractions(telemetryService); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/rum/RumPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/rum/RumPeriodicActionTest.java index 83af780c9c5..09f3a6ad04f 100644 --- a/telemetry/src/test/java/datadog/telemetry/rum/RumPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/rum/RumPeriodicActionTest.java @@ -19,48 +19,48 @@ class RumPeriodicActionTest { - private final TelemetryService telemetryService = mock(TelemetryService.class); + private final TelemetryService telemetryService = mock(TelemetryService.class); - @Test - void pushRumMetricsIntoTheTelemetryService() { - RumInjectorMetrics metricsCollector = new RumInjectorMetrics(); - metricsCollector.onInjectionSucceed("3"); - metricsCollector.onInjectionFailed("5", "gzip"); - metricsCollector.onInjectionResponseSize("3", 1024); + @Test + void pushRumMetricsIntoTheTelemetryService() { + RumInjectorMetrics metricsCollector = new RumInjectorMetrics(); + metricsCollector.onInjectionSucceed("3"); + metricsCollector.onInjectionFailed("5", "gzip"); + metricsCollector.onInjectionResponseSize("3", 1024); - RumPeriodicAction periodicAction = new RumPeriodicAction(metricsCollector); + RumPeriodicAction periodicAction = new RumPeriodicAction(metricsCollector); - periodicAction.doIteration(telemetryService); + periodicAction.doIteration(telemetryService); - ArgumentCaptor metricCaptor = forClass(Metric.class); - verify(telemetryService, times(2)).addMetric(metricCaptor.capture()); - Metric succeedMetric = metricCaptor.getAllValues().get(0); - assertEquals("rum", succeedMetric.getNamespace()); - assertEquals("injection.succeed", succeedMetric.getMetric()); - assertEquals(Metric.TypeEnum.COUNT, succeedMetric.getType()); + ArgumentCaptor metricCaptor = forClass(Metric.class); + verify(telemetryService, times(2)).addMetric(metricCaptor.capture()); + Metric succeedMetric = metricCaptor.getAllValues().get(0); + assertEquals("rum", succeedMetric.getNamespace()); + assertEquals("injection.succeed", succeedMetric.getMetric()); + assertEquals(Metric.TypeEnum.COUNT, succeedMetric.getType()); - Metric failedMetric = metricCaptor.getAllValues().get(1); - assertEquals("rum", failedMetric.getNamespace()); - assertEquals("injection.failed", failedMetric.getMetric()); - assertEquals(Metric.TypeEnum.COUNT, failedMetric.getType()); + Metric failedMetric = metricCaptor.getAllValues().get(1); + assertEquals("rum", failedMetric.getNamespace()); + assertEquals("injection.failed", failedMetric.getMetric()); + assertEquals(Metric.TypeEnum.COUNT, failedMetric.getType()); - ArgumentCaptor distributionCaptor = forClass(DistributionSeries.class); - verify(telemetryService, times(1)).addDistributionSeries(distributionCaptor.capture()); - DistributionSeries distribution = distributionCaptor.getValue(); - assertEquals("rum", distribution.getNamespace()); - assertEquals("injection.response.bytes", distribution.getMetric()); + ArgumentCaptor distributionCaptor = forClass(DistributionSeries.class); + verify(telemetryService, times(1)).addDistributionSeries(distributionCaptor.capture()); + DistributionSeries distribution = distributionCaptor.getValue(); + assertEquals("rum", distribution.getNamespace()); + assertEquals("injection.response.bytes", distribution.getMetric()); - verifyNoMoreInteractions(telemetryService); - } + verifyNoMoreInteractions(telemetryService); + } - @Test - void pushNothingWhenNoMetricsCollectorIsSet() { - RumPeriodicAction periodicAction = new RumPeriodicAction(RumTelemetryCollector.NO_OP); + @Test + void pushNothingWhenNoMetricsCollectorIsSet() { + RumPeriodicAction periodicAction = new RumPeriodicAction(RumTelemetryCollector.NO_OP); - periodicAction.doIteration(telemetryService); + periodicAction.doIteration(telemetryService); - verify(telemetryService, never()).addMetric(any()); - verify(telemetryService, never()).addDistributionSeries(any()); - verifyNoMoreInteractions(telemetryService); - } + verify(telemetryService, never()).addMetric(any()); + verify(telemetryService, never()).addDistributionSeries(any()); + verifyNoMoreInteractions(telemetryService); + } } diff --git a/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java b/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java index 4037e73286c..966327ff637 100644 --- a/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java +++ b/telemetry/src/test/java/datadog/telemetry/sca/ScaReachabilityPeriodicActionTest.java @@ -27,648 +27,617 @@ class ScaReachabilityPeriodicActionTest { - private TelemetryService telService; - private ScaReachabilityPeriodicAction action; - - @BeforeEach - void setUp() { - ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); - telService = mock(TelemetryService.class); - DependencyService depService = mock(DependencyService.class); - when(depService.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); - action = new ScaReachabilityPeriodicAction(depService); - // Pre-populate knownDeps with the common test dep so registry-only tests can emit via Step 3. - // This simulates the dep having been resolved by DependencyService in a prior heartbeat. - action.addKnownDepForTesting("com.example:lib", "1.0.0"); - action.addKnownDepForTesting("com.example:lib-a", "1.0.0"); - action.addKnownDepForTesting("com.example:lib-b", "2.0.0"); - action.addKnownDepForTesting("com.other:lib", "2.0.0"); - } - - @Test - void doesNothingWhenNoPendingDependencies() { - action.doIteration(telService); - verify(telService, never()).addDependency(org.mockito.Mockito.any()); - } - - @Test - void reportsRegisteredCveWithEmptyReached() { - // CVE registered but no hit yet → metadata: [{cve-1, reached:[]}] - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-xxx"); - - action.doIteration(telService); - - ArgumentCaptor captor = forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency dep = captor.getValue(); - assertEquals("com.example:lib", dep.name); - assertEquals(1, dep.reachabilityMetadata.size()); - assertTrue( - dep.reachabilityMetadata.get(0).contains("\"reached\":[]"), - "CVE with no hit must have reached:[]"); - assertTrue(dep.reachabilityMetadata.get(0).contains("\"id\":\"GHSA-xxx\"")); - } - - @Test - void reportsRegisteredCveWithCallsiteAfterHit() { - // CVE registered, then hit → metadata: [{cve-1, reached:[callsite]}] - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-xxx"); - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-xxx", "com.myapp.Service", "process", 42); - - action.doIteration(telService); - - ArgumentCaptor captor = forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - String metaValue = captor.getValue().reachabilityMetadata.get(0); - assertTrue(metaValue.contains("\"path\":\"com.myapp.Service\"")); - assertTrue(metaValue.contains("\"symbol\":\"process\"")); - assertTrue(metaValue.contains("\"line\":42")); - assertFalse(metaValue.contains("\"reached\":[]"), "Hit must not produce empty reached"); - } - - @Test - void groupsTwoCvesForSameArtifactIntoOneEntry() { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-cve-1"); - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-cve-2"); - - action.doIteration(telService); - - ArgumentCaptor captor = forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency dep = captor.getValue(); - assertEquals(2, dep.reachabilityMetadata.size()); - assertTrue(dep.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-1"))); - assertTrue(dep.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-2"))); - } - - @Test - void reportsAllCvesWhenOneIsHit() { - // RFC requirement: when cve-1 is hit, re-report BOTH cve-1 (with callsite) and cve-2 (empty) - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-cve-1"); - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-cve-2"); - // First heartbeat: both sent with empty reached - action.doIteration(telService); - - // Now hit cve-1 - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-cve-1", "com.myapp.Svc", "call", 10); - - // Second heartbeat: BOTH CVEs re-reported — cve-1 with callsite, cve-2 still empty - action.doIteration(telService); - - ArgumentCaptor captor = forClass(Dependency.class); - verify(telService, times(2)).addDependency(captor.capture()); - List reported = captor.getAllValues(); - Dependency secondReport = reported.get(1); - assertEquals(2, secondReport.reachabilityMetadata.size()); - // cve-1 now has a callsite - assertTrue( - secondReport.reachabilityMetadata.stream() - .anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\""))); - // cve-2 still has empty reached - assertTrue( - secondReport.reachabilityMetadata.stream() - .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"reached\":[]"))); - } - - @Test - void separateEntriesForDifferentArtifacts() { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib-a", "1.0.0", "GHSA-a"); - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib-b", "2.0.0", "GHSA-b"); - - action.doIteration(telService); - - verify(telService, times(2)).addDependency(org.mockito.Mockito.any()); - } - - @Test - void drainsClearsPendingState() { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-x"); - - action.doIteration(telService); - verify(telService, times(1)).addDependency(org.mockito.Mockito.any()); - - // Second iteration with no new state — nothing to report - TelemetryService telService2 = mock(TelemetryService.class); - action.doIteration(telService2); - verify(telService2, never()).addDependency(org.mockito.Mockito.any()); - } - - /** - * Validates the full RFC heartbeat flow (Heartbeats #2–#6 from the spec): - * - *
    - *
  1. Heartbeat after CVE registration: both CVEs reported with reached:[] - *
  2. Heartbeat with no changes: nothing reported - *
  3. Heartbeat after first CVE hit: both CVEs reported (one with callsite, one empty) - *
  4. Heartbeat with no changes: nothing reported - *
  5. Heartbeat after second CVE hit: both CVEs reported with their respective callsites - *
- */ - @Test - void rfcFullHeartbeatFlow_twoCveSameDepBothHitSequentially() { - // Phase 1 — CVE registration (Heartbeat #2) - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-cve-1"); - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-cve-2"); - - action.doIteration(telService); - - ArgumentCaptor captor1 = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor1.capture()); - Dependency hb2 = captor1.getValue(); - assertEquals(2, hb2.reachabilityMetadata.size()); - assertTrue( - hb2.reachabilityMetadata.stream().allMatch(v -> v.contains("\"reached\":[]")), - "Heartbeat #2: both CVEs must have reached:[]"); - - // Phase 2 — No changes (Heartbeat #3) - TelemetryService telService3 = mock(TelemetryService.class); - action.doIteration(telService3); - verify(telService3, never()).addDependency(org.mockito.Mockito.any()); - - // Phase 3 — First CVE hit (Heartbeat #4) - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-cve-1", "com.myapp.Controller", "handleRequest", 10); - - TelemetryService telService4 = mock(TelemetryService.class); - action.doIteration(telService4); - - ArgumentCaptor captor4 = ArgumentCaptor.forClass(Dependency.class); - verify(telService4, times(1)).addDependency(captor4.capture()); - Dependency hb4 = captor4.getValue(); - assertEquals(2, hb4.reachabilityMetadata.size()); - assertTrue( - hb4.reachabilityMetadata.stream() - .anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\"")), - "Heartbeat #4: cve-1 must have callsite"); - assertTrue( - hb4.reachabilityMetadata.stream() - .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"reached\":[]")), - "Heartbeat #4: cve-2 must still have reached:[]"); - - // Phase 4 — No changes (Heartbeat #5) - TelemetryService telService5 = mock(TelemetryService.class); - action.doIteration(telService5); - verify(telService5, never()).addDependency(org.mockito.Mockito.any()); - - // Phase 5 — Second CVE hit (Heartbeat #6) - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-cve-2", "com.myapp.Service", "processData", 44); - - TelemetryService telService6 = mock(TelemetryService.class); - action.doIteration(telService6); - - ArgumentCaptor captor6 = ArgumentCaptor.forClass(Dependency.class); - verify(telService6, times(1)).addDependency(captor6.capture()); - Dependency hb6 = captor6.getValue(); - assertEquals(2, hb6.reachabilityMetadata.size()); - assertTrue( - hb6.reachabilityMetadata.stream() - .anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\"")), - "Heartbeat #6: cve-1 must retain callsite"); - assertTrue( - hb6.reachabilityMetadata.stream() - .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"path\"")), - "Heartbeat #6: cve-2 must now have callsite"); - } - - @Test - void buildMetadataValue_emptyReachedWhenNoHit() { - ScaReachabilityDependencyRegistry.CveSnapshot cve = - new ScaReachabilityDependencyRegistry.CveSnapshot("GHSA-645p-88qh-w398", null); - - String value = ScaReachabilityPeriodicAction.buildMetadataValue(cve); - - assertEquals( - "{\"id\":\"GHSA-645p-88qh-w398\",\"reached\":[]}", - value, - "CVE with no hit must produce reached:[]"); - } - - @Test - void buildMetadataValue_includesCallsiteWhenHit() { - ScaReachabilityHit hit = - new ScaReachabilityHit( - "GHSA-645p-88qh-w398", - "com.fasterxml.jackson.core:jackson-databind", - "2.8.5", - "com.fasterxml.jackson.databind.ObjectMapper", - "", - 1); - ScaReachabilityDependencyRegistry.CveSnapshot cve = - new ScaReachabilityDependencyRegistry.CveSnapshot("GHSA-645p-88qh-w398", hit); - - String value = ScaReachabilityPeriodicAction.buildMetadataValue(cve); - - assertEquals( - "{\"id\":\"GHSA-645p-88qh-w398\"," - + "\"reached\":[{" - + "\"path\":\"com.fasterxml.jackson.databind.ObjectMapper\"," - + "\"symbol\":\"\"," - + "\"line\":1}]}", - value); - } - - // --------------------------------------------------------------------------- - // Merge logic: DependencyService + ScaReachabilityDependencyRegistry - // --------------------------------------------------------------------------- - - private static ScaReachabilityPeriodicAction actionWithDeps(Dependency... deps) { - DependencyService svc = mock(DependencyService.class); - org.mockito.Mockito.when(svc.drainDeterminedDependencies()) - .thenReturn(java.util.Arrays.asList(deps)); - return new ScaReachabilityPeriodicAction(svc); - } - - @Test - void newDep_noCveState_emitsWithEmptyMetadata() { - // DependencyService returns a new dep; registry has nothing for it. - // Expected: one entry with metadata:[] (SCA-active signal, no CVE data yet). - Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", null); - ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); - - merged.doIteration(telService); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency emitted = captor.getValue(); - assertEquals("com.example:lib", emitted.name); - assertEquals("1.0.0", emitted.version); - assertNotNull(emitted.reachabilityMetadata, "metadata must not be null when SCA active"); - assertTrue(emitted.reachabilityMetadata.isEmpty(), "metadata must be [] when no CVE state"); - } - - @Test - void newDep_withCveState_emitsMergedSingleEntry() { - // DependencyService returns dep X; registry has a pending CVE state for the same dep. - // Expected: ONE entry with the CVE metadata merged in — no separate dep:[] entry. - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-test-1234"); - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-test-1234", "com.myapp.Ctrl", "handle", 10); - - Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", "ABCD"); - ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); - - merged.doIteration(telService); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency emitted = captor.getValue(); - assertEquals("com.example:lib", emitted.name); - assertEquals("1.0.0", emitted.version); - assertEquals("ABCD", emitted.hash, "source/hash from DependencyService must be preserved"); - assertEquals(1, emitted.reachabilityMetadata.size()); - assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-test-1234")); - assertTrue( - emitted.reachabilityMetadata.get(0).contains("\"path\""), - "merged entry must include callsite"); - } - - @Test - void newDepAndUnrelatedCveState_emitsTwoIndependentEntries() { - // DependencyService returns depA; registry has pending state for depB (different dep). - // Expected: two separate entries — one for depA (metadata:[]), one for depB (CVE metadata). - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.other:lib", "2.0.0", "GHSA-other-5678"); - - Dependency incomingA = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", null); - ScaReachabilityPeriodicAction merged = actionWithDeps(incomingA); - // Simulate com.other:lib having been resolved by DependencyService in a prior heartbeat - merged.addKnownDepForTesting("com.other:lib", "2.0.0"); - - merged.doIteration(telService); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(2)).addDependency(captor.capture()); - java.util.List emitted = captor.getAllValues(); - - Dependency depA = - emitted.stream() - .filter(d -> "com.example:lib".equals(d.name)) - .findFirst() - .orElseThrow(() -> new AssertionError("dep not found")); - Dependency depB = - emitted.stream() - .filter(d -> "com.other:lib".equals(d.name)) - .findFirst() - .orElseThrow(() -> new AssertionError("dep not found")); - - assertTrue(depA.reachabilityMetadata.isEmpty(), "depA: no CVE state → metadata:[]"); - assertTrue( - depB.reachabilityMetadata.get(0).contains("GHSA-other-5678"), "depB: must carry CVE state"); - } - - // --------------------------------------------------------------------------- - // knownDeps / timing invariant tests - // --------------------------------------------------------------------------- - - /** - * Dep resolved by DependencyService in heartbeat N; CVE fires in heartbeat N+1. The dep is - * already in knownDeps, so Step 3 emits it with source/hash. - */ - @Test - void cveFiresAfterDepResolved_usesKnownDepsForSourceHash() { - DependencyService svc = mock(DependencyService.class); - // Heartbeat 1: DependencyService returns the dep, no CVE yet - when(svc.drainDeterminedDependencies()) - .thenReturn( - Collections.singletonList( - new Dependency("com.example:lib", "1.0.0", "lib.jar", "ABCD"))) - .thenReturn(Collections.emptyList()); // heartbeat 2: nothing new - ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); - - // Heartbeat 1: dep detected, no CVE → emits metadata:[] - merged.doIteration(telService); - - // CVE fires between heartbeat 1 and 2 - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-late"); - - // Heartbeat 2: DependencyService is empty, but CVE is pending - TelemetryService telService2 = mock(TelemetryService.class); - merged.doIteration(telService2); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService2, times(1)).addDependency(captor.capture()); - Dependency emitted = captor.getValue(); - assertEquals("lib.jar", emitted.source, "source from knownDeps must be preserved"); - assertEquals("ABCD", emitted.hash, "hash from knownDeps must be preserved"); - assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-late")); - } - - /** - * CVE fires before DependencyService has resolved the dep (timing race). - * - *

Step 3 emits immediately without source/hash so CVE data is never delayed (system tests need - * data within seconds). When the dep is later resolved and stored in knownDeps, subsequent CVE - * emissions (e.g., after a method hit) carry source/hash automatically. - */ - @Test - void cveFiresBeforeDepResolved_emitsImmediatelyWithoutSourceHash() { - DependencyService svc = mock(DependencyService.class); - // Heartbeat 1: DependencyService is empty (dep not yet resolved) - when(svc.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); - ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); - - // CVE fires before DependencyService resolves the dep - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-race"); - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-race", "com.app.Ctrl", "handle", 10); - - // Heartbeat 1: emits immediately without source/hash — CVE data is not delayed - merged.doIteration(telService); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency emitted = captor.getValue(); - assertNull(emitted.source, "source is null when dep not yet in knownDeps"); - assertNull(emitted.hash, "hash is null when dep not yet in knownDeps"); - assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-race")); - assertTrue(emitted.reachabilityMetadata.get(0).contains("\"path\""), "must include callsite"); - } - - /** - * Regression test for the Codex P2 race condition: CVE registered in HB1 (dep not yet resolved), - * emitted immediately without source/hash. In HB2 DependencyService resolves the JAR — if we - * naively emit metadata:[] because there is no pending snapshot, the backend overwrites the CVE - * state it already received. The fix uses peekSnapshot so HB2 emits the CVE metadata together - * with the now-known source/hash. - */ - @Test - void cveRegisteredBeforeDepResolved_step2PeeksCveStateInLaterHeartbeat() { - DependencyService svc = mock(DependencyService.class); - - // HB1: CVE registered, dep not yet resolved by DependencyService - when(svc.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); - ScaReachabilityPeriodicAction action = new ScaReachabilityPeriodicAction(svc); - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-peek"); - - action.doIteration(telService); - - // HB1 emits once: CVE state without source/hash (dep not yet resolved) - verify(telService, times(1)).addDependency(any(Dependency.class)); - reset(telService); - - // HB2: DependencyService now resolves the JAR; no new CVE activity (pendingReport=false) - Dependency resolved = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", "CAFEBABE"); - when(svc.drainDeterminedDependencies()).thenReturn(Collections.singletonList(resolved)); - - action.doIteration(telService); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency emitted = captor.getValue(); - assertEquals("lib-1.0.0.jar", emitted.source, "HB2 must include resolved source"); - assertEquals("CAFEBABE", emitted.hash, "HB2 must include resolved hash"); - assertFalse( - emitted.reachabilityMetadata.isEmpty(), - "HB2 must not emit metadata:[] — CVE state must be preserved via peekSnapshot"); - assertTrue( - emitted.reachabilityMetadata.get(0).contains("GHSA-peek"), - "HB2 metadata must contain the CVE id"); - } - - /** - * Regression test for the multi-classloader re-detection bug. - * - *

{@link DependencyService} can report the same JAR on consecutive heartbeats when it - * discovers it from multiple classloaders in the same application. The CVE hit must be emitted - * exactly once — on the heartbeat where the CVE state is pending or first detected via {@code - * peekSnapshot}. Re-detections of the same JAR with no new CVE activity must produce no emission. - * - *

Without the fix, Step 2 called {@code peekSnapshot} unconditionally when {@code - * snapshotByKey} had no entry, causing the hit to be re-emitted on every subsequent heartbeat - * where {@code DependencyService} reported the JAR again (e.g. from a different classloader). - */ - @Test - void depReDetectedByDependencyService_hitNotReEmittedOnSubsequentDetection() { - DependencyService svc = mock(DependencyService.class); - Dependency dep = new Dependency("com.example:lib", "1.0.0", "lib.jar", "HASH"); - // Same dep returned on both heartbeats (simulates multiple classloaders detecting the same JAR) - when(svc.drainDeterminedDependencies()) - .thenReturn(Collections.singletonList(dep)) // HB1: first detection - .thenReturn(Collections.singletonList(dep)); // HB2: re-detected from another classloader - ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); - - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-dup"); - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-dup", "com.app.Ctrl", "handle", 10); - - // HB1: first detection, CVE is pending → emit merged entry with hit - merged.doIteration(telService); - ArgumentCaptor captor1 = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor1.capture()); - assertTrue( - captor1.getValue().reachabilityMetadata.get(0).contains("\"path\""), - "HB1 must include the callsite hit"); - reset(telService); - - // HB2: same JAR re-detected, no new CVE activity → must NOT re-emit the hit - merged.doIteration(telService); - verify(telService, never()).addDependency(any()); - } - - /** - * Companion to {@link #depReDetectedByDependencyService_hitNotReEmittedOnSubsequentDetection}: - * verifies that a REAL new CVE state change (hit arriving between heartbeats) is still emitted - * correctly even when DependencyService also reports the dep again on the same heartbeat. - */ - @Test - void depReDetectedWithNewHit_emitsOnceWithNewHit() { - DependencyService svc = mock(DependencyService.class); - Dependency dep = new Dependency("com.example:lib", "1.0.0", "lib.jar", "HASH"); - when(svc.drainDeterminedDependencies()) - .thenReturn(Collections.singletonList(dep)) // HB1 - .thenReturn(Collections.singletonList(dep)); // HB2 - ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); - - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-new"); - - // HB1: first detection, CVE pending with reached:[] → emitted once - merged.doIteration(telService); - verify(telService, times(1)).addDependency(any()); - reset(telService); - - // Hit recorded between HB1 and HB2 - ScaReachabilityDependencyRegistry.INSTANCE.recordHit( - "com.example:lib", "1.0.0", "GHSA-new", "com.app.Svc", "exec", 7); - - // HB2: re-detection AND new hit pending (snapshotByKey has the entry) → emit once with hit - merged.doIteration(telService); - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - assertTrue( - captor.getValue().reachabilityMetadata.get(0).contains("\"path\""), - "HB2 must include the new callsite hit"); - } - - /** - * Dep and CVE arrive simultaneously (same heartbeat) — existing Step 2 merge path. This existing - * behavior must still work after the knownDeps refactor. - */ - @Test - void cveAndDepArriveSameHeartbeat_step2MergeStillWorks() { - ScaReachabilityDependencyRegistry.INSTANCE.registerCve( - "com.example:lib", "1.0.0", "GHSA-simultaneous"); - - Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib.jar", "HASH"); - ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); - - merged.doIteration(telService); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency emitted = captor.getValue(); - assertEquals("lib.jar", emitted.source, "Step 2 merge must preserve source"); - assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-simultaneous")); - } - - /** - * Regression test for the registry key mismatch for JARs without pom.properties (PR #11614). - * - *

For JARs without {@code pom.properties}, {@code DependencyResolver.guessFallbackNoPom} - * produces an artifact-ID-only name (e.g. {@code "junrar"} instead of {@code - * "com.github.junrar:junrar"}). The transformer must register the CVE under the same name so that - * the registry key matches what {@code DependencyService} will report. - * - *

Without the fix, {@code registerCve} was called with {@code entry.artifact()} = {@code - * "com.github.junrar:junrar"}, while {@code DependencyService} reported {@code dep.name} = {@code - * "junrar"}. The result: two separate telemetry entries for the same physical JAR — one with - * {@code metadata:[]} (from Step 2, carrying source/hash but no CVE) and one with the CVE - * metadata (from Step 3, without source/hash). - * - *

With the fix, the transformer uses the resolved {@code dep.name} from {@code matchDep} (i.e. - * {@code "junrar"}) for {@code registerCve}, so the registry and {@code DependencyService} keys - * match. Step 2 merges them into a single emission with both the CVE metadata and source/hash. - */ - @Test - void noPomJar_artifactIdOnlyName_cveAndSourceHashMergedIntoSingleEntry() { - // Simulates what ScaReachabilityTransformer.processClass() now does after the fix: - // registerCve with the artifactId-only name that DependencyService will also report. - ScaReachabilityDependencyRegistry.INSTANCE.registerCve("junrar", "7.5.5", "GHSA-hf5p-test"); - - // DependencyService returns the dep with the same artifactId-only name (guessFallbackNoPom). - Dependency incoming = new Dependency("junrar", "7.5.5", "junrar-7.5.5.jar", "CAFEBABE"); - ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); - - merged.doIteration(telService); - - // Must produce exactly ONE emission with BOTH the CVE metadata AND source/hash — not two - // separate entries (one with CVE but no source/hash, one with source/hash but no CVE). - ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor.capture()); - Dependency emitted = captor.getValue(); - assertEquals( - "junrar", emitted.name, "name must be the artifactId-only name from DependencyService"); - assertEquals("7.5.5", emitted.version); - assertEquals( - "junrar-7.5.5.jar", emitted.source, "source/hash from DependencyService must be preserved"); - assertEquals("CAFEBABE", emitted.hash, "hash from DependencyService must be preserved"); - assertFalse( - emitted.reachabilityMetadata.isEmpty(), - "CVE metadata must NOT be lost — a single merged entry must carry both CVE data and source/hash"); - assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-hf5p-test")); - } - - /** - * Same physical copy (identical source/hash) re-detected on a later heartbeat must NOT be - * re-emitted — the backend already received it. Mirrors a Spring Boot {@code - * LaunchedURLClassLoader} reporting the same nested JAR (same URI) again. - */ - @Test - void sameSourceHashReDetected_doesNotReEmit() { - DependencyService svc = mock(DependencyService.class); - Dependency dep = new Dependency("com.example:lib", "1.0", "lib.jar", "AABB"); - when(svc.drainDeterminedDependencies()) - .thenReturn(Collections.singletonList(dep)) // HB1: first detection - .thenReturn(Collections.singletonList(dep)); // HB2: same physical copy re-detected - ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); - - // HB1: first detection → emits metadata:[] (SCA monitoring signal) - merged.doIteration(telService); - verify(telService, times(1)).addDependency(any(Dependency.class)); - reset(telService); - - // HB2: same source/hash → backend already knows it → no emission - merged.doIteration(telService); - verify(telService, never()).addDependency(any()); - } - - /** - * Two physical copies of the same {@code name@version} but with different source/hash (e.g. two - * Tomcat webapps each shipping their own copy of the artifact) must BOTH be emitted, matching the - * legacy {@code DependencyPeriodicAction} behavior. - */ - @Test - void differentSourceHashSameKey_emitsBothPhysicalCopies() { - DependencyService svc = mock(DependencyService.class); - Dependency copy1 = new Dependency("com.example:lib", "1.0", "/app1/lib.jar", "AABB"); - Dependency copy2 = new Dependency("com.example:lib", "1.0", "/app2/lib.jar", "CCDD"); - when(svc.drainDeterminedDependencies()) - .thenReturn(Collections.singletonList(copy1)) // HB1: first physical copy - .thenReturn(Collections.singletonList(copy2)); // HB2: distinct physical copy - ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); - - // HB1: first copy → emitted - merged.doIteration(telService); - ArgumentCaptor captor1 = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor1.capture()); - assertEquals("/app1/lib.jar", captor1.getValue().source); - assertEquals("AABB", captor1.getValue().hash); - reset(telService); - - // HB2: different source/hash → new physical copy → also emitted - merged.doIteration(telService); - ArgumentCaptor captor2 = ArgumentCaptor.forClass(Dependency.class); - verify(telService, times(1)).addDependency(captor2.capture()); - assertEquals("/app2/lib.jar", captor2.getValue().source); - assertEquals("CCDD", captor2.getValue().hash); - } + private TelemetryService telService; + private ScaReachabilityPeriodicAction action; + + @BeforeEach + void setUp() { + ScaReachabilityDependencyRegistry.INSTANCE.resetForTesting(); + telService = mock(TelemetryService.class); + DependencyService depService = mock(DependencyService.class); + when(depService.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); + action = new ScaReachabilityPeriodicAction(depService); + // Pre-populate knownDeps with the common test dep so registry-only tests can emit via Step 3. + // This simulates the dep having been resolved by DependencyService in a prior heartbeat. + action.addKnownDepForTesting("com.example:lib", "1.0.0"); + action.addKnownDepForTesting("com.example:lib-a", "1.0.0"); + action.addKnownDepForTesting("com.example:lib-b", "2.0.0"); + action.addKnownDepForTesting("com.other:lib", "2.0.0"); + } + + @Test + void doesNothingWhenNoPendingDependencies() { + action.doIteration(telService); + verify(telService, never()).addDependency(org.mockito.Mockito.any()); + } + + @Test + void reportsRegisteredCveWithEmptyReached() { + // CVE registered but no hit yet → metadata: [{cve-1, reached:[]}] + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-xxx"); + + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency dep = captor.getValue(); + assertEquals("com.example:lib", dep.name); + assertEquals(1, dep.reachabilityMetadata.size()); + assertTrue(dep.reachabilityMetadata.get(0).contains("\"reached\":[]"), "CVE with no hit must have reached:[]"); + assertTrue(dep.reachabilityMetadata.get(0).contains("\"id\":\"GHSA-xxx\"")); + } + + @Test + void reportsRegisteredCveWithCallsiteAfterHit() { + // CVE registered, then hit → metadata: [{cve-1, reached:[callsite]}] + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-xxx"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-xxx", "com.myapp.Service", "process", 42); + + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + String metaValue = captor.getValue().reachabilityMetadata.get(0); + assertTrue(metaValue.contains("\"path\":\"com.myapp.Service\"")); + assertTrue(metaValue.contains("\"symbol\":\"process\"")); + assertTrue(metaValue.contains("\"line\":42")); + assertFalse(metaValue.contains("\"reached\":[]"), "Hit must not produce empty reached"); + } + + @Test + void groupsTwoCvesForSameArtifactIntoOneEntry() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-cve-1"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-cve-2"); + + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency dep = captor.getValue(); + assertEquals(2, dep.reachabilityMetadata.size()); + assertTrue(dep.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-1"))); + assertTrue(dep.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-2"))); + } + + @Test + void reportsAllCvesWhenOneIsHit() { + // RFC requirement: when cve-1 is hit, re-report BOTH cve-1 (with callsite) and cve-2 (empty) + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-cve-1"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-cve-2"); + // First heartbeat: both sent with empty reached + action.doIteration(telService); + + // Now hit cve-1 + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-cve-1", "com.myapp.Svc", "call", 10); + + // Second heartbeat: BOTH CVEs re-reported — cve-1 with callsite, cve-2 still empty + action.doIteration(telService); + + ArgumentCaptor captor = forClass(Dependency.class); + verify(telService, times(2)).addDependency(captor.capture()); + List reported = captor.getAllValues(); + Dependency secondReport = reported.get(1); + assertEquals(2, secondReport.reachabilityMetadata.size()); + // cve-1 now has a callsite + assertTrue(secondReport.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\""))); + // cve-2 still has empty reached + assertTrue(secondReport.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"reached\":[]"))); + } + + @Test + void separateEntriesForDifferentArtifacts() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib-a", "1.0.0", "GHSA-a"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib-b", "2.0.0", "GHSA-b"); + + action.doIteration(telService); + + verify(telService, times(2)).addDependency(org.mockito.Mockito.any()); + } + + @Test + void drainsClearsPendingState() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-x"); + + action.doIteration(telService); + verify(telService, times(1)).addDependency(org.mockito.Mockito.any()); + + // Second iteration with no new state — nothing to report + TelemetryService telService2 = mock(TelemetryService.class); + action.doIteration(telService2); + verify(telService2, never()).addDependency(org.mockito.Mockito.any()); + } + + /** + * Validates the full RFC heartbeat flow (Heartbeats #2–#6 from the spec): + * + *

    + *
  1. Heartbeat after CVE registration: both CVEs reported with reached:[] + *
  2. Heartbeat with no changes: nothing reported + *
  3. Heartbeat after first CVE hit: both CVEs reported (one with callsite, one empty) + *
  4. Heartbeat with no changes: nothing reported + *
  5. Heartbeat after second CVE hit: both CVEs reported with their respective callsites + *
+ */ + @Test + void rfcFullHeartbeatFlow_twoCveSameDepBothHitSequentially() { + // Phase 1 — CVE registration (Heartbeat #2) + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-cve-1"); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-cve-2"); + + action.doIteration(telService); + + ArgumentCaptor captor1 = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor1.capture()); + Dependency hb2 = captor1.getValue(); + assertEquals(2, hb2.reachabilityMetadata.size()); + assertTrue( + hb2.reachabilityMetadata.stream().allMatch(v -> v.contains("\"reached\":[]")), + "Heartbeat #2: both CVEs must have reached:[]"); + + // Phase 2 — No changes (Heartbeat #3) + TelemetryService telService3 = mock(TelemetryService.class); + action.doIteration(telService3); + verify(telService3, never()).addDependency(org.mockito.Mockito.any()); + + // Phase 3 — First CVE hit (Heartbeat #4) + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-cve-1", "com.myapp.Controller", "handleRequest", 10); + + TelemetryService telService4 = mock(TelemetryService.class); + action.doIteration(telService4); + + ArgumentCaptor captor4 = ArgumentCaptor.forClass(Dependency.class); + verify(telService4, times(1)).addDependency(captor4.capture()); + Dependency hb4 = captor4.getValue(); + assertEquals(2, hb4.reachabilityMetadata.size()); + assertTrue( + hb4.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\"")), + "Heartbeat #4: cve-1 must have callsite"); + assertTrue( + hb4.reachabilityMetadata.stream() + .anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"reached\":[]")), + "Heartbeat #4: cve-2 must still have reached:[]"); + + // Phase 4 — No changes (Heartbeat #5) + TelemetryService telService5 = mock(TelemetryService.class); + action.doIteration(telService5); + verify(telService5, never()).addDependency(org.mockito.Mockito.any()); + + // Phase 5 — Second CVE hit (Heartbeat #6) + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-cve-2", "com.myapp.Service", "processData", 44); + + TelemetryService telService6 = mock(TelemetryService.class); + action.doIteration(telService6); + + ArgumentCaptor captor6 = ArgumentCaptor.forClass(Dependency.class); + verify(telService6, times(1)).addDependency(captor6.capture()); + Dependency hb6 = captor6.getValue(); + assertEquals(2, hb6.reachabilityMetadata.size()); + assertTrue( + hb6.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-1") && v.contains("\"path\"")), + "Heartbeat #6: cve-1 must retain callsite"); + assertTrue( + hb6.reachabilityMetadata.stream().anyMatch(v -> v.contains("GHSA-cve-2") && v.contains("\"path\"")), + "Heartbeat #6: cve-2 must now have callsite"); + } + + @Test + void buildMetadataValue_emptyReachedWhenNoHit() { + ScaReachabilityDependencyRegistry.CveSnapshot cve = + new ScaReachabilityDependencyRegistry.CveSnapshot("GHSA-645p-88qh-w398", null); + + String value = ScaReachabilityPeriodicAction.buildMetadataValue(cve); + + assertEquals( + "{\"id\":\"GHSA-645p-88qh-w398\",\"reached\":[]}", value, "CVE with no hit must produce reached:[]"); + } + + @Test + void buildMetadataValue_includesCallsiteWhenHit() { + ScaReachabilityHit hit = new ScaReachabilityHit( + "GHSA-645p-88qh-w398", + "com.fasterxml.jackson.core:jackson-databind", + "2.8.5", + "com.fasterxml.jackson.databind.ObjectMapper", + "", + 1); + ScaReachabilityDependencyRegistry.CveSnapshot cve = + new ScaReachabilityDependencyRegistry.CveSnapshot("GHSA-645p-88qh-w398", hit); + + String value = ScaReachabilityPeriodicAction.buildMetadataValue(cve); + + assertEquals( + "{\"id\":\"GHSA-645p-88qh-w398\"," + + "\"reached\":[{" + + "\"path\":\"com.fasterxml.jackson.databind.ObjectMapper\"," + + "\"symbol\":\"\"," + + "\"line\":1}]}", + value); + } + + // --------------------------------------------------------------------------- + // Merge logic: DependencyService + ScaReachabilityDependencyRegistry + // --------------------------------------------------------------------------- + + private static ScaReachabilityPeriodicAction actionWithDeps(Dependency... deps) { + DependencyService svc = mock(DependencyService.class); + org.mockito.Mockito.when(svc.drainDeterminedDependencies()).thenReturn(java.util.Arrays.asList(deps)); + return new ScaReachabilityPeriodicAction(svc); + } + + @Test + void newDep_noCveState_emitsWithEmptyMetadata() { + // DependencyService returns a new dep; registry has nothing for it. + // Expected: one entry with metadata:[] (SCA-active signal, no CVE data yet). + Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", null); + ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("com.example:lib", emitted.name); + assertEquals("1.0.0", emitted.version); + assertNotNull(emitted.reachabilityMetadata, "metadata must not be null when SCA active"); + assertTrue(emitted.reachabilityMetadata.isEmpty(), "metadata must be [] when no CVE state"); + } + + @Test + void newDep_withCveState_emitsMergedSingleEntry() { + // DependencyService returns dep X; registry has a pending CVE state for the same dep. + // Expected: ONE entry with the CVE metadata merged in — no separate dep:[] entry. + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-test-1234"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-test-1234", "com.myapp.Ctrl", "handle", 10); + + Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", "ABCD"); + ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("com.example:lib", emitted.name); + assertEquals("1.0.0", emitted.version); + assertEquals("ABCD", emitted.hash, "source/hash from DependencyService must be preserved"); + assertEquals(1, emitted.reachabilityMetadata.size()); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-test-1234")); + assertTrue(emitted.reachabilityMetadata.get(0).contains("\"path\""), "merged entry must include callsite"); + } + + @Test + void newDepAndUnrelatedCveState_emitsTwoIndependentEntries() { + // DependencyService returns depA; registry has pending state for depB (different dep). + // Expected: two separate entries — one for depA (metadata:[]), one for depB (CVE metadata). + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.other:lib", "2.0.0", "GHSA-other-5678"); + + Dependency incomingA = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", null); + ScaReachabilityPeriodicAction merged = actionWithDeps(incomingA); + // Simulate com.other:lib having been resolved by DependencyService in a prior heartbeat + merged.addKnownDepForTesting("com.other:lib", "2.0.0"); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(2)).addDependency(captor.capture()); + java.util.List emitted = captor.getAllValues(); + + Dependency depA = emitted.stream() + .filter(d -> "com.example:lib".equals(d.name)) + .findFirst() + .orElseThrow(() -> new AssertionError("dep not found")); + Dependency depB = emitted.stream() + .filter(d -> "com.other:lib".equals(d.name)) + .findFirst() + .orElseThrow(() -> new AssertionError("dep not found")); + + assertTrue(depA.reachabilityMetadata.isEmpty(), "depA: no CVE state → metadata:[]"); + assertTrue(depB.reachabilityMetadata.get(0).contains("GHSA-other-5678"), "depB: must carry CVE state"); + } + + // --------------------------------------------------------------------------- + // knownDeps / timing invariant tests + // --------------------------------------------------------------------------- + + /** + * Dep resolved by DependencyService in heartbeat N; CVE fires in heartbeat N+1. The dep is + * already in knownDeps, so Step 3 emits it with source/hash. + */ + @Test + void cveFiresAfterDepResolved_usesKnownDepsForSourceHash() { + DependencyService svc = mock(DependencyService.class); + // Heartbeat 1: DependencyService returns the dep, no CVE yet + when(svc.drainDeterminedDependencies()) + .thenReturn(Collections.singletonList(new Dependency("com.example:lib", "1.0.0", "lib.jar", "ABCD"))) + .thenReturn(Collections.emptyList()); // heartbeat 2: nothing new + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + // Heartbeat 1: dep detected, no CVE → emits metadata:[] + merged.doIteration(telService); + + // CVE fires between heartbeat 1 and 2 + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-late"); + + // Heartbeat 2: DependencyService is empty, but CVE is pending + TelemetryService telService2 = mock(TelemetryService.class); + merged.doIteration(telService2); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService2, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("lib.jar", emitted.source, "source from knownDeps must be preserved"); + assertEquals("ABCD", emitted.hash, "hash from knownDeps must be preserved"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-late")); + } + + /** + * CVE fires before DependencyService has resolved the dep (timing race). + * + *

Step 3 emits immediately without source/hash so CVE data is never delayed (system tests need + * data within seconds). When the dep is later resolved and stored in knownDeps, subsequent CVE + * emissions (e.g., after a method hit) carry source/hash automatically. + */ + @Test + void cveFiresBeforeDepResolved_emitsImmediatelyWithoutSourceHash() { + DependencyService svc = mock(DependencyService.class); + // Heartbeat 1: DependencyService is empty (dep not yet resolved) + when(svc.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + // CVE fires before DependencyService resolves the dep + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-race"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-race", "com.app.Ctrl", "handle", 10); + + // Heartbeat 1: emits immediately without source/hash — CVE data is not delayed + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertNull(emitted.source, "source is null when dep not yet in knownDeps"); + assertNull(emitted.hash, "hash is null when dep not yet in knownDeps"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-race")); + assertTrue(emitted.reachabilityMetadata.get(0).contains("\"path\""), "must include callsite"); + } + + /** + * Regression test for the Codex P2 race condition: CVE registered in HB1 (dep not yet resolved), + * emitted immediately without source/hash. In HB2 DependencyService resolves the JAR — if we + * naively emit metadata:[] because there is no pending snapshot, the backend overwrites the CVE + * state it already received. The fix uses peekSnapshot so HB2 emits the CVE metadata together + * with the now-known source/hash. + */ + @Test + void cveRegisteredBeforeDepResolved_step2PeeksCveStateInLaterHeartbeat() { + DependencyService svc = mock(DependencyService.class); + + // HB1: CVE registered, dep not yet resolved by DependencyService + when(svc.drainDeterminedDependencies()).thenReturn(Collections.emptyList()); + ScaReachabilityPeriodicAction action = new ScaReachabilityPeriodicAction(svc); + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-peek"); + + action.doIteration(telService); + + // HB1 emits once: CVE state without source/hash (dep not yet resolved) + verify(telService, times(1)).addDependency(any(Dependency.class)); + reset(telService); + + // HB2: DependencyService now resolves the JAR; no new CVE activity (pendingReport=false) + Dependency resolved = new Dependency("com.example:lib", "1.0.0", "lib-1.0.0.jar", "CAFEBABE"); + when(svc.drainDeterminedDependencies()).thenReturn(Collections.singletonList(resolved)); + + action.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("lib-1.0.0.jar", emitted.source, "HB2 must include resolved source"); + assertEquals("CAFEBABE", emitted.hash, "HB2 must include resolved hash"); + assertFalse( + emitted.reachabilityMetadata.isEmpty(), + "HB2 must not emit metadata:[] — CVE state must be preserved via peekSnapshot"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-peek"), "HB2 metadata must contain the CVE id"); + } + + /** + * Regression test for the multi-classloader re-detection bug. + * + *

{@link DependencyService} can report the same JAR on consecutive heartbeats when it + * discovers it from multiple classloaders in the same application. The CVE hit must be emitted + * exactly once — on the heartbeat where the CVE state is pending or first detected via {@code + * peekSnapshot}. Re-detections of the same JAR with no new CVE activity must produce no emission. + * + *

Without the fix, Step 2 called {@code peekSnapshot} unconditionally when {@code + * snapshotByKey} had no entry, causing the hit to be re-emitted on every subsequent heartbeat + * where {@code DependencyService} reported the JAR again (e.g. from a different classloader). + */ + @Test + void depReDetectedByDependencyService_hitNotReEmittedOnSubsequentDetection() { + DependencyService svc = mock(DependencyService.class); + Dependency dep = new Dependency("com.example:lib", "1.0.0", "lib.jar", "HASH"); + // Same dep returned on both heartbeats (simulates multiple classloaders detecting the same JAR) + when(svc.drainDeterminedDependencies()) + .thenReturn(Collections.singletonList(dep)) // HB1: first detection + .thenReturn(Collections.singletonList(dep)); // HB2: re-detected from another classloader + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-dup"); + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-dup", "com.app.Ctrl", "handle", 10); + + // HB1: first detection, CVE is pending → emit merged entry with hit + merged.doIteration(telService); + ArgumentCaptor captor1 = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor1.capture()); + assertTrue( + captor1.getValue().reachabilityMetadata.get(0).contains("\"path\""), + "HB1 must include the callsite hit"); + reset(telService); + + // HB2: same JAR re-detected, no new CVE activity → must NOT re-emit the hit + merged.doIteration(telService); + verify(telService, never()).addDependency(any()); + } + + /** + * Companion to {@link #depReDetectedByDependencyService_hitNotReEmittedOnSubsequentDetection}: + * verifies that a REAL new CVE state change (hit arriving between heartbeats) is still emitted + * correctly even when DependencyService also reports the dep again on the same heartbeat. + */ + @Test + void depReDetectedWithNewHit_emitsOnceWithNewHit() { + DependencyService svc = mock(DependencyService.class); + Dependency dep = new Dependency("com.example:lib", "1.0.0", "lib.jar", "HASH"); + when(svc.drainDeterminedDependencies()) + .thenReturn(Collections.singletonList(dep)) // HB1 + .thenReturn(Collections.singletonList(dep)); // HB2 + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-new"); + + // HB1: first detection, CVE pending with reached:[] → emitted once + merged.doIteration(telService); + verify(telService, times(1)).addDependency(any()); + reset(telService); + + // Hit recorded between HB1 and HB2 + ScaReachabilityDependencyRegistry.INSTANCE.recordHit( + "com.example:lib", "1.0.0", "GHSA-new", "com.app.Svc", "exec", 7); + + // HB2: re-detection AND new hit pending (snapshotByKey has the entry) → emit once with hit + merged.doIteration(telService); + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + assertTrue( + captor.getValue().reachabilityMetadata.get(0).contains("\"path\""), + "HB2 must include the new callsite hit"); + } + + /** + * Dep and CVE arrive simultaneously (same heartbeat) — existing Step 2 merge path. This existing + * behavior must still work after the knownDeps refactor. + */ + @Test + void cveAndDepArriveSameHeartbeat_step2MergeStillWorks() { + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("com.example:lib", "1.0.0", "GHSA-simultaneous"); + + Dependency incoming = new Dependency("com.example:lib", "1.0.0", "lib.jar", "HASH"); + ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); + + merged.doIteration(telService); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("lib.jar", emitted.source, "Step 2 merge must preserve source"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-simultaneous")); + } + + /** + * Regression test for the registry key mismatch for JARs without pom.properties (PR #11614). + * + *

For JARs without {@code pom.properties}, {@code DependencyResolver.guessFallbackNoPom} + * produces an artifact-ID-only name (e.g. {@code "junrar"} instead of {@code + * "com.github.junrar:junrar"}). The transformer must register the CVE under the same name so that + * the registry key matches what {@code DependencyService} will report. + * + *

Without the fix, {@code registerCve} was called with {@code entry.artifact()} = {@code + * "com.github.junrar:junrar"}, while {@code DependencyService} reported {@code dep.name} = {@code + * "junrar"}. The result: two separate telemetry entries for the same physical JAR — one with + * {@code metadata:[]} (from Step 2, carrying source/hash but no CVE) and one with the CVE + * metadata (from Step 3, without source/hash). + * + *

With the fix, the transformer uses the resolved {@code dep.name} from {@code matchDep} (i.e. + * {@code "junrar"}) for {@code registerCve}, so the registry and {@code DependencyService} keys + * match. Step 2 merges them into a single emission with both the CVE metadata and source/hash. + */ + @Test + void noPomJar_artifactIdOnlyName_cveAndSourceHashMergedIntoSingleEntry() { + // Simulates what ScaReachabilityTransformer.processClass() now does after the fix: + // registerCve with the artifactId-only name that DependencyService will also report. + ScaReachabilityDependencyRegistry.INSTANCE.registerCve("junrar", "7.5.5", "GHSA-hf5p-test"); + + // DependencyService returns the dep with the same artifactId-only name (guessFallbackNoPom). + Dependency incoming = new Dependency("junrar", "7.5.5", "junrar-7.5.5.jar", "CAFEBABE"); + ScaReachabilityPeriodicAction merged = actionWithDeps(incoming); + + merged.doIteration(telService); + + // Must produce exactly ONE emission with BOTH the CVE metadata AND source/hash — not two + // separate entries (one with CVE but no source/hash, one with source/hash but no CVE). + ArgumentCaptor captor = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor.capture()); + Dependency emitted = captor.getValue(); + assertEquals("junrar", emitted.name, "name must be the artifactId-only name from DependencyService"); + assertEquals("7.5.5", emitted.version); + assertEquals("junrar-7.5.5.jar", emitted.source, "source/hash from DependencyService must be preserved"); + assertEquals("CAFEBABE", emitted.hash, "hash from DependencyService must be preserved"); + assertFalse( + emitted.reachabilityMetadata.isEmpty(), + "CVE metadata must NOT be lost — a single merged entry must carry both CVE data and source/hash"); + assertTrue(emitted.reachabilityMetadata.get(0).contains("GHSA-hf5p-test")); + } + + /** + * Same physical copy (identical source/hash) re-detected on a later heartbeat must NOT be + * re-emitted — the backend already received it. Mirrors a Spring Boot {@code + * LaunchedURLClassLoader} reporting the same nested JAR (same URI) again. + */ + @Test + void sameSourceHashReDetected_doesNotReEmit() { + DependencyService svc = mock(DependencyService.class); + Dependency dep = new Dependency("com.example:lib", "1.0", "lib.jar", "AABB"); + when(svc.drainDeterminedDependencies()) + .thenReturn(Collections.singletonList(dep)) // HB1: first detection + .thenReturn(Collections.singletonList(dep)); // HB2: same physical copy re-detected + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + // HB1: first detection → emits metadata:[] (SCA monitoring signal) + merged.doIteration(telService); + verify(telService, times(1)).addDependency(any(Dependency.class)); + reset(telService); + + // HB2: same source/hash → backend already knows it → no emission + merged.doIteration(telService); + verify(telService, never()).addDependency(any()); + } + + /** + * Two physical copies of the same {@code name@version} but with different source/hash (e.g. two + * Tomcat webapps each shipping their own copy of the artifact) must BOTH be emitted, matching the + * legacy {@code DependencyPeriodicAction} behavior. + */ + @Test + void differentSourceHashSameKey_emitsBothPhysicalCopies() { + DependencyService svc = mock(DependencyService.class); + Dependency copy1 = new Dependency("com.example:lib", "1.0", "/app1/lib.jar", "AABB"); + Dependency copy2 = new Dependency("com.example:lib", "1.0", "/app2/lib.jar", "CCDD"); + when(svc.drainDeterminedDependencies()) + .thenReturn(Collections.singletonList(copy1)) // HB1: first physical copy + .thenReturn(Collections.singletonList(copy2)); // HB2: distinct physical copy + ScaReachabilityPeriodicAction merged = new ScaReachabilityPeriodicAction(svc); + + // HB1: first copy → emitted + merged.doIteration(telService); + ArgumentCaptor captor1 = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor1.capture()); + assertEquals("/app1/lib.jar", captor1.getValue().source); + assertEquals("AABB", captor1.getValue().hash); + reset(telService); + + // HB2: different source/hash → new physical copy → also emitted + merged.doIteration(telService); + ArgumentCaptor captor2 = ArgumentCaptor.forClass(Dependency.class); + verify(telService, times(1)).addDependency(captor2.capture()); + assertEquals("/app2/lib.jar", captor2.getValue().source); + assertEquals("CCDD", captor2.getValue().hash); + } } diff --git a/test-published-dependencies/agent-logs-on-java-7/src/main/java/test/published/dependencies/App.java b/test-published-dependencies/agent-logs-on-java-7/src/main/java/test/published/dependencies/App.java index b0db71e7a9c..889d770b586 100644 --- a/test-published-dependencies/agent-logs-on-java-7/src/main/java/test/published/dependencies/App.java +++ b/test-published-dependencies/agent-logs-on-java-7/src/main/java/test/published/dependencies/App.java @@ -1,10 +1,10 @@ package test.published.dependencies; public class App { - public static void main(String[] args) { - System.out.println("Hello World!"); - for (String s : args) { - System.out.println(s); + public static void main(String[] args) { + System.out.println("Hello World!"); + for (String s : args) { + System.out.println(s); + } } - } } diff --git a/test-published-dependencies/agent-logs-on-java-7/src/test/java/StartWithAgentTest.java b/test-published-dependencies/agent-logs-on-java-7/src/test/java/StartWithAgentTest.java index 4d5fcc59e0e..585459bbcd4 100644 --- a/test-published-dependencies/agent-logs-on-java-7/src/test/java/StartWithAgentTest.java +++ b/test-published-dependencies/agent-logs-on-java-7/src/test/java/StartWithAgentTest.java @@ -17,101 +17,91 @@ public class StartWithAgentTest { - private static final Pattern WARNING_PATTERN = - Pattern.compile( - "^Warning: Version [^ ]+ of dd-java-agent is not compatible with Java [^ ]+ found at [^ ]+ and is effectively disabled\\.$"); - private static final String UPGRADE_MESSAGE = "Please upgrade your Java version to 8+"; + private static final Pattern WARNING_PATTERN = Pattern.compile( + "^Warning: Version [^ ]+ of dd-java-agent is not compatible with Java [^ ]+ found at [^ ]+ and is effectively disabled\\.$"); + private static final String UPGRADE_MESSAGE = "Please upgrade your Java version to 8+"; - @Test - void ensureThatApplicationStartsWithAgentOnJava7() throws InterruptedException, IOException { - String expectedMessage = "Woho! Started on Java 7"; - Process process = startAndWaitForJvmWithAgentForJava("JAVA_7_HOME", expectedMessage); - int exitCode = process.waitFor(); - List output = getLines(process.getInputStream()); - List errors = getLines(process.getErrorStream()); - logProcessOutput(output, errors); - assertEquals(0, exitCode, "Command failed with unexpected exit code"); - assertTrue( - output.contains(expectedMessage), "Output does not contain '" + expectedMessage + "'"); - assertTrue( - errors.stream().anyMatch(WARNING_PATTERN.asPredicate()), - "Output does not contain line matching '" + WARNING_PATTERN + "'"); - assertTrue( - errors.contains(UPGRADE_MESSAGE), "Output does not contain '" + UPGRADE_MESSAGE + "'"); - } + @Test + void ensureThatApplicationStartsWithAgentOnJava7() throws InterruptedException, IOException { + String expectedMessage = "Woho! Started on Java 7"; + Process process = startAndWaitForJvmWithAgentForJava("JAVA_7_HOME", expectedMessage); + int exitCode = process.waitFor(); + List output = getLines(process.getInputStream()); + List errors = getLines(process.getErrorStream()); + logProcessOutput(output, errors); + assertEquals(0, exitCode, "Command failed with unexpected exit code"); + assertTrue(output.contains(expectedMessage), "Output does not contain '" + expectedMessage + "'"); + assertTrue( + errors.stream().anyMatch(WARNING_PATTERN.asPredicate()), + "Output does not contain line matching '" + WARNING_PATTERN + "'"); + assertTrue(errors.contains(UPGRADE_MESSAGE), "Output does not contain '" + UPGRADE_MESSAGE + "'"); + } - @Test - void ensureThatApplicationStartsWithAgentOnJava8() throws InterruptedException, IOException { - ensureThatApplicationStartsWithoutWarning("8"); - } + @Test + void ensureThatApplicationStartsWithAgentOnJava8() throws InterruptedException, IOException { + ensureThatApplicationStartsWithoutWarning("8"); + } - @Test - void ensureThatApplicationStartsWithAgentOnJava11() throws InterruptedException, IOException { - ensureThatApplicationStartsWithoutWarning("11"); - } + @Test + void ensureThatApplicationStartsWithAgentOnJava11() throws InterruptedException, IOException { + ensureThatApplicationStartsWithoutWarning("11"); + } - private static void ensureThatApplicationStartsWithoutWarning(String version) - throws InterruptedException, IOException { - String expectedMessage = "Woho! Started on Java " + version; - Process process = - startAndWaitForJvmWithAgentForJava("JAVA_" + version + "_HOME", expectedMessage); - int exitCode = process.waitFor(); - List output = getLines(process.getInputStream()); - List errors = getLines(process.getErrorStream()); - logProcessOutput(output, errors); - assertEquals(0, exitCode, "Command failed with unexpected exit code"); - assertTrue( - output.contains(expectedMessage), "Output does not contain '" + expectedMessage + "'"); - assertFalse( - errors.stream().anyMatch(WARNING_PATTERN.asPredicate()), - "Output contains unexpected line matching '" + WARNING_PATTERN + "'"); - assertFalse( - errors.contains(UPGRADE_MESSAGE), - "Output contains unexpected line '" + UPGRADE_MESSAGE + "'"); - } + private static void ensureThatApplicationStartsWithoutWarning(String version) + throws InterruptedException, IOException { + String expectedMessage = "Woho! Started on Java " + version; + Process process = startAndWaitForJvmWithAgentForJava("JAVA_" + version + "_HOME", expectedMessage); + int exitCode = process.waitFor(); + List output = getLines(process.getInputStream()); + List errors = getLines(process.getErrorStream()); + logProcessOutput(output, errors); + assertEquals(0, exitCode, "Command failed with unexpected exit code"); + assertTrue(output.contains(expectedMessage), "Output does not contain '" + expectedMessage + "'"); + assertFalse( + errors.stream().anyMatch(WARNING_PATTERN.asPredicate()), + "Output contains unexpected line matching '" + WARNING_PATTERN + "'"); + assertFalse(errors.contains(UPGRADE_MESSAGE), "Output contains unexpected line '" + UPGRADE_MESSAGE + "'"); + } - private static Process startAndWaitForJvmWithAgentForJava(String javaHomeEnv, String message) - throws IOException { - String javaHome = System.getenv(javaHomeEnv); - checkFile(javaHome, javaHomeEnv); - String javaAgent = System.getProperty("test.published.dependencies.agent"); - checkFile(javaAgent, "test.published.dependencies.agent"); - String jarFile = System.getProperty("test.published.dependencies.jar"); - checkFile(jarFile, "test.published.dependencies.jar"); + private static Process startAndWaitForJvmWithAgentForJava(String javaHomeEnv, String message) throws IOException { + String javaHome = System.getenv(javaHomeEnv); + checkFile(javaHome, javaHomeEnv); + String javaAgent = System.getProperty("test.published.dependencies.agent"); + checkFile(javaAgent, "test.published.dependencies.agent"); + String jarFile = System.getProperty("test.published.dependencies.jar"); + checkFile(jarFile, "test.published.dependencies.jar"); - List commandLine = new ArrayList<>(); - commandLine.add(Paths.get(javaHome).resolve("bin").resolve("java").toString()); - commandLine.add("-Xmx256M"); - commandLine.add("-javaagent:" + javaAgent); - commandLine.add("-jar"); - commandLine.add(jarFile); - commandLine.add(message); - ProcessBuilder builder = new ProcessBuilder(commandLine); - builder.environment().put("JAVA_HOME", javaHome); - Process process = builder.start(); - return process; - } + List commandLine = new ArrayList<>(); + commandLine.add(Paths.get(javaHome).resolve("bin").resolve("java").toString()); + commandLine.add("-Xmx256M"); + commandLine.add("-javaagent:" + javaAgent); + commandLine.add("-jar"); + commandLine.add(jarFile); + commandLine.add(message); + ProcessBuilder builder = new ProcessBuilder(commandLine); + builder.environment().put("JAVA_HOME", javaHome); + Process process = builder.start(); + return process; + } - private static void checkFile(String file, String name) { - assertNotNull(file, name + " should be set"); - assertFalse(file.isEmpty(), name + " should not be empty "); - assertTrue(Files.exists(Paths.get(file)), name + " [" + file + "] should be an existing path"); - } + private static void checkFile(String file, String name) { + assertNotNull(file, name + " should be set"); + assertFalse(file.isEmpty(), name + " should not be empty "); + assertTrue(Files.exists(Paths.get(file)), name + " [" + file + "] should be an existing path"); + } - private static List getLines(InputStream inputStream) { - return new BufferedReader(new InputStreamReader(inputStream)) - .lines() - .collect(Collectors.toList()); - } + private static List getLines(InputStream inputStream) { + return new BufferedReader(new InputStreamReader(inputStream)).lines().collect(Collectors.toList()); + } - private static void logProcessOutput(List output, List errors) { - System.out.println("-------------------"); - System.out.println("Sub process output:"); - output.forEach(System.out::println); - System.out.println("-------------------"); - System.out.println("Sub process errors:"); - errors.forEach(System.out::println); - System.out.println("-------------------"); - System.out.println("Sub process done."); - } + private static void logProcessOutput(List output, List errors) { + System.out.println("-------------------"); + System.out.println("Sub process output:"); + output.forEach(System.out::println); + System.out.println("-------------------"); + System.out.println("Sub process errors:"); + errors.forEach(System.out::println); + System.out.println("-------------------"); + System.out.println("Sub process done."); + } } diff --git a/test-published-dependencies/all-deps-exist/src/main/java/test/published/dependencies/App.java b/test-published-dependencies/all-deps-exist/src/main/java/test/published/dependencies/App.java index 341942ce9ca..d44c1903c0f 100644 --- a/test-published-dependencies/all-deps-exist/src/main/java/test/published/dependencies/App.java +++ b/test-published-dependencies/all-deps-exist/src/main/java/test/published/dependencies/App.java @@ -5,13 +5,13 @@ import io.opentracing.util.GlobalTracer; public class App { - public static void main(String[] args) { - DDTracer tracer = DDTracer.builder().serviceName("TestService").build(); - GlobalTracer.registerIfAbsent(tracer); - datadog.trace.api.GlobalTracer.registerIfAbsent(tracer); - new App().tracedMethod(); - } + public static void main(String[] args) { + DDTracer tracer = DDTracer.builder().serviceName("TestService").build(); + GlobalTracer.registerIfAbsent(tracer); + datadog.trace.api.GlobalTracer.registerIfAbsent(tracer); + new App().tracedMethod(); + } - @Trace - void tracedMethod() {} + @Trace + void tracedMethod() {} } diff --git a/test-published-dependencies/ot-pulls-in-api/src/main/java/test/published/dependencies/App.java b/test-published-dependencies/ot-pulls-in-api/src/main/java/test/published/dependencies/App.java index 341942ce9ca..d44c1903c0f 100644 --- a/test-published-dependencies/ot-pulls-in-api/src/main/java/test/published/dependencies/App.java +++ b/test-published-dependencies/ot-pulls-in-api/src/main/java/test/published/dependencies/App.java @@ -5,13 +5,13 @@ import io.opentracing.util.GlobalTracer; public class App { - public static void main(String[] args) { - DDTracer tracer = DDTracer.builder().serviceName("TestService").build(); - GlobalTracer.registerIfAbsent(tracer); - datadog.trace.api.GlobalTracer.registerIfAbsent(tracer); - new App().tracedMethod(); - } + public static void main(String[] args) { + DDTracer tracer = DDTracer.builder().serviceName("TestService").build(); + GlobalTracer.registerIfAbsent(tracer); + datadog.trace.api.GlobalTracer.registerIfAbsent(tracer); + new App().tracedMethod(); + } - @Trace - void tracedMethod() {} + @Trace + void tracedMethod() {} } diff --git a/test-published-dependencies/ot-pulls-in-api/src/test/java/CreateTracerTest.java b/test-published-dependencies/ot-pulls-in-api/src/test/java/CreateTracerTest.java index a6f1224ec73..41a4238c593 100644 --- a/test-published-dependencies/ot-pulls-in-api/src/test/java/CreateTracerTest.java +++ b/test-published-dependencies/ot-pulls-in-api/src/test/java/CreateTracerTest.java @@ -6,27 +6,28 @@ public class CreateTracerTest { - @Test - void createTracer() throws InterruptedException { - DDTracer tracer = DDTracer.builder().serviceName("TestService").build(); - GlobalTracer.registerIfAbsent(tracer); - datadog.trace.api.GlobalTracer.registerIfAbsent(tracer); - tracer.activateSpan(tracer.buildSpan("test-span").start()).close(); - // Sleep a bit so the trace sending machinery has a chance to start - Thread.sleep(1000); - } + @Test + void createTracer() throws InterruptedException { + DDTracer tracer = DDTracer.builder().serviceName("TestService").build(); + GlobalTracer.registerIfAbsent(tracer); + datadog.trace.api.GlobalTracer.registerIfAbsent(tracer); + tracer.activateSpan(tracer.buildSpan("test-span").start()).close(); + // Sleep a bit so the trace sending machinery has a chance to start + Thread.sleep(1000); + } - @Test - void buildTracerWithUDS() throws Exception { - // trigger use of JNR and JFFI to validate native method linking - Path fakeSocketPath = Files.createTempFile("dd-java", "test-uds"); - System.setProperty("dd.trace.agent.url", "unix://" + fakeSocketPath.toUri().getPath()); - try { - // we don't need to actually use the tracer, just build it - DDTracer.builder().serviceName("TestServiceWithUDS").build(); - } finally { - System.clearProperty("dd.trace.agent.url"); - Files.delete(fakeSocketPath); + @Test + void buildTracerWithUDS() throws Exception { + // trigger use of JNR and JFFI to validate native method linking + Path fakeSocketPath = Files.createTempFile("dd-java", "test-uds"); + System.setProperty( + "dd.trace.agent.url", "unix://" + fakeSocketPath.toUri().getPath()); + try { + // we don't need to actually use the tracer, just build it + DDTracer.builder().serviceName("TestServiceWithUDS").build(); + } finally { + System.clearProperty("dd.trace.agent.url"); + Files.delete(fakeSocketPath); + } } - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/ConfigCollector.java b/utils/config-utils/src/main/java/datadog/trace/api/ConfigCollector.java index a7ec9a83265..6872fb8e6ae 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/ConfigCollector.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/ConfigCollector.java @@ -17,89 +17,87 @@ * consumers. So this is based on a ConcurrentHashMap to deal with it. */ public class ConfigCollector { - private static final ConfigCollector INSTANCE = new ConfigCollector(); - - private ConfigCollector() {} - - private static final AtomicReferenceFieldUpdater COLLECTED_UPDATER = - AtomicReferenceFieldUpdater.newUpdater(ConfigCollector.class, Map.class, "collected"); - - private static final Function> NEW_SUB_MAP = - k -> new ConcurrentHashMap<>(); - - private volatile Map> collected = - new ConcurrentHashMap<>(); - - public static ConfigCollector get() { - return INSTANCE; - } - - public void put(String key, Object value, ConfigOrigin origin, int seqId) { - put(key, value, origin, seqId, null); - } - - public void put(String key, Object value, ConfigOrigin origin, int seqId, String configId) { - ConfigSetting setting = ConfigSetting.of(key, value, origin, seqId, configId); - Map configMap = collected.computeIfAbsent(origin, NEW_SUB_MAP); - configMap.put(key, setting); // replaces any previous value for this key at origin - } - - // put method specifically for DEFAULT origins. We don't allow overrides for configs from DEFAULT - // origins - public void putDefault(String key, Object value) { - ConfigSetting setting = ConfigSetting.of(key, value, DEFAULT, DEFAULT_SEQ_ID); - Map configMap = collected.computeIfAbsent(DEFAULT, NEW_SUB_MAP); - configMap.putIfAbsent(key, setting); // don't replace previous default for this key - } - - /** - * Report single configuration setting with REMOTE origin. - * - * @param key configuration key to report - * @param value configuration value to report - */ - public void putRemote(String key, Object value) { - put(key, value, REMOTE, ABSENT_SEQ_ID); - } - - /** - * Report multiple configuration settings with REMOTE origin. - * - * @param configMap map of configuration key-value pairs to report - */ - public void putRemote(Map configMap) { - // attempt merge+replace to avoid collector seeing partial update - Map merged = new ConcurrentHashMap<>(); - - // prepare update - for (Map.Entry entry : configMap.entrySet()) { - ConfigSetting setting = - ConfigSetting.of(entry.getKey(), entry.getValue(), REMOTE, ABSENT_SEQ_ID); - merged.put(entry.getKey(), setting); + private static final ConfigCollector INSTANCE = new ConfigCollector(); + + private ConfigCollector() {} + + private static final AtomicReferenceFieldUpdater COLLECTED_UPDATER = + AtomicReferenceFieldUpdater.newUpdater(ConfigCollector.class, Map.class, "collected"); + + private static final Function> NEW_SUB_MAP = + k -> new ConcurrentHashMap<>(); + + private volatile Map> collected = new ConcurrentHashMap<>(); + + public static ConfigCollector get() { + return INSTANCE; + } + + public void put(String key, Object value, ConfigOrigin origin, int seqId) { + put(key, value, origin, seqId, null); } - while (true) { - // first try adding our update to the map - Map current = collected.putIfAbsent(REMOTE, merged); - if (current == null) { - break; // success, no merging required - } - // merge existing entries with updated entries - current.forEach(merged::putIfAbsent); - if (collected.replace(REMOTE, current, merged)) { - break; // success, atomically swapped in merged map - } - // roll back to original update before next attempt - merged.keySet().retainAll(configMap.keySet()); + public void put(String key, Object value, ConfigOrigin origin, int seqId, String configId) { + ConfigSetting setting = ConfigSetting.of(key, value, origin, seqId, configId); + Map configMap = collected.computeIfAbsent(origin, NEW_SUB_MAP); + configMap.put(key, setting); // replaces any previous value for this key at origin } - } - - @SuppressWarnings("unchecked") - public Map> collect() { - if (!collected.isEmpty()) { - return COLLECTED_UPDATER.getAndSet(this, new ConcurrentHashMap<>()); - } else { - return Collections.emptyMap(); + + // put method specifically for DEFAULT origins. We don't allow overrides for configs from DEFAULT + // origins + public void putDefault(String key, Object value) { + ConfigSetting setting = ConfigSetting.of(key, value, DEFAULT, DEFAULT_SEQ_ID); + Map configMap = collected.computeIfAbsent(DEFAULT, NEW_SUB_MAP); + configMap.putIfAbsent(key, setting); // don't replace previous default for this key + } + + /** + * Report single configuration setting with REMOTE origin. + * + * @param key configuration key to report + * @param value configuration value to report + */ + public void putRemote(String key, Object value) { + put(key, value, REMOTE, ABSENT_SEQ_ID); + } + + /** + * Report multiple configuration settings with REMOTE origin. + * + * @param configMap map of configuration key-value pairs to report + */ + public void putRemote(Map configMap) { + // attempt merge+replace to avoid collector seeing partial update + Map merged = new ConcurrentHashMap<>(); + + // prepare update + for (Map.Entry entry : configMap.entrySet()) { + ConfigSetting setting = ConfigSetting.of(entry.getKey(), entry.getValue(), REMOTE, ABSENT_SEQ_ID); + merged.put(entry.getKey(), setting); + } + + while (true) { + // first try adding our update to the map + Map current = collected.putIfAbsent(REMOTE, merged); + if (current == null) { + break; // success, no merging required + } + // merge existing entries with updated entries + current.forEach(merged::putIfAbsent); + if (collected.replace(REMOTE, current, merged)) { + break; // success, atomically swapped in merged map + } + // roll back to original update before next attempt + merged.keySet().retainAll(configMap.keySet()); + } + } + + @SuppressWarnings("unchecked") + public Map> collect() { + if (!collected.isEmpty()) { + return COLLECTED_UPDATER.getAndSet(this, new ConcurrentHashMap<>()); + } else { + return Collections.emptyMap(); + } } - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/ConfigOrigin.java b/utils/config-utils/src/main/java/datadog/trace/api/ConfigOrigin.java index 850c39c9dea..4c5f2655a31 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/ConfigOrigin.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/ConfigOrigin.java @@ -3,30 +3,30 @@ // https://github.com/DataDog/instrumentation-telemetry-api-docs/blob/main/ // GeneratedDocumentation/ApiDocs/v2/SchemaDocumentation/Schemas/conf_key_value.md public enum ConfigOrigin { - /** configurations that are set through environment variables */ - ENV("env_var"), - /** values that are set using remote config */ - REMOTE("remote_config"), - /** configurations that are set through JVM properties */ - JVM_PROP("jvm_prop"), - /** configuration read in the stable config file, managed by users */ - LOCAL_STABLE_CONFIG("local_stable_config"), - /** configuration read in the stable config file, managed by fleet */ - FLEET_STABLE_CONFIG("fleet_stable_config"), - /** configurations that are set through the customer application */ - CODE("code"), - /** set by the dd.yaml file or json */ - DD_CONFIG("dd_config"), - /** set for cases where it is difficult/not possible to determine the source of a config. */ - UNKNOWN("unknown"), - /** set when the config is calculated from multiple configs */ - CALCULATED("calculated"), - /** set when the user has not set any configuration for the key (defaults to a value) */ - DEFAULT("default"); + /** configurations that are set through environment variables */ + ENV("env_var"), + /** values that are set using remote config */ + REMOTE("remote_config"), + /** configurations that are set through JVM properties */ + JVM_PROP("jvm_prop"), + /** configuration read in the stable config file, managed by users */ + LOCAL_STABLE_CONFIG("local_stable_config"), + /** configuration read in the stable config file, managed by fleet */ + FLEET_STABLE_CONFIG("fleet_stable_config"), + /** configurations that are set through the customer application */ + CODE("code"), + /** set by the dd.yaml file or json */ + DD_CONFIG("dd_config"), + /** set for cases where it is difficult/not possible to determine the source of a config. */ + UNKNOWN("unknown"), + /** set when the config is calculated from multiple configs */ + CALCULATED("calculated"), + /** set when the user has not set any configuration for the key (defaults to a value) */ + DEFAULT("default"); - public final String value; + public final String value; - ConfigOrigin(String value) { - this.value = value; - } + ConfigOrigin(String value) { + this.value = value; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java b/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java index 43ec0e34353..a470892b95e 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java @@ -8,139 +8,138 @@ import java.util.Objects; public final class ConfigSetting { - public static final int DEFAULT_SEQ_ID = 1; - public static final int NON_DEFAULT_SEQ_ID = DEFAULT_SEQ_ID + 1; - public static final int ABSENT_SEQ_ID = 0; - - public final String key; - public final Object value; - public final ConfigOrigin origin; - public final int seqId; - - /** The config ID associated with this setting, or {@code null} if not applicable. */ - public final String configId; - - public static ConfigSetting of(String key, Object value, ConfigOrigin origin) { - return new ConfigSetting(key, value, origin, ABSENT_SEQ_ID, null); - } - - public static ConfigSetting of(String key, Object value, ConfigOrigin origin, int seqId) { - return new ConfigSetting(key, value, origin, seqId, null); - } - - // No usages of this function - public static ConfigSetting of(String key, Object value, ConfigOrigin origin, String configId) { - return new ConfigSetting(key, value, origin, ABSENT_SEQ_ID, configId); - } - - public static ConfigSetting of( - String key, Object value, ConfigOrigin origin, int seqId, String configId) { - return new ConfigSetting(key, value, origin, seqId, configId); - } - - private ConfigSetting(String key, Object value, ConfigOrigin origin, int seqId, String configId) { - this.key = toCanonicalEnvVar(key); - this.value = (value != null && SENSITIVE_KEYS.contains(this.key)) ? "" : value; - this.origin = origin; - this.seqId = seqId; - this.configId = configId; - } - - public String stringValue() { - if (value == null) { - return null; - } else if (value instanceof BitSet) { - return renderIntegerRange((BitSet) value); - } else if (value instanceof Map) { - return renderMap((Map) value); - } else if (value instanceof Iterable) { - return renderIterable((Iterable) value); - } else { - return value.toString(); + public static final int DEFAULT_SEQ_ID = 1; + public static final int NON_DEFAULT_SEQ_ID = DEFAULT_SEQ_ID + 1; + public static final int ABSENT_SEQ_ID = 0; + + public final String key; + public final Object value; + public final ConfigOrigin origin; + public final int seqId; + + /** The config ID associated with this setting, or {@code null} if not applicable. */ + public final String configId; + + public static ConfigSetting of(String key, Object value, ConfigOrigin origin) { + return new ConfigSetting(key, value, origin, ABSENT_SEQ_ID, null); + } + + public static ConfigSetting of(String key, Object value, ConfigOrigin origin, int seqId) { + return new ConfigSetting(key, value, origin, seqId, null); + } + + // No usages of this function + public static ConfigSetting of(String key, Object value, ConfigOrigin origin, String configId) { + return new ConfigSetting(key, value, origin, ABSENT_SEQ_ID, configId); + } + + public static ConfigSetting of(String key, Object value, ConfigOrigin origin, int seqId, String configId) { + return new ConfigSetting(key, value, origin, seqId, configId); + } + + private ConfigSetting(String key, Object value, ConfigOrigin origin, int seqId, String configId) { + this.key = toCanonicalEnvVar(key); + this.value = (value != null && SENSITIVE_KEYS.contains(this.key)) ? "" : value; + this.origin = origin; + this.seqId = seqId; + this.configId = configId; } - } - - private static String renderIntegerRange(BitSet bitset) { - StringBuilder sb = new StringBuilder(); - int start = 0; - while (true) { - start = bitset.nextSetBit(start); - if (start < 0) { - break; - } - int end = bitset.nextClearBit(start); - if (sb.length() > 0) { - sb.append(','); - } - if (start < end - 1) { - // interval - sb.append(start); - sb.append('-'); - sb.append(end); - } else { - // single value - sb.append(start); - } - start = end; + + public String stringValue() { + if (value == null) { + return null; + } else if (value instanceof BitSet) { + return renderIntegerRange((BitSet) value); + } else if (value instanceof Map) { + return renderMap((Map) value); + } else if (value instanceof Iterable) { + return renderIterable((Iterable) value); + } else { + return value.toString(); + } + } + + private static String renderIntegerRange(BitSet bitset) { + StringBuilder sb = new StringBuilder(); + int start = 0; + while (true) { + start = bitset.nextSetBit(start); + if (start < 0) { + break; + } + int end = bitset.nextClearBit(start); + if (sb.length() > 0) { + sb.append(','); + } + if (start < end - 1) { + // interval + sb.append(start); + sb.append('-'); + sb.append(end); + } else { + // single value + sb.append(start); + } + start = end; + } + return sb.toString(); } - return sb.toString(); - } - - private static String renderMap(Map merged) { - StringBuilder result = new StringBuilder(); - for (Map.Entry entry : merged.entrySet()) { - if (result.length() > 0) { - result.append(','); - } - result.append(entry.getKey()); - result.append(':'); - result.append(entry.getValue()); + + private static String renderMap(Map merged) { + StringBuilder result = new StringBuilder(); + for (Map.Entry entry : merged.entrySet()) { + if (result.length() > 0) { + result.append(','); + } + result.append(entry.getKey()); + result.append(':'); + result.append(entry.getValue()); + } + return result.toString(); + } + + private static String renderIterable(Iterable iterable) { + StringBuilder result = new StringBuilder(); + for (Object entry : iterable) { + if (result.length() > 0) { + result.append(','); + } + result.append(entry); + } + return result.toString(); } - return result.toString(); - } - - private static String renderIterable(Iterable iterable) { - StringBuilder result = new StringBuilder(); - for (Object entry : iterable) { - if (result.length() > 0) { - result.append(','); - } - result.append(entry); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ConfigSetting that = (ConfigSetting) o; + return key.equals(that.key) + && Objects.equals(value, that.value) + && origin == that.origin + && seqId == that.seqId + && Objects.equals(configId, that.configId); + } + + @Override + public int hashCode() { + return Objects.hash(key, value, origin, seqId, configId); + } + + @Override + public String toString() { + return "ConfigSetting{" + + "key='" + + key + + '\'' + + ", value=" + + stringValue() + + ", origin=" + + origin + + ", seqId=" + + seqId + + ", configId=" + + configId + + '}'; } - return result.toString(); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ConfigSetting that = (ConfigSetting) o; - return key.equals(that.key) - && Objects.equals(value, that.value) - && origin == that.origin - && seqId == that.seqId - && Objects.equals(configId, that.configId); - } - - @Override - public int hashCode() { - return Objects.hash(key, value, origin, seqId, configId); - } - - @Override - public String toString() { - return "ConfigSetting{" - + "key='" - + key - + '\'' - + ", value=" - + stringValue() - + ", origin=" - + origin - + ", seqId=" - + seqId - + ", configId=" - + configId - + '}'; - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/env/CapturedEnvironment.java b/utils/config-utils/src/main/java/datadog/trace/api/env/CapturedEnvironment.java index 2db02edd88a..180e330745d 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/env/CapturedEnvironment.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/env/CapturedEnvironment.java @@ -15,74 +15,74 @@ */ public class CapturedEnvironment { - public static class ProcessInfo { - public String mainClass; - public File jarFile; - - public ProcessInfo() { - String jarName = JavaVirtualMachine.getJarFile(); - jarFile = jarName == null ? null : new File(jarName); - mainClass = JavaVirtualMachine.getMainClass(); + public static class ProcessInfo { + public String mainClass; + public File jarFile; + + public ProcessInfo() { + String jarName = JavaVirtualMachine.getJarFile(); + jarFile = jarName == null ? null : new File(jarName); + mainClass = JavaVirtualMachine.getMainClass(); + } + + @VisibleForTesting + ProcessInfo(String mainClass, File jarFile) { + this.mainClass = mainClass; + this.jarFile = jarFile; + } + } + + private static final CapturedEnvironment INSTANCE = new CapturedEnvironment(); + + private final Map properties; + private ProcessInfo processInfo; + + private CapturedEnvironment() { + properties = new HashMap<>(); + processInfo = new ProcessInfo(); + properties.put(GeneralConfig.SERVICE_NAME, autodetectServiceName()); + } + + public Map getProperties() { + return properties; + } + + public ProcessInfo getProcessInfo() { + return processInfo; } @VisibleForTesting - ProcessInfo(String mainClass, File jarFile) { - this.mainClass = mainClass; - this.jarFile = jarFile; + static void useFixedEnv(final Map props) { + INSTANCE.properties.clear(); + INSTANCE.properties.putAll(props); } - } - - private static final CapturedEnvironment INSTANCE = new CapturedEnvironment(); - - private final Map properties; - private ProcessInfo processInfo; - - private CapturedEnvironment() { - properties = new HashMap<>(); - processInfo = new ProcessInfo(); - properties.put(GeneralConfig.SERVICE_NAME, autodetectServiceName()); - } - - public Map getProperties() { - return properties; - } - - public ProcessInfo getProcessInfo() { - return processInfo; - } - - @VisibleForTesting - static void useFixedEnv(final Map props) { - INSTANCE.properties.clear(); - INSTANCE.properties.putAll(props); - } - - @VisibleForTesting - static void useFixedProcessInfo(final ProcessInfo processInfo) { - INSTANCE.processInfo = processInfo; - } - - /** - * Returns autodetected service name based on the java process command line. Typically, the - * autodetection will return either the JAR filename or the java main class. - */ - private String autodetectServiceName() { - String inAas = ConfigHelper.env("DD_AZURE_APP_SERVICES"); - String siteName = ConfigHelper.env("WEBSITE_SITE_NAME"); - - if (("true".equalsIgnoreCase(inAas) || "1".equals(inAas)) && siteName != null) { - return siteName; + + @VisibleForTesting + static void useFixedProcessInfo(final ProcessInfo processInfo) { + INSTANCE.processInfo = processInfo; } - // preserve the original logic that is case sensitive on the .jar extension - if (processInfo.jarFile != null && processInfo.jarFile.getName().endsWith(".jar")) { - return processInfo.jarFile.getName().replace(".jar", ""); - } else { - return processInfo.mainClass; + /** + * Returns autodetected service name based on the java process command line. Typically, the + * autodetection will return either the JAR filename or the java main class. + */ + private String autodetectServiceName() { + String inAas = ConfigHelper.env("DD_AZURE_APP_SERVICES"); + String siteName = ConfigHelper.env("WEBSITE_SITE_NAME"); + + if (("true".equalsIgnoreCase(inAas) || "1".equals(inAas)) && siteName != null) { + return siteName; + } + + // preserve the original logic that is case sensitive on the .jar extension + if (processInfo.jarFile != null && processInfo.jarFile.getName().endsWith(".jar")) { + return processInfo.jarFile.getName().replace(".jar", ""); + } else { + return processInfo.mainClass; + } } - } - public static CapturedEnvironment get() { - return INSTANCE; - } + public static CapturedEnvironment get() { + return INSTANCE; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollector.java b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollector.java index e36045d7836..0f12a62f250 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollector.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollector.java @@ -1,5 +1,5 @@ package datadog.trace.api.telemetry; public interface ConfigInversionMetricCollector { - void setUndocumentedEnvVarMetric(String configName); + void setUndocumentedEnvVarMetric(String configName); } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorProvider.java b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorProvider.java index 1170ef70ebc..d69e0c8d6d5 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorProvider.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/ConfigInversionMetricCollectorProvider.java @@ -1,16 +1,15 @@ package datadog.trace.api.telemetry; public final class ConfigInversionMetricCollectorProvider { - private static ConfigInversionMetricCollector INSTANCE = - NoOpConfigInversionMetricCollector.getInstance(); + private static ConfigInversionMetricCollector INSTANCE = NoOpConfigInversionMetricCollector.getInstance(); - private ConfigInversionMetricCollectorProvider() {} + private ConfigInversionMetricCollectorProvider() {} - public static ConfigInversionMetricCollector get() { - return INSTANCE; - } + public static ConfigInversionMetricCollector get() { + return INSTANCE; + } - public static void register(ConfigInversionMetricCollector instance) { - INSTANCE = instance; - } + public static void register(ConfigInversionMetricCollector instance) { + INSTANCE = instance; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/NoOpConfigInversionMetricCollector.java b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/NoOpConfigInversionMetricCollector.java index 002ae7d6198..16cb50bf37a 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/NoOpConfigInversionMetricCollector.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/NoOpConfigInversionMetricCollector.java @@ -8,20 +8,18 @@ * is not registered during build tasks like instrumentJava. */ public final class NoOpConfigInversionMetricCollector implements ConfigInversionMetricCollector { - private static final NoOpConfigInversionMetricCollector INSTANCE = - new NoOpConfigInversionMetricCollector(); + private static final NoOpConfigInversionMetricCollector INSTANCE = new NoOpConfigInversionMetricCollector(); - private static final Logger log = - LoggerFactory.getLogger(NoOpConfigInversionMetricCollector.class); + private static final Logger log = LoggerFactory.getLogger(NoOpConfigInversionMetricCollector.class); - private NoOpConfigInversionMetricCollector() {} + private NoOpConfigInversionMetricCollector() {} - public static NoOpConfigInversionMetricCollector getInstance() { - return INSTANCE; - } + public static NoOpConfigInversionMetricCollector getInstance() { + return INSTANCE; + } - @Override - public void setUndocumentedEnvVarMetric(String configName) { - log.debug("Environment variable {} is undocumented", configName); - } + @Override + public void setUndocumentedEnvVarMetric(String configName) { + log.debug("Environment variable {} is undocumented", configName); + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollector.java b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollector.java index 13cf568ddff..738cd718917 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollector.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollector.java @@ -1,9 +1,9 @@ package datadog.trace.api.telemetry; public interface OtelEnvMetricCollector { - void setHidingOtelEnvVarMetric(String otelName, String ddName); + void setHidingOtelEnvVarMetric(String otelName, String ddName); - void setInvalidOtelEnvVarMetric(String otelName, String ddName); + void setInvalidOtelEnvVarMetric(String otelName, String ddName); - void setUnsupportedOtelEnvVarMetric(String otelName); + void setUnsupportedOtelEnvVarMetric(String otelName); } diff --git a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorProvider.java b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorProvider.java index 7e55ff3819b..55c03fba486 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorProvider.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/telemetry/OtelEnvMetricCollectorProvider.java @@ -1,18 +1,18 @@ package datadog.trace.api.telemetry; public final class OtelEnvMetricCollectorProvider { - private static OtelEnvMetricCollector INSTANCE = null; + private static OtelEnvMetricCollector INSTANCE = null; - private OtelEnvMetricCollectorProvider() {} + private OtelEnvMetricCollectorProvider() {} - public static OtelEnvMetricCollector get() { - if (INSTANCE == null) { - throw new IllegalStateException("OtelEnvMetricCollectorService has not been registered."); + public static OtelEnvMetricCollector get() { + if (INSTANCE == null) { + throw new IllegalStateException("OtelEnvMetricCollectorService has not been registered."); + } + return INSTANCE; } - return INSTANCE; - } - public static void register(OtelEnvMetricCollector instance) { - INSTANCE = instance; - } + public static void register(OtelEnvMetricCollector instance) { + INSTANCE = instance; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsInjector.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsInjector.java index 7a5a3c44883..d5a98e23f78 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsInjector.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsInjector.java @@ -8,37 +8,37 @@ public class AgentArgsInjector { - /** - * Parses agent arguments and sets corresponding system properties. - * - * @param agentArgs Agent arguments to be parsed and set - */ - public static void injectAgentArgsConfig(String agentArgs) { - Map args = ConfigConverter.parseMap(agentArgs, "javaagent arguments", '='); - injectAgentArgsConfig(args); - } - - public static void injectAgentArgsConfig(Map args) { - if (args == null) { - return; + /** + * Parses agent arguments and sets corresponding system properties. + * + * @param agentArgs Agent arguments to be parsed and set + */ + public static void injectAgentArgsConfig(String agentArgs) { + Map args = ConfigConverter.parseMap(agentArgs, "javaagent arguments", '='); + injectAgentArgsConfig(args); } - for (Map.Entry e : args.entrySet()) { - String propertyName = e.getKey(); - String existingPropertyValue = SystemProperties.get(propertyName); - if (existingPropertyValue != null) { - // system properties should have higher priority than agent arguments - continue; - } - String envVarName = toEnvVar(propertyName); - String envVarValue = ConfigHelper.env(envVarName); - if (envVarValue != null) { - // env variables should have higher priority than agent arguments - continue; - } + public static void injectAgentArgsConfig(Map args) { + if (args == null) { + return; + } + for (Map.Entry e : args.entrySet()) { + String propertyName = e.getKey(); + String existingPropertyValue = SystemProperties.get(propertyName); + if (existingPropertyValue != null) { + // system properties should have higher priority than agent arguments + continue; + } + + String envVarName = toEnvVar(propertyName); + String envVarValue = ConfigHelper.env(envVarName); + if (envVarValue != null) { + // env variables should have higher priority than agent arguments + continue; + } - String propertyValue = e.getValue(); - System.setProperty(propertyName, propertyValue); + String propertyValue = e.getValue(); + System.setProperty(propertyName, propertyValue); + } } - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsParser.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsParser.java index c5faef4219f..c6f85828661 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsParser.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/AgentArgsParser.java @@ -8,36 +8,36 @@ public class AgentArgsParser { - private static final Logger log = LoggerFactory.getLogger(AgentArgsParser.class); + private static final Logger log = LoggerFactory.getLogger(AgentArgsParser.class); - /** - * Parses agent arguments. Key-value pairs should be separated with ',' character. Key and value - * within a pair should be separated with '=' character. For simplicity quoting is not supported - * - * @param agentArgs in form "key1=value1,key2=value2,..." - * @return parsed arguments - */ - public static Map parseAgentArgs(String agentArgs) { - if (agentArgs == null || agentArgs.isEmpty()) { - return null; - } - try { - Map args = new HashMap<>(); + /** + * Parses agent arguments. Key-value pairs should be separated with ',' character. Key and value + * within a pair should be separated with '=' character. For simplicity quoting is not supported + * + * @param agentArgs in form "key1=value1,key2=value2,..." + * @return parsed arguments + */ + public static Map parseAgentArgs(String agentArgs) { + if (agentArgs == null || agentArgs.isEmpty()) { + return null; + } + try { + Map args = new HashMap<>(); - Scanner scanner = new Scanner(agentArgs); - scanner.useDelimiter(","); - while (scanner.hasNext()) { - String arg = scanner.next(); - int idx = arg.indexOf('='); - String key = arg.substring(0, idx); - String value = arg.substring(idx + 1); - args.put(key, value); - } - return args; + Scanner scanner = new Scanner(agentArgs); + scanner.useDelimiter(","); + while (scanner.hasNext()) { + String arg = scanner.next(); + int idx = arg.indexOf('='); + String key = arg.substring(0, idx); + String value = arg.substring(idx + 1); + args.put(key, value); + } + return args; - } catch (Exception ex) { - log.error("Error parsing agent args: {}", agentArgs, ex); - return null; + } catch (Exception ex) { + log.error("Error parsing agent args: {}", agentArgs, ex); + return null; + } } - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java index 56bfb0ad547..fa40c78c5e9 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java @@ -4,23 +4,23 @@ import datadog.trace.api.env.CapturedEnvironment; public final class CapturedEnvironmentConfigSource extends ConfigProvider.Source { - private final CapturedEnvironment env; + private final CapturedEnvironment env; - public CapturedEnvironmentConfigSource() { - this(CapturedEnvironment.get()); - } + public CapturedEnvironmentConfigSource() { + this(CapturedEnvironment.get()); + } - public CapturedEnvironmentConfigSource(CapturedEnvironment env) { - this.env = env; - } + public CapturedEnvironmentConfigSource(CapturedEnvironment env) { + this.env = env; + } - @Override - protected String get(String key) { - return env.getProperties().get(key); - } + @Override + protected String get(String key) { + return env.getProperties().get(key); + } - @Override - public ConfigOrigin origin() { - return ConfigOrigin.ENV; - } + @Override + public ConfigOrigin origin() { + return ConfigOrigin.ENV; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java index 53024a3273a..e72408ce586 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java @@ -21,443 +21,422 @@ final class ConfigConverter { - private static final Logger log = LoggerFactory.getLogger(ConfigConverter.class); + private static final Logger log = LoggerFactory.getLogger(ConfigConverter.class); - /** - * Custom exception for invalid boolean values to maintain backward compatibility. When caught in - * ConfigProvider, boolean methods should return false instead of default value. - */ - static class InvalidBooleanValueException extends IllegalArgumentException { - public InvalidBooleanValueException(String message) { - super(message); + /** + * Custom exception for invalid boolean values to maintain backward compatibility. When caught in + * ConfigProvider, boolean methods should return false instead of default value. + */ + static class InvalidBooleanValueException extends IllegalArgumentException { + public InvalidBooleanValueException(String message) { + super(message); + } } - } - private static final ValueOfLookup LOOKUP = new ValueOfLookup(); + private static final ValueOfLookup LOOKUP = new ValueOfLookup(); - /** - * @param value to parse by tClass::valueOf - * @param tClass should contain static parsing method "T valueOf(String)" - * @param - * @return value == null || value.trim().isEmpty() ? defaultValue : tClass.valueOf(value) - * @throws NumberFormatException - */ - static T valueOf(final String value, @Nonnull final Class tClass) { - Objects.requireNonNull(tClass, "tClass is marked non-null but is null"); - if (value == null || value.trim().isEmpty()) { - return null; + /** + * @param value to parse by tClass::valueOf + * @param tClass should contain static parsing method "T valueOf(String)" + * @param + * @return value == null || value.trim().isEmpty() ? defaultValue : tClass.valueOf(value) + * @throws NumberFormatException + */ + static T valueOf(final String value, @Nonnull final Class tClass) { + Objects.requireNonNull(tClass, "tClass is marked non-null but is null"); + if (value == null || value.trim().isEmpty()) { + return null; + } + try { + return (T) LOOKUP.get(tClass).invoke(value); + } catch (final NumberFormatException e) { + throw e; + } catch (final IllegalArgumentException e) { + throw e; + } catch (final Throwable e) { + log.debug("Can't parse: ", e); + throw new NumberFormatException(e.toString()); + } } - try { - return (T) LOOKUP.get(tClass).invoke(value); - } catch (final NumberFormatException e) { - throw e; - } catch (final IllegalArgumentException e) { - throw e; - } catch (final Throwable e) { - log.debug("Can't parse: ", e); - throw new NumberFormatException(e.toString()); + + @Nonnull + static List parseList(final String str) { + return parseList(str, ","); } - } - @Nonnull - static List parseList(final String str) { - return parseList(str, ","); - } + @Nonnull + @SuppressForbidden + static List parseList(final String str, final String separator) { + String trimmed = trim(str); + if (trimmed.isEmpty()) { + return Collections.emptyList(); + } - @Nonnull - @SuppressForbidden - static List parseList(final String str, final String separator) { - String trimmed = trim(str); - if (trimmed.isEmpty()) { - return Collections.emptyList(); + final String[] tokens = trimmed.split(separator, -1); + // Remove whitespace from each item. + for (int i = 0; i < tokens.length; i++) { + tokens[i] = tokens[i].trim(); + } + return Collections.unmodifiableList(Arrays.asList(tokens)); } - final String[] tokens = trimmed.split(separator, -1); - // Remove whitespace from each item. - for (int i = 0; i < tokens.length; i++) { - tokens[i] = tokens[i].trim(); + @Nonnull + static Map parseMap(final String str, final String settingName) { + return parseMap(str, settingName, ':'); } - return Collections.unmodifiableList(Arrays.asList(tokens)); - } - - @Nonnull - static Map parseMap(final String str, final String settingName) { - return parseMap(str, settingName, ':'); - } - @Nonnull - static Map parseMap( - final String str, final String settingName, final char keyValueSeparator) { - // If we ever want to have default values besides an empty map, this will need to change. - String trimmed = trim(str); - if (trimmed.isEmpty()) { - return Collections.emptyMap(); + @Nonnull + static Map parseMap(final String str, final String settingName, final char keyValueSeparator) { + // If we ever want to have default values besides an empty map, this will need to change. + String trimmed = trim(str); + if (trimmed.isEmpty()) { + return Collections.emptyMap(); + } + Map map = new HashMap<>(); + loadMap(map, trimmed, settingName, keyValueSeparator); + return map; } - Map map = new HashMap<>(); - loadMap(map, trimmed, settingName, keyValueSeparator); - return map; - } - @Nonnull - static Map parseTraceTagsMap( - final String str, final char keyValueSeparator, final List argSeparators) { - // If we ever want to have default values besides an empty map, this will need to change. - String trimmed = trim(str); - if (trimmed.isEmpty()) { - return Collections.emptyMap(); + @Nonnull + static Map parseTraceTagsMap( + final String str, final char keyValueSeparator, final List argSeparators) { + // If we ever want to have default values besides an empty map, this will need to change. + String trimmed = trim(str); + if (trimmed.isEmpty()) { + return Collections.emptyMap(); + } + Map map = new HashMap<>(); + loadTraceTagsMap(map, trimmed, keyValueSeparator, argSeparators); + return map; } - Map map = new HashMap<>(); - loadTraceTagsMap(map, trimmed, keyValueSeparator, argSeparators); - return map; - } - /** - * This parses a mixed map that can have both key value pairs, and also keys only, that will get - * values on the form "defaultPrefix.key". For keys without a value, the corresponding value will - * be normalized by converting the key to lower case and replacing all non alphanumeric - * characters, except '_', '-', '/' with '_'. - * - *

The allowed format is "(key:value|key)([ ,](key:value|key))*", where you have to choose - * between ',' or ' ' as the separator. - * - * @param str String to parse - * @param settingName Name of the setting being parsed - * @param defaultPrefix Default prefix to add to key ony items - * @param lowercaseKeys Should the keys be converted to lowercase - * @return A map containing the parsed key value pairs - */ - @Nonnull - static Map parseMapWithOptionalMappings( - final String str, - final String settingName, - final String defaultPrefix, - boolean lowercaseKeys) { - String trimmed = trim(str); - if (trimmed.isEmpty()) { - return Collections.emptyMap(); + /** + * This parses a mixed map that can have both key value pairs, and also keys only, that will get + * values on the form "defaultPrefix.key". For keys without a value, the corresponding value will + * be normalized by converting the key to lower case and replacing all non alphanumeric + * characters, except '_', '-', '/' with '_'. + * + *

The allowed format is "(key:value|key)([ ,](key:value|key))*", where you have to choose + * between ',' or ' ' as the separator. + * + * @param str String to parse + * @param settingName Name of the setting being parsed + * @param defaultPrefix Default prefix to add to key ony items + * @param lowercaseKeys Should the keys be converted to lowercase + * @return A map containing the parsed key value pairs + */ + @Nonnull + static Map parseMapWithOptionalMappings( + final String str, final String settingName, final String defaultPrefix, boolean lowercaseKeys) { + String trimmed = trim(str); + if (trimmed.isEmpty()) { + return Collections.emptyMap(); + } + Map map = new HashMap<>(); + loadMapWithOptionalMapping(map, trimmed, settingName, defaultPrefix, lowercaseKeys); + return map; } - Map map = new HashMap<>(); - loadMapWithOptionalMapping(map, trimmed, settingName, defaultPrefix, lowercaseKeys); - return map; - } - @Nonnull - static Map parseOrderedMap(final String str, final String settingName) { - // If we ever want to have default values besides an empty map, this will need to change. - String trimmed = trim(str); - if (trimmed.isEmpty()) { - return Collections.emptyMap(); + @Nonnull + static Map parseOrderedMap(final String str, final String settingName) { + // If we ever want to have default values besides an empty map, this will need to change. + String trimmed = trim(str); + if (trimmed.isEmpty()) { + return Collections.emptyMap(); + } + Map map = new LinkedHashMap<>(); + loadMap(map, trimmed, settingName, ':'); + return map; } - Map map = new LinkedHashMap<>(); - loadMap(map, trimmed, settingName, ':'); - return map; - } - private static final class BadFormatException extends Exception { - public BadFormatException(String message) { - super(message); + private static final class BadFormatException extends Exception { + public BadFormatException(String message) { + super(message); + } } - } - private static void loadMap( - Map map, String str, String settingName, char keyValueSeparator) { - // we know that the str is trimmed and rely on that there is no leading/trailing whitespace - try { - int start = 0; - int splitter = str.indexOf(keyValueSeparator, start); - while (splitter != -1) { - int nextSplitter = str.indexOf(keyValueSeparator, splitter + 1); - int nextComma = str.indexOf(',', splitter + 1); - nextComma = nextComma == -1 ? str.length() : nextComma; - int nextSpace = str.indexOf(' ', splitter + 1); - nextSpace = nextSpace == -1 ? str.length() : nextSpace; - // if we have a delimiter after this splitter, then try to move the splitter forward to - // allow for tags with ':' in them - int end = nextComma < str.length() ? nextComma : nextSpace; - while (nextSplitter != -1 && nextSplitter < end) { - nextSplitter = str.indexOf(keyValueSeparator, nextSplitter + 1); - } - if (nextSplitter == -1) { - // this is either the end of the string or the next position where the value should be - // trimmed - end = nextComma; - if (nextComma < str.length() - 1) { - // there are non-space characters after the ',' - throw new BadFormatException("Non white space characters after trailing ','"); - } - } else { - if (nextComma < str.length()) { - end = nextComma; - } else if (nextSpace < str.length()) { - end = nextSpace; - } else { - // this should not happen - throw new BadFormatException("Illegal position of split character ':'"); - } - } - String key = str.substring(start, splitter).trim(); - if (key.indexOf(',') != -1) { - throw new BadFormatException("Illegal ',' character in key '" + key + "'"); - } - String value = str.substring(splitter + 1, end).trim(); - if (value.indexOf(' ') != -1) { - throw new BadFormatException("Illegal ' ' character in value for key '" + key + "'"); - } - if (!key.isEmpty() && !value.isEmpty()) { - map.put(key, value); + private static void loadMap(Map map, String str, String settingName, char keyValueSeparator) { + // we know that the str is trimmed and rely on that there is no leading/trailing whitespace + try { + int start = 0; + int splitter = str.indexOf(keyValueSeparator, start); + while (splitter != -1) { + int nextSplitter = str.indexOf(keyValueSeparator, splitter + 1); + int nextComma = str.indexOf(',', splitter + 1); + nextComma = nextComma == -1 ? str.length() : nextComma; + int nextSpace = str.indexOf(' ', splitter + 1); + nextSpace = nextSpace == -1 ? str.length() : nextSpace; + // if we have a delimiter after this splitter, then try to move the splitter forward to + // allow for tags with ':' in them + int end = nextComma < str.length() ? nextComma : nextSpace; + while (nextSplitter != -1 && nextSplitter < end) { + nextSplitter = str.indexOf(keyValueSeparator, nextSplitter + 1); + } + if (nextSplitter == -1) { + // this is either the end of the string or the next position where the value should be + // trimmed + end = nextComma; + if (nextComma < str.length() - 1) { + // there are non-space characters after the ',' + throw new BadFormatException("Non white space characters after trailing ','"); + } + } else { + if (nextComma < str.length()) { + end = nextComma; + } else if (nextSpace < str.length()) { + end = nextSpace; + } else { + // this should not happen + throw new BadFormatException("Illegal position of split character ':'"); + } + } + String key = str.substring(start, splitter).trim(); + if (key.indexOf(',') != -1) { + throw new BadFormatException("Illegal ',' character in key '" + key + "'"); + } + String value = str.substring(splitter + 1, end).trim(); + if (value.indexOf(' ') != -1) { + throw new BadFormatException("Illegal ' ' character in value for key '" + key + "'"); + } + if (!key.isEmpty() && !value.isEmpty()) { + map.put(key, value); + } + splitter = nextSplitter; + start = end + 1; + } + } catch (Throwable t) { + if (t instanceof BadFormatException) { + log.warn( + "Invalid config for {}. {}. Must match " + + "'key1{}value1,key2{}value2' or " + + "'key1{}value1 key2{}value2'.", + settingName, + t.getMessage(), + keyValueSeparator, + keyValueSeparator, + keyValueSeparator, + keyValueSeparator); + } else { + log.warn("Unexpected exception during config parsing of {}.", settingName, t); + } + map.clear(); } - splitter = nextSplitter; - start = end + 1; - } - } catch (Throwable t) { - if (t instanceof BadFormatException) { - log.warn( - "Invalid config for {}. {}. Must match " - + "'key1{}value1,key2{}value2' or " - + "'key1{}value1 key2{}value2'.", - settingName, - t.getMessage(), - keyValueSeparator, - keyValueSeparator, - keyValueSeparator, - keyValueSeparator); - } else { - log.warn("Unexpected exception during config parsing of {}.", settingName, t); - } - map.clear(); } - } - private static void loadTraceTagsMap( - Map map, - String str, - char keyValueSeparator, - final List argSeparators) { - int start = 0; - int splitter = str.indexOf(keyValueSeparator, start); - char argSeparator = '\0'; - int argSeparatorInd = -1; + private static void loadTraceTagsMap( + Map map, String str, char keyValueSeparator, final List argSeparators) { + int start = 0; + int splitter = str.indexOf(keyValueSeparator, start); + char argSeparator = '\0'; + int argSeparatorInd = -1; - // Given a list of separators ordered by priority, find the first (highest priority) separator - // that appears in the string and store its value and first occurrence in the string - for (Character sep : argSeparators) { - argSeparatorInd = str.indexOf(sep); - if (argSeparatorInd != -1) { - argSeparator = sep; - break; - } - } - while (start < str.length()) { - int nextSplitter = - argSeparatorInd == -1 - ? -1 - : str.indexOf( - keyValueSeparator, - argSeparatorInd + 1); // next splitter after the next argSeparator - int nextArgSeparator = - argSeparatorInd == -1 ? -1 : str.indexOf(argSeparator, argSeparatorInd + 1); - int end = argSeparatorInd == -1 ? str.length() : argSeparatorInd; + // Given a list of separators ordered by priority, find the first (highest priority) separator + // that appears in the string and store its value and first occurrence in the string + for (Character sep : argSeparators) { + argSeparatorInd = str.indexOf(sep); + if (argSeparatorInd != -1) { + argSeparator = sep; + break; + } + } + while (start < str.length()) { + int nextSplitter = argSeparatorInd == -1 + ? -1 + : str.indexOf(keyValueSeparator, argSeparatorInd + 1); // next splitter after the next argSeparator + int nextArgSeparator = argSeparatorInd == -1 ? -1 : str.indexOf(argSeparator, argSeparatorInd + 1); + int end = argSeparatorInd == -1 ? str.length() : argSeparatorInd; - if (start >= end) { // the character is only the delimiter - start = end + 1; - splitter = nextSplitter; - argSeparatorInd = nextArgSeparator; - continue; - } + if (start >= end) { // the character is only the delimiter + start = end + 1; + splitter = nextSplitter; + argSeparatorInd = nextArgSeparator; + continue; + } - String key, value; - if (splitter >= end - || splitter - == -1) { // only key, no value; either due end of string or substring not having - // splitter - key = str.substring(start, end).trim(); - value = ""; - } else { - key = str.substring(start, splitter).trim(); - value = str.substring(splitter + 1, end).trim(); - } - if (!key.isEmpty()) { - map.put(key, value); - } - splitter = nextSplitter; - argSeparatorInd = nextArgSeparator; - start = end + 1; + String key, value; + if (splitter >= end + || splitter == -1) { // only key, no value; either due end of string or substring not having + // splitter + key = str.substring(start, end).trim(); + value = ""; + } else { + key = str.substring(start, splitter).trim(); + value = str.substring(splitter + 1, end).trim(); + } + if (!key.isEmpty()) { + map.put(key, value); + } + splitter = nextSplitter; + argSeparatorInd = nextArgSeparator; + start = end + 1; + } } - } - private static void loadMapWithOptionalMapping( - Map map, - String str, - String settingName, - String defaultPrefix, - boolean lowercaseKeys) { - try { - defaultPrefix = null == defaultPrefix ? "" : defaultPrefix; - if (!defaultPrefix.isEmpty() && !defaultPrefix.endsWith(".")) { - defaultPrefix = defaultPrefix + "."; - } - int start = 0; - int len = str.length(); - char listChar = str.indexOf(',') == -1 ? ' ' : ','; - while (start < len) { - int end = len; - int listPos = str.indexOf(listChar, start); - int mapPos = str.indexOf(':', start); - int delimiter = listPos == -1 ? mapPos : mapPos == -1 ? listPos : Math.min(listPos, mapPos); - if (delimiter == -1) { - delimiter = end; - } else if (delimiter == mapPos) { - // we're in a mapping, so let's find the next part - int nextList = str.indexOf(listChar, delimiter + 1); - if (mapPos == start) { - // can't have an empty key - throw new BadFormatException("Illegal empty key at position " + start); - } else if (nextList != -1) { - end = nextList; - } - } else { - // delimiter is at listPos, so set end to delimiter - end = delimiter; - } + private static void loadMapWithOptionalMapping( + Map map, String str, String settingName, String defaultPrefix, boolean lowercaseKeys) { + try { + defaultPrefix = null == defaultPrefix ? "" : defaultPrefix; + if (!defaultPrefix.isEmpty() && !defaultPrefix.endsWith(".")) { + defaultPrefix = defaultPrefix + "."; + } + int start = 0; + int len = str.length(); + char listChar = str.indexOf(',') == -1 ? ' ' : ','; + while (start < len) { + int end = len; + int listPos = str.indexOf(listChar, start); + int mapPos = str.indexOf(':', start); + int delimiter = listPos == -1 ? mapPos : mapPos == -1 ? listPos : Math.min(listPos, mapPos); + if (delimiter == -1) { + delimiter = end; + } else if (delimiter == mapPos) { + // we're in a mapping, so let's find the next part + int nextList = str.indexOf(listChar, delimiter + 1); + if (mapPos == start) { + // can't have an empty key + throw new BadFormatException("Illegal empty key at position " + start); + } else if (nextList != -1) { + end = nextList; + } + } else { + // delimiter is at listPos, so set end to delimiter + end = delimiter; + } - if (start != end) { - String key = trimmedHeader(str, start, delimiter, lowercaseKeys); - if (!key.isEmpty()) { - String value; - if (delimiter == mapPos) { - value = trimmedHeader(str, delimiter + 1, end, false); - // tags must start with a letter - if (!value.isEmpty() && !Character.isLetter(value.charAt(0))) { - throw new BadFormatException( - "Illegal tag starting with non letter for key '" + key + "'"); - } - } else { - // If wildcard exists, we do not allow other header mappings - if (key.charAt(0) == '*') { - map.clear(); - map.put(key, defaultPrefix); - return; - } - if (Character.isLetter(key.charAt(0))) { - value = defaultPrefix + normalizedHeaderTag(key); - } else { - // tags must start with a letter - throw new BadFormatException( - "Illegal key only tag starting with non letter '" + key + "'"); - } + if (start != end) { + String key = trimmedHeader(str, start, delimiter, lowercaseKeys); + if (!key.isEmpty()) { + String value; + if (delimiter == mapPos) { + value = trimmedHeader(str, delimiter + 1, end, false); + // tags must start with a letter + if (!value.isEmpty() && !Character.isLetter(value.charAt(0))) { + throw new BadFormatException( + "Illegal tag starting with non letter for key '" + key + "'"); + } + } else { + // If wildcard exists, we do not allow other header mappings + if (key.charAt(0) == '*') { + map.clear(); + map.put(key, defaultPrefix); + return; + } + if (Character.isLetter(key.charAt(0))) { + value = defaultPrefix + normalizedHeaderTag(key); + } else { + // tags must start with a letter + throw new BadFormatException( + "Illegal key only tag starting with non letter '" + key + "'"); + } + } + if (!value.isEmpty()) { + map.put(key, value); + } + } + } + start = end + 1; } - if (!value.isEmpty()) { - map.put(key, value); + } catch (Throwable t) { + if (t instanceof BadFormatException) { + log.warn( + "Invalid config for {}. {}. Must match '(key:value|key)([ ,](key:value|key))*'.", + settingName, + t.getMessage()); + } else { + log.warn("Unexpected exception during config parsing of {}.", settingName, t); } - } + map.clear(); } - start = end + 1; - } - } catch (Throwable t) { - if (t instanceof BadFormatException) { - log.warn( - "Invalid config for {}. {}. Must match '(key:value|key)([ ,](key:value|key))*'.", - settingName, - t.getMessage()); - } else { - log.warn("Unexpected exception during config parsing of {}.", settingName, t); - } - map.clear(); } - } - @Nonnull - private static String trimmedHeader(String str, int start, int end, boolean lowercase) { - if (start >= end) { - return ""; - } - StringBuilder builder = new StringBuilder(end - start); - int firstNonWhiteSpace = -1; - int lastNonWhitespace = -1; - for (int i = start; i < end; i++) { - char c = lowercase ? Character.toLowerCase(str.charAt(i)) : str.charAt(i); - if (Character.isWhitespace(c)) { - builder.append(' '); - } else { - firstNonWhiteSpace = firstNonWhiteSpace == -1 ? i : firstNonWhiteSpace; - lastNonWhitespace = i; - builder.append(c); - } - } - if (firstNonWhiteSpace == -1) { - return ""; - } else { - str = builder.substring(firstNonWhiteSpace - start, lastNonWhitespace - start + 1); - return str; + @Nonnull + private static String trimmedHeader(String str, int start, int end, boolean lowercase) { + if (start >= end) { + return ""; + } + StringBuilder builder = new StringBuilder(end - start); + int firstNonWhiteSpace = -1; + int lastNonWhitespace = -1; + for (int i = start; i < end; i++) { + char c = lowercase ? Character.toLowerCase(str.charAt(i)) : str.charAt(i); + if (Character.isWhitespace(c)) { + builder.append(' '); + } else { + firstNonWhiteSpace = firstNonWhiteSpace == -1 ? i : firstNonWhiteSpace; + lastNonWhitespace = i; + builder.append(c); + } + } + if (firstNonWhiteSpace == -1) { + return ""; + } else { + str = builder.substring(firstNonWhiteSpace - start, lastNonWhitespace - start + 1); + return str; + } } - } - @Nonnull - @SuppressForbidden - static BitSet parseIntegerRangeSet(@Nonnull String str, final String settingName) - throws NumberFormatException { - str = str.replaceAll("\\s", ""); - if (!str.matches("\\d{1,3}(?:-\\d{1,3})?(?:,\\d{1,3}(?:-\\d{1,3})?)*")) { - log.warn( - "Invalid config for {}: '{}'. Must be formatted like '400-403,405,410-499'.", - settingName, - str); - throw new NumberFormatException(); - } + @Nonnull + @SuppressForbidden + static BitSet parseIntegerRangeSet(@Nonnull String str, final String settingName) throws NumberFormatException { + str = str.replaceAll("\\s", ""); + if (!str.matches("\\d{1,3}(?:-\\d{1,3})?(?:,\\d{1,3}(?:-\\d{1,3})?)*")) { + log.warn("Invalid config for {}: '{}'. Must be formatted like '400-403,405,410-499'.", settingName, str); + throw new NumberFormatException(); + } - final int lastSeparator = Math.max(str.lastIndexOf(','), str.lastIndexOf('-')); - final int maxValue = Integer.parseInt(str.substring(lastSeparator + 1)); - final BitSet set = new BitSet(maxValue); - final String[] tokens = str.split(",", -1); - for (final String token : tokens) { - final int separator = token.indexOf('-'); - if (separator == -1) { - set.set(Integer.parseInt(token)); - } else if (separator > 0) { - final int left = Integer.parseInt(token.substring(0, separator)); - final int right = Integer.parseInt(token.substring(separator + 1)); - final int min = Math.min(left, right); - final int max = Math.max(left, right); - set.set(min, max + 1); - } + final int lastSeparator = Math.max(str.lastIndexOf(','), str.lastIndexOf('-')); + final int maxValue = Integer.parseInt(str.substring(lastSeparator + 1)); + final BitSet set = new BitSet(maxValue); + final String[] tokens = str.split(",", -1); + for (final String token : tokens) { + final int separator = token.indexOf('-'); + if (separator == -1) { + set.set(Integer.parseInt(token)); + } else if (separator > 0) { + final int left = Integer.parseInt(token.substring(0, separator)); + final int right = Integer.parseInt(token.substring(separator + 1)); + final int min = Math.min(left, right); + final int max = Math.max(left, right); + set.set(min, max + 1); + } + } + return set; } - return set; - } - public static Boolean booleanValueOf(String value) { - if ("1".equals(value)) { - return Boolean.TRUE; - } else if ("0".equals(value)) { - return Boolean.FALSE; - } else if ("true".equalsIgnoreCase(value)) { - return Boolean.TRUE; - } else if ("false".equalsIgnoreCase(value)) { - return Boolean.FALSE; - } else { - // Throw custom exception for invalid boolean values to maintain backward compatibility - throw new InvalidBooleanValueException("Invalid boolean value: " + value); + public static Boolean booleanValueOf(String value) { + if ("1".equals(value)) { + return Boolean.TRUE; + } else if ("0".equals(value)) { + return Boolean.FALSE; + } else if ("true".equalsIgnoreCase(value)) { + return Boolean.TRUE; + } else if ("false".equalsIgnoreCase(value)) { + return Boolean.FALSE; + } else { + // Throw custom exception for invalid boolean values to maintain backward compatibility + throw new InvalidBooleanValueException("Invalid boolean value: " + value); + } } - } - private static class ValueOfLookup extends ClassValue { - private static final MethodHandles.Lookup PUBLIC_LOOKUP = MethodHandles.publicLookup(); + private static class ValueOfLookup extends ClassValue { + private static final MethodHandles.Lookup PUBLIC_LOOKUP = MethodHandles.publicLookup(); - @Override - protected MethodHandle computeValue(Class type) { - try { - if (Boolean.class.equals(type)) { - return MethodHandles.lookup() - .findStatic( - ConfigConverter.class, - "booleanValueOf", - MethodType.methodType(Boolean.class, String.class)); - } + @Override + protected MethodHandle computeValue(Class type) { + try { + if (Boolean.class.equals(type)) { + return MethodHandles.lookup() + .findStatic( + ConfigConverter.class, + "booleanValueOf", + MethodType.methodType(Boolean.class, String.class)); + } - return PUBLIC_LOOKUP.findStatic(type, "valueOf", MethodType.methodType(type, String.class)); - } catch (final NoSuchMethodException | IllegalAccessException e) { - log.debug("Can't invoke or access 'valueOf': ", e); - throw new RuntimeException(e); - } + return PUBLIC_LOOKUP.findStatic(type, "valueOf", MethodType.methodType(type, String.class)); + } catch (final NoSuchMethodException | IllegalAccessException e) { + log.debug("Can't invoke or access 'valueOf': ", e); + throw new RuntimeException(e); + } + } } - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java index f8187b546d2..f8842ccc949 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java @@ -30,715 +30,694 @@ import org.slf4j.LoggerFactory; public final class ConfigProvider { - private static final class Singleton { - private static final ConfigProvider INSTANCE = ConfigProvider.createDefault(); - } - - private static final Logger log = LoggerFactory.getLogger(ConfigProvider.class); - - private final boolean collectConfig; - - private final ConfigProvider.Source[] sources; - - private ConfigProvider(ConfigProvider.Source... sources) { - this(true, sources); - } - - private ConfigProvider(boolean collectConfig, ConfigProvider.Source... sources) { - this.collectConfig = collectConfig; - this.sources = sources; - } - - public String getConfigFileStatus() { - for (ConfigProvider.Source source : sources) { - if (source instanceof PropertiesConfigSource) { - String configFileStatus = ((PropertiesConfigSource) source).getConfigFileStatus(); - if (null != configFileStatus) { - return configFileStatus; - } - } - } - return "no config file present"; - } - - // Gets a string value when there is no default value. - public String getString(String key) { - return getString(key, null); - } - - /** - * Gets a string value with a default fallback and optional aliases. Use for configs with - * meaningful defaults. Reports default to telemetry. - */ - public String getString(String key, String defaultValue, String... aliases) { - if (collectConfig) { - reportDefault(key, defaultValue); - } - String value = getStringInternal(key, aliases); - - return value != null ? value : defaultValue; - } - - // Internal helper that performs configuration source lookup and reports values from non-default - // sources to telemetry. - private String getStringInternal(String key, String... aliases) { - ConfigValueResolver resolver = null; - int seqId = NON_DEFAULT_SEQ_ID; - - for (int i = sources.length - 1; i >= 0; i--) { - ConfigProvider.Source source = sources[i]; - String candidate = source.get(key, aliases); - // Create resolver if we have a valid candidate - if (candidate != null) { - resolver = ConfigValueResolver.of(candidate); - // And report to telemetry - if (collectConfig) { - ConfigCollector.get() - .put(key, candidate, source.origin(), seqId, getConfigIdFromSource(source)); - } - } - - seqId++; + private static final class Singleton { + private static final ConfigProvider INSTANCE = ConfigProvider.createDefault(); } - return resolver != null ? resolver.value : null; - } + private static final Logger log = LoggerFactory.getLogger(ConfigProvider.class); - public > T getEnum(String key, Class enumType, T defaultValue) { - if (collectConfig) { - String defaultValueString = defaultValue == null ? null : defaultValue.name(); - reportDefault(key, defaultValueString); - } - String value = getStringInternal(key); - if (null != value) { - try { - // replace invalid characters with _ and make sure it's upper-case before converting - return Enum.valueOf(enumType, value.replace('/', '_').toUpperCase(Locale.ROOT)); - } catch (Exception ignoreAndUseDefault) { - log.debug("failed to parse {} for {}, defaulting to {}", value, key, defaultValue); - } + private final boolean collectConfig; + + private final ConfigProvider.Source[] sources; + + private ConfigProvider(ConfigProvider.Source... sources) { + this(true, sources); } - return defaultValue; - } - /** - * Like {@link #getString(String, String, String...)} but falls back to next source if a value is - * an empty or blank string. - */ - public String getStringNotEmpty(String key, String defaultValue, String... aliases) { - if (collectConfig) { - reportDefault(key, defaultValue); + private ConfigProvider(boolean collectConfig, ConfigProvider.Source... sources) { + this.collectConfig = collectConfig; + this.sources = sources; } - ConfigValueResolver resolver = null; - int seqId = NON_DEFAULT_SEQ_ID; + public String getConfigFileStatus() { + for (ConfigProvider.Source source : sources) { + if (source instanceof PropertiesConfigSource) { + String configFileStatus = ((PropertiesConfigSource) source).getConfigFileStatus(); + if (null != configFileStatus) { + return configFileStatus; + } + } + } + return "no config file present"; + } - for (int i = sources.length - 1; i >= 0; i--) { - ConfigProvider.Source source = sources[i]; - String candidateValue = source.get(key, aliases); + // Gets a string value when there is no default value. + public String getString(String key) { + return getString(key, null); + } - // Report any non-null values to telemetry - if (candidateValue != null) { + /** + * Gets a string value with a default fallback and optional aliases. Use for configs with + * meaningful defaults. Reports default to telemetry. + */ + public String getString(String key, String defaultValue, String... aliases) { if (collectConfig) { - ConfigCollector.get() - .put(key, candidateValue, source.origin(), seqId, getConfigIdFromSource(source)); - } - // Create resolver only if candidate is not empty or blank - if (!candidateValue.trim().isEmpty()) { - resolver = - ConfigValueResolver.of( - candidateValue, source.origin(), seqId, getConfigIdFromSource(source)); + reportDefault(key, defaultValue); } - } + String value = getStringInternal(key, aliases); - seqId++; + return value != null ? value : defaultValue; } - // Re-report the chosen value with the highest seqId - if (resolver != null && collectConfig) { - resolver.reReportToCollector(key, seqId + 1); - } + // Internal helper that performs configuration source lookup and reports values from non-default + // sources to telemetry. + private String getStringInternal(String key, String... aliases) { + ConfigValueResolver resolver = null; + int seqId = NON_DEFAULT_SEQ_ID; - return resolver != null ? resolver.value : defaultValue; - } + for (int i = sources.length - 1; i >= 0; i--) { + ConfigProvider.Source source = sources[i]; + String candidate = source.get(key, aliases); + // Create resolver if we have a valid candidate + if (candidate != null) { + resolver = ConfigValueResolver.of(candidate); + // And report to telemetry + if (collectConfig) { + ConfigCollector.get().put(key, candidate, source.origin(), seqId, getConfigIdFromSource(source)); + } + } - public String getStringExcludingSource( - String key, - String defaultValue, - Class excludedSource, - String... aliases) { - if (collectConfig) { - reportDefault(key, defaultValue); + seqId++; + } + + return resolver != null ? resolver.value : null; } - ConfigValueResolver resolver = null; - int seqId = NON_DEFAULT_SEQ_ID; - for (int i = sources.length - 1; i >= 0; i--) { - ConfigProvider.Source source = sources[i]; - String candidate = source.get(key, aliases); - // Skip excluded source types - if (excludedSource.isAssignableFrom(source.getClass())) { - seqId++; - continue; - } - if (candidate != null) { - resolver = ConfigValueResolver.of(candidate); + public > T getEnum(String key, Class enumType, T defaultValue) { if (collectConfig) { - ConfigCollector.get() - .put(key, candidate, source.origin(), seqId, getConfigIdFromSource(source)); + String defaultValueString = defaultValue == null ? null : defaultValue.name(); + reportDefault(key, defaultValueString); + } + String value = getStringInternal(key); + if (null != value) { + try { + // replace invalid characters with _ and make sure it's upper-case before converting + return Enum.valueOf(enumType, value.replace('/', '_').toUpperCase(Locale.ROOT)); + } catch (Exception ignoreAndUseDefault) { + log.debug("failed to parse {} for {}, defaulting to {}", value, key, defaultValue); + } } - } - seqId++; + return defaultValue; } - return resolver != null ? resolver.value : defaultValue; - } - public boolean isSet(String key) { - String value = getString(key); - return value != null && !value.isEmpty(); - } + /** + * Like {@link #getString(String, String, String...)} but falls back to next source if a value is + * an empty or blank string. + */ + public String getStringNotEmpty(String key, String defaultValue, String... aliases) { + if (collectConfig) { + reportDefault(key, defaultValue); + } - public Boolean getBoolean(String key) { - return get(key, null, Boolean.class); - } + ConfigValueResolver resolver = null; + int seqId = NON_DEFAULT_SEQ_ID; + + for (int i = sources.length - 1; i >= 0; i--) { + ConfigProvider.Source source = sources[i]; + String candidateValue = source.get(key, aliases); + + // Report any non-null values to telemetry + if (candidateValue != null) { + if (collectConfig) { + ConfigCollector.get() + .put(key, candidateValue, source.origin(), seqId, getConfigIdFromSource(source)); + } + // Create resolver only if candidate is not empty or blank + if (!candidateValue.trim().isEmpty()) { + resolver = ConfigValueResolver.of( + candidateValue, source.origin(), seqId, getConfigIdFromSource(source)); + } + } - public Boolean getBoolean(String key, String... aliases) { - return get(key, null, Boolean.class, aliases); - } + seqId++; + } - public boolean getBoolean(String key, boolean defaultValue, String... aliases) { - return get(key, defaultValue, Boolean.class, aliases); - } + // Re-report the chosen value with the highest seqId + if (resolver != null && collectConfig) { + resolver.reReportToCollector(key, seqId + 1); + } - public Integer getInteger(String key) { - return get(key, null, Integer.class); - } + return resolver != null ? resolver.value : defaultValue; + } - public Integer getInteger(String key, String... aliases) { - return get(key, null, Integer.class, aliases); - } + public String getStringExcludingSource( + String key, String defaultValue, Class excludedSource, String... aliases) { + if (collectConfig) { + reportDefault(key, defaultValue); + } + ConfigValueResolver resolver = null; + int seqId = NON_DEFAULT_SEQ_ID; + for (int i = sources.length - 1; i >= 0; i--) { + ConfigProvider.Source source = sources[i]; + String candidate = source.get(key, aliases); + + // Skip excluded source types + if (excludedSource.isAssignableFrom(source.getClass())) { + seqId++; + continue; + } + if (candidate != null) { + resolver = ConfigValueResolver.of(candidate); + if (collectConfig) { + ConfigCollector.get().put(key, candidate, source.origin(), seqId, getConfigIdFromSource(source)); + } + } + seqId++; + } + return resolver != null ? resolver.value : defaultValue; + } + + public boolean isSet(String key) { + String value = getString(key); + return value != null && !value.isEmpty(); + } + + public Boolean getBoolean(String key) { + return get(key, null, Boolean.class); + } + + public Boolean getBoolean(String key, String... aliases) { + return get(key, null, Boolean.class, aliases); + } - public int getInteger(String key, int defaultValue, String... aliases) { - return get(key, defaultValue, Integer.class, aliases); - } + public boolean getBoolean(String key, boolean defaultValue, String... aliases) { + return get(key, defaultValue, Boolean.class, aliases); + } - public Long getLong(String key) { - return get(key, null, Long.class); - } + public Integer getInteger(String key) { + return get(key, null, Integer.class); + } - public Long getLong(String key, String... aliases) { - return get(key, null, Long.class, aliases); - } + public Integer getInteger(String key, String... aliases) { + return get(key, null, Integer.class, aliases); + } - public long getLong(String key, long defaultValue, String... aliases) { - return get(key, defaultValue, Long.class, aliases); - } + public int getInteger(String key, int defaultValue, String... aliases) { + return get(key, defaultValue, Integer.class, aliases); + } - public Float getFloat(String key, String... aliases) { - return get(key, null, Float.class, aliases); - } + public Long getLong(String key) { + return get(key, null, Long.class); + } + + public Long getLong(String key, String... aliases) { + return get(key, null, Long.class, aliases); + } - public float getFloat(String key, float defaultValue) { - return get(key, defaultValue, Float.class); - } + public long getLong(String key, long defaultValue, String... aliases) { + return get(key, defaultValue, Long.class, aliases); + } - public Double getDouble(String key) { - return get(key, null, Double.class); - } + public Float getFloat(String key, String... aliases) { + return get(key, null, Float.class, aliases); + } - public double getDouble(String key, double defaultValue) { - return get(key, defaultValue, Double.class); - } + public float getFloat(String key, float defaultValue) { + return get(key, defaultValue, Float.class); + } - public double getDouble(String key, double defaultValue, String... aliases) { - return get(key, defaultValue, Double.class, aliases); - } + public Double getDouble(String key) { + return get(key, null, Double.class); + } - private T get(String key, T defaultValue, Class type, String... aliases) { - if (collectConfig) { - reportDefault(key, defaultValue); + public double getDouble(String key, double defaultValue) { + return get(key, defaultValue, Double.class); } - ConfigValueResolver resolver = null; - int seqId = NON_DEFAULT_SEQ_ID; + public double getDouble(String key, double defaultValue, String... aliases) { + return get(key, defaultValue, Double.class, aliases); + } - for (int i = sources.length - 1; i >= 0; i--) { - String sourceValue = sources[i].get(key, aliases); - String configId = getConfigIdFromSource(sources[i]); + private T get(String key, T defaultValue, Class type, String... aliases) { + if (collectConfig) { + reportDefault(key, defaultValue); + } - // Always report raw value to telemetry - if (sourceValue != null && collectConfig) { - ConfigCollector.get().put(key, sourceValue, sources[i].origin(), seqId, configId); - } + ConfigValueResolver resolver = null; + int seqId = NON_DEFAULT_SEQ_ID; + + for (int i = sources.length - 1; i >= 0; i--) { + String sourceValue = sources[i].get(key, aliases); + String configId = getConfigIdFromSource(sources[i]); + + // Always report raw value to telemetry + if (sourceValue != null && collectConfig) { + ConfigCollector.get().put(key, sourceValue, sources[i].origin(), seqId, configId); + } + + try { + T candidate = ConfigConverter.valueOf(sourceValue, type); + if (candidate != null) { + resolver = ConfigValueResolver.of(candidate, sources[i].origin(), seqId, configId); + } + } catch (ConfigConverter.InvalidBooleanValueException ex) { + // For backward compatibility: invalid boolean values should return false, not default + // Store the invalid sourceValue for telemetry, but return false + if (Boolean.class.equals(type)) { + resolver = ConfigValueResolver.of((T) Boolean.FALSE, ConfigOrigin.CALCULATED, seqId, configId); + } + // For non-boolean types, continue to next source + } catch (IllegalArgumentException ex) { + // continue - covers both NumberFormatException and other IllegalArgumentException + } - try { - T candidate = ConfigConverter.valueOf(sourceValue, type); - if (candidate != null) { - resolver = ConfigValueResolver.of(candidate, sources[i].origin(), seqId, configId); + seqId++; } - } catch (ConfigConverter.InvalidBooleanValueException ex) { - // For backward compatibility: invalid boolean values should return false, not default - // Store the invalid sourceValue for telemetry, but return false - if (Boolean.class.equals(type)) { - resolver = - ConfigValueResolver.of((T) Boolean.FALSE, ConfigOrigin.CALCULATED, seqId, configId); + + // Re-report the chosen value and origin to ensure its seqId is higher than any error configs + if (resolver != null && collectConfig) { + resolver.reReportToCollector(key, seqId + 1); } - // For non-boolean types, continue to next source - } catch (IllegalArgumentException ex) { - // continue - covers both NumberFormatException and other IllegalArgumentException - } - seqId++; + return resolver != null ? resolver.value : defaultValue; } - // Re-report the chosen value and origin to ensure its seqId is higher than any error configs - if (resolver != null && collectConfig) { - resolver.reReportToCollector(key, seqId + 1); + public List getList(String key) { + return ConfigConverter.parseList(getString(key)); } - return resolver != null ? resolver.value : defaultValue; - } - - public List getList(String key) { - return ConfigConverter.parseList(getString(key)); - } - - public List getList(String key, List defaultValue, String... aliases) { - // Ensure the first item at DEFAULT is the accurate one - if (collectConfig) { - reportDefault(key, defaultValue); + public List getList(String key, List defaultValue, String... aliases) { + // Ensure the first item at DEFAULT is the accurate one + if (collectConfig) { + reportDefault(key, defaultValue); + } + String list = getStringInternal(key, aliases); + if (null == list) { + return defaultValue; + } else { + return ConfigConverter.parseList(list); + } } - String list = getStringInternal(key, aliases); - if (null == list) { - return defaultValue; - } else { - return ConfigConverter.parseList(list); + + public Set getSet(String key, Set defaultValue) { + // Ensure the first item at DEFAULT is the most accurate one + if (collectConfig) { + reportDefault(key, defaultValue); + } + String list = getStringInternal(key); + if (null == list) { + return defaultValue; + } else { + return new HashSet(ConfigConverter.parseList(list)); + } } - } - public Set getSet(String key, Set defaultValue) { - // Ensure the first item at DEFAULT is the most accurate one - if (collectConfig) { - reportDefault(key, defaultValue); + public List getSpacedList(String key) { + return ConfigConverter.parseList(getString(key), " "); } - String list = getStringInternal(key); - if (null == list) { - return defaultValue; - } else { - return new HashSet(ConfigConverter.parseList(list)); + + public Map getMergedMap(String key, String... aliases) { + return getMergedMap(key, ':', aliases); } - } - public List getSpacedList(String key) { - return ConfigConverter.parseList(getString(key), " "); - } + public Map getMergedMap(String key, char keyValueDelimiter, String... aliases) { + ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new HashMap<>()); + int seqId = NON_DEFAULT_SEQ_ID; + + // System properties take precedence over env + // prior art: + // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html + // We reverse iterate to allow overrides + for (int i = sources.length - 1; 0 <= i; i--) { + String value = sources[i].get(key, aliases); + Map parsedMap = ConfigConverter.parseMap(value, key, keyValueDelimiter); - public Map getMergedMap(String key, String... aliases) { - return getMergedMap(key, ':', aliases); - } + if (!parsedMap.isEmpty()) { + if (collectConfig) { + seqId++; + ConfigCollector.get() + .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); + } + mergeResolver.addContribution(parsedMap, sources[i].origin()); + } + } - public Map getMergedMap(String key, char keyValueDelimiter, String... aliases) { - ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new HashMap<>()); - int seqId = NON_DEFAULT_SEQ_ID; + if (collectConfig) { + reportDefault(key, Collections.emptyMap()); + mergeResolver.reReportFinalResult(key, seqId); + } - // System properties take precedence over env - // prior art: - // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html - // We reverse iterate to allow overrides - for (int i = sources.length - 1; 0 <= i; i--) { - String value = sources[i].get(key, aliases); - Map parsedMap = ConfigConverter.parseMap(value, key, keyValueDelimiter); + return mergeResolver.getMergedValue(); + } + + public Map getMergedTagsMap(String key, String... aliases) { + ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new HashMap<>()); + int seqId = NON_DEFAULT_SEQ_ID; + + // System properties take precedence over env + // prior art: + // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html + // We reverse iterate to allow overrides + for (int i = sources.length - 1; 0 <= i; i--) { + String value = sources[i].get(key, aliases); + Map parsedMap = ConfigConverter.parseTraceTagsMap(value, ':', Arrays.asList(',', ' ')); + + if (!parsedMap.isEmpty()) { + if (collectConfig) { + seqId++; + ConfigCollector.get() + .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); + } + mergeResolver.addContribution(parsedMap, sources[i].origin()); + } + } - if (!parsedMap.isEmpty()) { if (collectConfig) { - seqId++; - ConfigCollector.get() - .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); + reportDefault(key, Collections.emptyMap()); + mergeResolver.reReportFinalResult(key, seqId); } - mergeResolver.addContribution(parsedMap, sources[i].origin()); - } - } - if (collectConfig) { - reportDefault(key, Collections.emptyMap()); - mergeResolver.reReportFinalResult(key, seqId); - } + return mergeResolver.getMergedValue(); + } + + public Map getOrderedMap(String key) { + // Use LinkedHashMap to preserve insertion order of map entries + ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new LinkedHashMap<>()); + int seqId = NON_DEFAULT_SEQ_ID; + + // System properties take precedence over env + // prior art: + // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html + // We reverse iterate to allow overrides + for (int i = sources.length - 1; 0 <= i; i--) { + String value = sources[i].get(key); + Map parsedMap = ConfigConverter.parseOrderedMap(value, key); + + if (!parsedMap.isEmpty()) { + if (collectConfig) { + seqId++; + ConfigCollector.get() + .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); + } + mergeResolver.addContribution(parsedMap, sources[i].origin()); + } + } - return mergeResolver.getMergedValue(); - } + if (collectConfig) { + reportDefault(key, Collections.emptyMap()); + mergeResolver.reReportFinalResult(key, seqId); + } - public Map getMergedTagsMap(String key, String... aliases) { - ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new HashMap<>()); - int seqId = NON_DEFAULT_SEQ_ID; + return mergeResolver.getMergedValue(); + } + + public Map getMergedMapWithOptionalMappings( + String defaultPrefix, boolean lowercaseKeys, String... keys) { + ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new HashMap<>()); + int seqId = NON_DEFAULT_SEQ_ID; + + // System properties take precedence over env + // prior art: + // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html + // We reverse iterate to allow overrides + for (String key : keys) { + for (int i = sources.length - 1; 0 <= i; i--) { + String value = sources[i].get(key); + Map parsedMap = + ConfigConverter.parseMapWithOptionalMappings(value, key, defaultPrefix, lowercaseKeys); + + if (!parsedMap.isEmpty()) { + if (collectConfig) { + seqId++; + ConfigCollector.get() + .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); + } + mergeResolver.addContribution(parsedMap, sources[i].origin()); + } + } + + if (collectConfig) { + reportDefault(key, Collections.emptyMap()); + mergeResolver.reReportFinalResult(key, seqId); + } + } - // System properties take precedence over env - // prior art: - // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html - // We reverse iterate to allow overrides - for (int i = sources.length - 1; 0 <= i; i--) { - String value = sources[i].get(key, aliases); - Map parsedMap = - ConfigConverter.parseTraceTagsMap(value, ':', Arrays.asList(',', ' ')); + return mergeResolver.getMergedValue(); + } - if (!parsedMap.isEmpty()) { + public BitSet getIntegerRange(final String key, final BitSet defaultValue, String... aliases) { + // Ensure the first item at DEFAULT is the most accurate one if (collectConfig) { - seqId++; - ConfigCollector.get() - .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); + reportDefault(key, defaultValue); + } + final String value = getStringInternal(key, aliases); + try { + if (value != null) { + return ConfigConverter.parseIntegerRangeSet(value, key); + } + } catch (final NumberFormatException e) { + log.warn("Invalid configuration for {}", key, e); } - mergeResolver.addContribution(parsedMap, sources[i].origin()); - } + return defaultValue; + } + + public boolean isEnabled( + final Iterable settingNames, + final String settingPrefix, + final String settingSuffix, + final boolean defaultEnabled) { + // If default is enabled, we want to disable individually. + // If default is disabled, we want to enable individually. + boolean anyEnabled = defaultEnabled; + for (final String name : settingNames) { + final String configKey = settingPrefix + name + settingSuffix; + final String fullKey = configKey.startsWith("trace.") ? configKey : "trace." + configKey; + final boolean configEnabled = getBoolean(fullKey, defaultEnabled, configKey); + if (defaultEnabled) { + anyEnabled &= configEnabled; + } else { + anyEnabled |= configEnabled; + } + } + return anyEnabled; } - if (collectConfig) { - reportDefault(key, Collections.emptyMap()); - mergeResolver.reReportFinalResult(key, seqId); + public static ConfigProvider getInstance() { + return Singleton.INSTANCE; } - return mergeResolver.getMergedValue(); - } - - public Map getOrderedMap(String key) { - // Use LinkedHashMap to preserve insertion order of map entries - ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new LinkedHashMap<>()); - int seqId = NON_DEFAULT_SEQ_ID; + public static ConfigProvider createDefault() { + Properties configProperties = loadConfigurationFile( + new ConfigProvider(new SystemPropertiesConfigSource(), new EnvironmentConfigSource())); + ConfigProvider.Source propertiesSource = + !configProperties.isEmpty() ? new PropertiesConfigSource(configProperties, true) : null; - // System properties take precedence over env - // prior art: - // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html - // We reverse iterate to allow overrides - for (int i = sources.length - 1; 0 <= i; i--) { - String value = sources[i].get(key); - Map parsedMap = ConfigConverter.parseOrderedMap(value, key); + Map ciEnvironmentVariables = CiEnvironmentVariables.getAll(); + ConfigProvider.Source ciEnvironmentSource = ciEnvironmentVariables != null + ? new MapConfigSource( + ciEnvironmentVariables, ConfigStrings::propertyNameToEnvironmentVariableName, ConfigOrigin.ENV) + : null; - if (!parsedMap.isEmpty()) { - if (collectConfig) { - seqId++; - ConfigCollector.get() - .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); + return new ConfigProvider(filterNonNull( + new SystemPropertiesConfigSource(), + StableConfigSource.FLEET, + ciEnvironmentSource, + new EnvironmentConfigSource(), + propertiesSource, + new OtelEnvironmentConfigSource(), + StableConfigSource.LOCAL, + new CapturedEnvironmentConfigSource())); + } + + private static ConfigProvider.Source[] filterNonNull(ConfigProvider.Source... values) { + return Arrays.stream(values).filter(Objects::nonNull).toArray(Source[]::new); + } + + public static ConfigProvider withoutCollector() { + Properties configProperties = loadConfigurationFile( + new ConfigProvider(false, new SystemPropertiesConfigSource(), new EnvironmentConfigSource())); + if (configProperties.isEmpty()) { + return new ConfigProvider( + false, + new SystemPropertiesConfigSource(), + StableConfigSource.FLEET, + new EnvironmentConfigSource(), + new OtelEnvironmentConfigSource(), + StableConfigSource.LOCAL, + new CapturedEnvironmentConfigSource()); + } else { + return new ConfigProvider( + false, + new SystemPropertiesConfigSource(), + StableConfigSource.FLEET, + new EnvironmentConfigSource(), + new PropertiesConfigSource(configProperties, true), + new OtelEnvironmentConfigSource(configProperties), + StableConfigSource.LOCAL, + new CapturedEnvironmentConfigSource()); } - mergeResolver.addContribution(parsedMap, sources[i].origin()); - } } - if (collectConfig) { - reportDefault(key, Collections.emptyMap()); - mergeResolver.reReportFinalResult(key, seqId); + public static ConfigProvider withPropertiesOverride(Properties properties) { + PropertiesConfigSource providedConfigSource = new PropertiesConfigSource(properties, false); + Properties configProperties = loadConfigurationFile(new ConfigProvider( + new SystemPropertiesConfigSource(), new EnvironmentConfigSource(), providedConfigSource)); + if (configProperties.isEmpty()) { + return new ConfigProvider( + new SystemPropertiesConfigSource(), + StableConfigSource.FLEET, + new EnvironmentConfigSource(), + providedConfigSource, + new OtelEnvironmentConfigSource(), + StableConfigSource.LOCAL, + new CapturedEnvironmentConfigSource()); + } else { + return new ConfigProvider( + providedConfigSource, + new SystemPropertiesConfigSource(), + StableConfigSource.FLEET, + new EnvironmentConfigSource(), + new PropertiesConfigSource(configProperties, true), + new OtelEnvironmentConfigSource(configProperties), + StableConfigSource.LOCAL, + new CapturedEnvironmentConfigSource()); + } } - return mergeResolver.getMergedValue(); - } + /** + * Loads the optional configuration properties file into the global {@link Properties} object. + * + * @return The {@link Properties} object. the returned instance might be empty of file does not + * exist or if it is in a wrong format. + * @param configProvider + */ + @SuppressForbidden + private static Properties loadConfigurationFile(ConfigProvider configProvider) { + final Properties properties = new Properties(); + + // Reading from system property first and from env after + String configurationFilePath = configProvider.getString(CONFIGURATION_FILE); + if (null == configurationFilePath) { + return properties; + } + + // Normalizing tilde (~) paths for unix systems + String home; + if (configurationFilePath.charAt(0) == '~' && (home = SystemProperties.get("user.home")) != null) { + configurationFilePath = home + configurationFilePath.substring(1); + } - public Map getMergedMapWithOptionalMappings( - String defaultPrefix, boolean lowercaseKeys, String... keys) { - ConfigMergeResolver mergeResolver = new ConfigMergeResolver(new HashMap<>()); - int seqId = NON_DEFAULT_SEQ_ID; + // Configuration properties file is optional + final File configurationFile = new File(configurationFilePath); + if (!configurationFile.exists()) { + log.error("Configuration file '{}' not found.", configurationFilePath); + return properties; + } - // System properties take precedence over env - // prior art: - // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html - // We reverse iterate to allow overrides - for (String key : keys) { - for (int i = sources.length - 1; 0 <= i; i--) { - String value = sources[i].get(key); - Map parsedMap = - ConfigConverter.parseMapWithOptionalMappings(value, key, defaultPrefix, lowercaseKeys); + try (final FileReader fileReader = new FileReader(configurationFile)) { + properties.load(fileReader); + } catch (final FileNotFoundException fnf) { + log.error("Configuration file '{}' not found.", configurationFilePath); + } catch (final IOException ioe) { + log.error("Configuration file '{}' cannot be accessed or correctly parsed.", configurationFilePath); + } - if (!parsedMap.isEmpty()) { - if (collectConfig) { - seqId++; - ConfigCollector.get() - .put(key, parsedMap, sources[i].origin(), seqId, getConfigIdFromSource(sources[i])); - } - mergeResolver.addContribution(parsedMap, sources[i].origin()); - } - } - - if (collectConfig) { - reportDefault(key, Collections.emptyMap()); - mergeResolver.reReportFinalResult(key, seqId); - } - } - - return mergeResolver.getMergedValue(); - } - - public BitSet getIntegerRange(final String key, final BitSet defaultValue, String... aliases) { - // Ensure the first item at DEFAULT is the most accurate one - if (collectConfig) { - reportDefault(key, defaultValue); - } - final String value = getStringInternal(key, aliases); - try { - if (value != null) { - return ConfigConverter.parseIntegerRangeSet(value, key); - } - } catch (final NumberFormatException e) { - log.warn("Invalid configuration for {}", key, e); - } - return defaultValue; - } - - public boolean isEnabled( - final Iterable settingNames, - final String settingPrefix, - final String settingSuffix, - final boolean defaultEnabled) { - // If default is enabled, we want to disable individually. - // If default is disabled, we want to enable individually. - boolean anyEnabled = defaultEnabled; - for (final String name : settingNames) { - final String configKey = settingPrefix + name + settingSuffix; - final String fullKey = configKey.startsWith("trace.") ? configKey : "trace." + configKey; - final boolean configEnabled = getBoolean(fullKey, defaultEnabled, configKey); - if (defaultEnabled) { - anyEnabled &= configEnabled; - } else { - anyEnabled |= configEnabled; - } - } - return anyEnabled; - } - - public static ConfigProvider getInstance() { - return Singleton.INSTANCE; - } - - public static ConfigProvider createDefault() { - Properties configProperties = - loadConfigurationFile( - new ConfigProvider(new SystemPropertiesConfigSource(), new EnvironmentConfigSource())); - ConfigProvider.Source propertiesSource = - !configProperties.isEmpty() ? new PropertiesConfigSource(configProperties, true) : null; - - Map ciEnvironmentVariables = CiEnvironmentVariables.getAll(); - ConfigProvider.Source ciEnvironmentSource = - ciEnvironmentVariables != null - ? new MapConfigSource( - ciEnvironmentVariables, - ConfigStrings::propertyNameToEnvironmentVariableName, - ConfigOrigin.ENV) - : null; - - return new ConfigProvider( - filterNonNull( - new SystemPropertiesConfigSource(), - StableConfigSource.FLEET, - ciEnvironmentSource, - new EnvironmentConfigSource(), - propertiesSource, - new OtelEnvironmentConfigSource(), - StableConfigSource.LOCAL, - new CapturedEnvironmentConfigSource())); - } - - private static ConfigProvider.Source[] filterNonNull(ConfigProvider.Source... values) { - return Arrays.stream(values).filter(Objects::nonNull).toArray(Source[]::new); - } - - public static ConfigProvider withoutCollector() { - Properties configProperties = - loadConfigurationFile( - new ConfigProvider( - false, new SystemPropertiesConfigSource(), new EnvironmentConfigSource())); - if (configProperties.isEmpty()) { - return new ConfigProvider( - false, - new SystemPropertiesConfigSource(), - StableConfigSource.FLEET, - new EnvironmentConfigSource(), - new OtelEnvironmentConfigSource(), - StableConfigSource.LOCAL, - new CapturedEnvironmentConfigSource()); - } else { - return new ConfigProvider( - false, - new SystemPropertiesConfigSource(), - StableConfigSource.FLEET, - new EnvironmentConfigSource(), - new PropertiesConfigSource(configProperties, true), - new OtelEnvironmentConfigSource(configProperties), - StableConfigSource.LOCAL, - new CapturedEnvironmentConfigSource()); - } - } - - public static ConfigProvider withPropertiesOverride(Properties properties) { - PropertiesConfigSource providedConfigSource = new PropertiesConfigSource(properties, false); - Properties configProperties = - loadConfigurationFile( - new ConfigProvider( - new SystemPropertiesConfigSource(), - new EnvironmentConfigSource(), - providedConfigSource)); - if (configProperties.isEmpty()) { - return new ConfigProvider( - new SystemPropertiesConfigSource(), - StableConfigSource.FLEET, - new EnvironmentConfigSource(), - providedConfigSource, - new OtelEnvironmentConfigSource(), - StableConfigSource.LOCAL, - new CapturedEnvironmentConfigSource()); - } else { - return new ConfigProvider( - providedConfigSource, - new SystemPropertiesConfigSource(), - StableConfigSource.FLEET, - new EnvironmentConfigSource(), - new PropertiesConfigSource(configProperties, true), - new OtelEnvironmentConfigSource(configProperties), - StableConfigSource.LOCAL, - new CapturedEnvironmentConfigSource()); - } - } - - /** - * Loads the optional configuration properties file into the global {@link Properties} object. - * - * @return The {@link Properties} object. the returned instance might be empty of file does not - * exist or if it is in a wrong format. - * @param configProvider - */ - @SuppressForbidden - private static Properties loadConfigurationFile(ConfigProvider configProvider) { - final Properties properties = new Properties(); - - // Reading from system property first and from env after - String configurationFilePath = configProvider.getString(CONFIGURATION_FILE); - if (null == configurationFilePath) { - return properties; - } - - // Normalizing tilde (~) paths for unix systems - String home; - if (configurationFilePath.charAt(0) == '~' - && (home = SystemProperties.get("user.home")) != null) { - configurationFilePath = home + configurationFilePath.substring(1); - } - - // Configuration properties file is optional - final File configurationFile = new File(configurationFilePath); - if (!configurationFile.exists()) { - log.error("Configuration file '{}' not found.", configurationFilePath); - return properties; - } - - try (final FileReader fileReader = new FileReader(configurationFile)) { - properties.load(fileReader); - } catch (final FileNotFoundException fnf) { - log.error("Configuration file '{}' not found.", configurationFilePath); - } catch (final IOException ioe) { - log.error( - "Configuration file '{}' cannot be accessed or correctly parsed.", configurationFilePath); - } - - properties.setProperty(PropertiesConfigSource.CONFIG_FILE_STATUS, configurationFilePath); - - return properties; - } - - private static String getConfigIdFromSource(Source source) { - if (source instanceof StableConfigSource) { - return ((StableConfigSource) source).getConfigId(); - } - return null; - } - - private static void reportDefault(String key, T defaultValue) { - ConfigCollector.get().putDefault(key, defaultValue); - } - - /** Helper class to store resolved configuration values with their metadata */ - static final class ConfigValueResolver { - final T value; - final ConfigOrigin origin; - final int seqId; - final String configId; - - // Single constructor that takes all fields - private ConfigValueResolver(T value, ConfigOrigin origin, int seqId, String configId) { - this.value = value; - this.origin = origin; - this.seqId = seqId; - this.configId = configId; - } - - // Factory method for cases where we only care about the value (e.g., getString) - static ConfigValueResolver of(T value) { - return new ConfigValueResolver<>(value, null, ABSENT_SEQ_ID, null); - } - - // Factory method for cases where we need to re-report (e.g., getStringNotEmpty, get) - static ConfigValueResolver of(T value, ConfigOrigin origin, int seqId, String configId) { - return new ConfigValueResolver<>(value, origin, seqId, configId); - } - - /** Re-reports this resolved value to ConfigCollector with the specified seqId */ - void reReportToCollector(String key, int finalSeqId) { - // Value should never be null if there is an initialized ConfigValueResolver - if (origin != null) { - ConfigCollector.get().put(key, value, origin, finalSeqId, configId); - } + properties.setProperty(PropertiesConfigSource.CONFIG_FILE_STATUS, configurationFilePath); + + return properties; } - } - /** Helper class for methods that merge map values from multiple sources (e.g., getMergedMap) */ - private static final class ConfigMergeResolver { - private final Map mergedValue; - private ConfigOrigin currentOrigin; + private static String getConfigIdFromSource(Source source) { + if (source instanceof StableConfigSource) { + return ((StableConfigSource) source).getConfigId(); + } + return null; + } - ConfigMergeResolver(Map initialValue) { - this.mergedValue = initialValue; - this.currentOrigin = ConfigOrigin.DEFAULT; + private static void reportDefault(String key, T defaultValue) { + ConfigCollector.get().putDefault(key, defaultValue); } - /** Adds a contribution from a source and updates the origin tracking */ - void addContribution(Map contribution, ConfigOrigin sourceOrigin) { - mergedValue.putAll(contribution); + /** Helper class to store resolved configuration values with their metadata */ + static final class ConfigValueResolver { + final T value; + final ConfigOrigin origin; + final int seqId; + final String configId; - // Update origin: DEFAULT -> source origin -> CALCULATED if multiple sources - if (currentOrigin != ConfigOrigin.DEFAULT) { - // if we already have a non-default origin, the value is calculated from multiple sources - currentOrigin = ConfigOrigin.CALCULATED; - } else { - currentOrigin = sourceOrigin; - } - } + // Single constructor that takes all fields + private ConfigValueResolver(T value, ConfigOrigin origin, int seqId, String configId) { + this.value = value; + this.origin = origin; + this.seqId = seqId; + this.configId = configId; + } - /** - * Re-reports the final merged result to ConfigCollector if it has actual contributions. Does - * NOT re-report when no contributions were made since defaults are reported separately. - */ - void reReportFinalResult(String key, int finalSeqId) { - if (currentOrigin != ConfigOrigin.DEFAULT && !mergedValue.isEmpty()) { - ConfigCollector.get().put(key, mergedValue, currentOrigin, finalSeqId); - } - } + // Factory method for cases where we only care about the value (e.g., getString) + static ConfigValueResolver of(T value) { + return new ConfigValueResolver<>(value, null, ABSENT_SEQ_ID, null); + } + + // Factory method for cases where we need to re-report (e.g., getStringNotEmpty, get) + static ConfigValueResolver of(T value, ConfigOrigin origin, int seqId, String configId) { + return new ConfigValueResolver<>(value, origin, seqId, configId); + } - /** Gets the final merged value */ - Map getMergedValue() { - return mergedValue; + /** Re-reports this resolved value to ConfigCollector with the specified seqId */ + void reReportToCollector(String key, int finalSeqId) { + // Value should never be null if there is an initialized ConfigValueResolver + if (origin != null) { + ConfigCollector.get().put(key, value, origin, finalSeqId, configId); + } + } } - } - public abstract static class Source { - public final String get(String key, String... aliases) { - String value = get(key); - if (value != null) { - return value; - } - for (String alias : aliases) { - value = get(alias); - if (value != null) { - return value; + /** Helper class for methods that merge map values from multiple sources (e.g., getMergedMap) */ + private static final class ConfigMergeResolver { + private final Map mergedValue; + private ConfigOrigin currentOrigin; + + ConfigMergeResolver(Map initialValue) { + this.mergedValue = initialValue; + this.currentOrigin = ConfigOrigin.DEFAULT; + } + + /** Adds a contribution from a source and updates the origin tracking */ + void addContribution(Map contribution, ConfigOrigin sourceOrigin) { + mergedValue.putAll(contribution); + + // Update origin: DEFAULT -> source origin -> CALCULATED if multiple sources + if (currentOrigin != ConfigOrigin.DEFAULT) { + // if we already have a non-default origin, the value is calculated from multiple sources + currentOrigin = ConfigOrigin.CALCULATED; + } else { + currentOrigin = sourceOrigin; + } + } + + /** + * Re-reports the final merged result to ConfigCollector if it has actual contributions. Does + * NOT re-report when no contributions were made since defaults are reported separately. + */ + void reReportFinalResult(String key, int finalSeqId) { + if (currentOrigin != ConfigOrigin.DEFAULT && !mergedValue.isEmpty()) { + ConfigCollector.get().put(key, mergedValue, currentOrigin, finalSeqId); + } + } + + /** Gets the final merged value */ + Map getMergedValue() { + return mergedValue; } - } - return null; } - protected abstract String get(String key); + public abstract static class Source { + public final String get(String key, String... aliases) { + String value = get(key); + if (value != null) { + return value; + } + for (String alias : aliases) { + value = get(alias); + if (value != null) { + return value; + } + } + return null; + } + + protected abstract String get(String key); - public abstract ConfigOrigin origin(); - } + public abstract ConfigOrigin origin(); + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java index 3c34ed15c38..1ce5bfde79e 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java @@ -7,13 +7,13 @@ import datadog.trace.config.inversion.ConfigHelper; final class EnvironmentConfigSource extends ConfigProvider.Source { - @Override - protected String get(String key) { - return ConfigHelper.env(propertyNameToEnvironmentVariableName(key)); - } + @Override + protected String get(String key) { + return ConfigHelper.env(propertyNameToEnvironmentVariableName(key)); + } - @Override - public ConfigOrigin origin() { - return ENV; - } + @Override + public ConfigOrigin origin() { + return ENV; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/MapConfigSource.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/MapConfigSource.java index 00490b50819..240e5b59d12 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/MapConfigSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/MapConfigSource.java @@ -6,26 +6,23 @@ final class MapConfigSource extends ConfigProvider.Source { - private final Map properties; - private final Function keyTransformer; - private final ConfigOrigin origin; + private final Map properties; + private final Function keyTransformer; + private final ConfigOrigin origin; - MapConfigSource( - Map properties, - Function keyTransformer, - ConfigOrigin origin) { - this.properties = properties; - this.keyTransformer = keyTransformer; - this.origin = origin; - } + MapConfigSource(Map properties, Function keyTransformer, ConfigOrigin origin) { + this.properties = properties; + this.keyTransformer = keyTransformer; + this.origin = origin; + } - @Override - protected String get(String key) { - return properties.get(keyTransformer.apply(key)); - } + @Override + protected String get(String key) { + return properties.get(keyTransformer.apply(key)); + } - @Override - public ConfigOrigin origin() { - return origin; - } + @Override + public ConfigOrigin origin() { + return origin; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/OtelEnvironmentConfigSource.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/OtelEnvironmentConfigSource.java index 67dd95eab2d..34c899dc32e 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/OtelEnvironmentConfigSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/OtelEnvironmentConfigSource.java @@ -70,522 +70,482 @@ /** Maps OpenTelemetry system properties and environment variables to their Datadog equivalents. */ final class OtelEnvironmentConfigSource extends ConfigProvider.Source { - private static final Logger log = LoggerFactory.getLogger(OtelEnvironmentConfigSource.class); + private static final Logger log = LoggerFactory.getLogger(OtelEnvironmentConfigSource.class); - private final boolean enabled; + private final boolean enabled; - private final Map otelEnvironment = new HashMap<>(); + private final Map otelEnvironment = new HashMap<>(); - private final Properties otelConfigFile = loadOtelConfigFile(); + private final Properties otelConfigFile = loadOtelConfigFile(); - private final Properties datadogConfigFile; + private final Properties datadogConfigFile; - @Override - protected String get(String key) { - if (!enabled) { - return null; - } + @Override + protected String get(String key) { + if (!enabled) { + return null; + } - String value = otelEnvironment.get(key); - if (null == value && key.startsWith("otel.")) { - value = getOtelProperty(key); + String value = otelEnvironment.get(key); + if (null == value && key.startsWith("otel.")) { + value = getOtelProperty(key); + } + return value; } - return value; - } - @Override - public ConfigOrigin origin() { - return ConfigOrigin.ENV; - } + @Override + public ConfigOrigin origin() { + return ConfigOrigin.ENV; + } - OtelEnvironmentConfigSource() { - this(null); - } + OtelEnvironmentConfigSource() { + this(null); + } - OtelEnvironmentConfigSource(Properties datadogConfigFile) { - this.datadogConfigFile = datadogConfigFile; - this.enabled = traceOtelEnabled() || metricsOtelEnabled() || logsOtelEnabled(); + OtelEnvironmentConfigSource(Properties datadogConfigFile) { + this.datadogConfigFile = datadogConfigFile; + this.enabled = traceOtelEnabled() || metricsOtelEnabled() || logsOtelEnabled(); - if (enabled) { - setupOtelEnvironment(); + if (enabled) { + setupOtelEnvironment(); + } } - } - private void setupOtelEnvironment() { + private void setupOtelEnvironment() { - // only applies when OTEL is enabled by default - String sdkDisabled = getOtelProperty("otel.sdk.disabled", "dd." + TRACE_OTEL_ENABLED); - if ("true".equalsIgnoreCase(sdkDisabled)) { - capture(TRACE_OTEL_ENABLED, "false"); - capture(METRICS_OTEL_ENABLED, "false"); - capture(LOGS_OTEL_ENABLED, "false"); - return; - } - String logLevel = getOtelProperty("otel.log.level", "dd." + LOG_LEVEL); - String resourceAttributes = getOtelProperty("otel.resource.attributes", "dd." + TAGS); - String serviceName = getOtelProperty("otel.service.name", "dd." + SERVICE_NAME); - if (null != resourceAttributes) { - Map attributeMap = parseOtelMap(resourceAttributes); - capture(SERVICE_NAME, attributeMap.remove("service.name")); - capture(VERSION, attributeMap.remove("service.version")); - String environment = attributeMap.remove("deployment.environment"); - String namedEnvironment = attributeMap.remove("deployment.environment.name"); - capture(ENV, namedEnvironment != null ? namedEnvironment : environment); - capture(TAGS, renderDatadogMap(attributeMap, 10)); - } - capture(LOG_LEVEL, logLevel); - capture(SERVICE_NAME, serviceName); + // only applies when OTEL is enabled by default + String sdkDisabled = getOtelProperty("otel.sdk.disabled", "dd." + TRACE_OTEL_ENABLED); + if ("true".equalsIgnoreCase(sdkDisabled)) { + capture(TRACE_OTEL_ENABLED, "false"); + capture(METRICS_OTEL_ENABLED, "false"); + capture(LOGS_OTEL_ENABLED, "false"); + return; + } + String logLevel = getOtelProperty("otel.log.level", "dd." + LOG_LEVEL); + String resourceAttributes = getOtelProperty("otel.resource.attributes", "dd." + TAGS); + String serviceName = getOtelProperty("otel.service.name", "dd." + SERVICE_NAME); + if (null != resourceAttributes) { + Map attributeMap = parseOtelMap(resourceAttributes); + capture(SERVICE_NAME, attributeMap.remove("service.name")); + capture(VERSION, attributeMap.remove("service.version")); + String environment = attributeMap.remove("deployment.environment"); + String namedEnvironment = attributeMap.remove("deployment.environment.name"); + capture(ENV, namedEnvironment != null ? namedEnvironment : environment); + capture(TAGS, renderDatadogMap(attributeMap, 10)); + } + capture(LOG_LEVEL, logLevel); + capture(SERVICE_NAME, serviceName); - if (traceOtelEnabled()) { - setupTraceOtelEnvironment(); - } + if (traceOtelEnabled()) { + setupTraceOtelEnvironment(); + } - if (metricsOtelEnabled()) { - setupMetricsOtelEnvironment(); - } else { - capture(RUNTIME_METRICS_ENABLED, mapDataCollection("metrics")); - } + if (metricsOtelEnabled()) { + setupMetricsOtelEnvironment(); + } else { + capture(RUNTIME_METRICS_ENABLED, mapDataCollection("metrics")); + } - if (logsOtelEnabled()) { - setupLogsOtelEnvironment(); - } else { - mapDataCollection("logs"); - } - } - - private void setupTraceOtelEnvironment() { - String propagators = getOtelProperty("otel.propagators", "dd." + TRACE_PROPAGATION_STYLE); - String tracesSampler = getOtelProperty("otel.traces.sampler", "dd." + TRACE_SAMPLE_RATE); - String requestHeaders = getOtelHeaders("request-headers", "dd." + REQUEST_HEADER_TAGS); - String responseHeaders = getOtelHeaders("response-headers", "dd." + RESPONSE_HEADER_TAGS); - String extensions = getOtelProperty("otel.javaagent.extensions", "dd." + TRACE_EXTENSIONS_PATH); - capture(TRACE_PROPAGATION_STYLE, mapPropagationStyle(propagators)); - capture(TRACE_SAMPLE_RATE, mapSampleRate(tracesSampler)); - capture(REQUEST_HEADER_TAGS, mapHeaderTags("http.request.header.", requestHeaders)); - capture(RESPONSE_HEADER_TAGS, mapHeaderTags("http.response.header.", responseHeaders)); - capture(TRACE_EXTENSIONS_PATH, extensions); - capture( - OTEL_TRACES_SPAN_METRICS_ENABLED, - getOtelProperty( - "otel.traces.span.metrics.enabled", "dd." + OTEL_TRACES_SPAN_METRICS_ENABLED)); - - String exporter = getOtelProperty("otel.traces.exporter"); - if ("otlp".equalsIgnoreCase(exporter)) { // traces defaults to non-OTLP (i.e. datadog) - capture(TRACE_OTEL_EXPORTER, "otlp"); - capture( - OTLP_TRACES_HEADERS, - getOtelOtlpProperty("traces", "headers", "dd." + OTLP_TRACES_HEADERS)); - capture( - OTLP_TRACES_PROTOCOL, - getOtelOtlpProperty("traces", "protocol", "dd." + OTLP_TRACES_PROTOCOL)); - capture( - OTLP_TRACES_COMPRESSION, - getOtelOtlpProperty("traces", "compression", "dd." + OTLP_TRACES_COMPRESSION)); - capture( - OTLP_TRACES_TIMEOUT, - getOtelOtlpProperty("traces", "timeout", "dd." + OTLP_TRACES_TIMEOUT)); - capture( - OTLP_TRACES_ENDPOINT, - getOtelOtlpProperty("traces", "endpoint", "dd." + OTLP_TRACES_ENDPOINT)); - } else { - capture(TRACE_ENABLED, mapDataCollection("traces")); - } - } - - private void setupMetricsOtelEnvironment() { - capture( - METRICS_OTEL_INTERVAL, - getOtelProperty("otel.metric.export.interval", "dd." + METRICS_OTEL_INTERVAL)); - capture( - METRICS_OTEL_TIMEOUT, - getOtelProperty("otel.metric.export.timeout", "dd." + METRICS_OTEL_TIMEOUT)); - capture( - METRICS_OTEL_CARDINALITY_LIMIT, - getOtelProperty( - "otel.java.metrics.cardinality.limit", "dd." + METRICS_OTEL_CARDINALITY_LIMIT)); - capture( - METRICS_OTEL_EXPERIMENTAL_ENABLED, - getOtelProperty( - "otel.instrumentation.runtime-telemetry.emit-experimental-metrics", - "dd." + METRICS_OTEL_EXPERIMENTAL_ENABLED)); - - String exporter = getOtelProperty("otel.metrics.exporter"); - if (exporter == null || "otlp".equalsIgnoreCase(exporter)) { // metrics defaults to OTLP - capture(METRICS_OTEL_EXPORTER, "otlp"); - capture( - OTLP_METRICS_HEADERS, - getOtelOtlpProperty("metrics", "headers", "dd." + OTLP_METRICS_HEADERS)); - capture( - OTLP_METRICS_PROTOCOL, - getOtelOtlpProperty("metrics", "protocol", "dd." + OTLP_METRICS_PROTOCOL)); - capture( - OTLP_METRICS_COMPRESSION, - getOtelOtlpProperty("metrics", "compression", "dd." + OTLP_METRICS_COMPRESSION)); - capture( - OTLP_METRICS_TIMEOUT, - getOtelOtlpProperty("metrics", "timeout", "dd." + OTLP_METRICS_TIMEOUT)); - capture( - OTLP_METRICS_ENDPOINT, - getOtelOtlpProperty("metrics", "endpoint", "dd." + OTLP_METRICS_ENDPOINT)); - capture( - OTLP_METRICS_TEMPORALITY_PREFERENCE, - getOtelProperty( - "otel.exporter.otlp.metrics.temporality.preference", - "dd." + OTLP_METRICS_TEMPORALITY_PREFERENCE)); - } else { - capture(RUNTIME_METRICS_ENABLED, mapDataCollection("metrics")); - } - } - - private void setupLogsOtelEnvironment() { - capture( - LOGS_OTEL_INTERVAL, - getOtelProperty("otel.blrp.schedule.delay", "dd." + LOGS_OTEL_INTERVAL)); - capture( - LOGS_OTEL_TIMEOUT, getOtelProperty("otel.blrp.export.timeout", "dd." + LOGS_OTEL_TIMEOUT)); - capture( - LOGS_OTEL_QUEUE_SIZE, - getOtelProperty("otel.blrp.max.queue.size", "dd." + LOGS_OTEL_QUEUE_SIZE)); - capture( - LOGS_OTEL_BATCH_SIZE, - getOtelProperty("otel.blrp.max.export.batch.size", "dd." + LOGS_OTEL_BATCH_SIZE)); - String exporter = getOtelProperty("otel.logs.exporter"); - if (exporter == null || "otlp".equalsIgnoreCase(exporter)) { // logs defaults to OTLP - capture(LOGS_OTEL_EXPORTER, "otlp"); - capture(OTLP_LOGS_HEADERS, getOtelOtlpProperty("logs", "headers", "dd." + OTLP_LOGS_HEADERS)); - capture( - OTLP_LOGS_PROTOCOL, getOtelOtlpProperty("logs", "protocol", "dd." + OTLP_LOGS_PROTOCOL)); - capture( - OTLP_LOGS_COMPRESSION, - getOtelOtlpProperty("logs", "compression", "dd." + OTLP_LOGS_COMPRESSION)); - capture(OTLP_LOGS_TIMEOUT, getOtelOtlpProperty("logs", "timeout", "dd." + OTLP_LOGS_TIMEOUT)); - capture( - OTLP_LOGS_ENDPOINT, getOtelOtlpProperty("logs", "endpoint", "dd." + OTLP_LOGS_ENDPOINT)); - } else { - mapDataCollection("logs"); - } - } - - private boolean traceOtelEnabled() { - String enabled = getDatadogProperty("dd." + TRACE_OTEL_ENABLED); - if (null != enabled) { - return Boolean.parseBoolean(enabled); - } else { - return DEFAULT_TRACE_OTEL_ENABLED; - } - } - - private boolean metricsOtelEnabled() { - String enabled = getDatadogProperty("dd." + METRICS_OTEL_ENABLED); - if (null != enabled) { - return Boolean.parseBoolean(enabled); - } else { - return DEFAULT_METRICS_OTEL_ENABLED; - } - } - - private boolean logsOtelEnabled() { - String enabled = getDatadogProperty("dd." + LOGS_OTEL_ENABLED); - if (null != enabled) { - return Boolean.parseBoolean(enabled); - } else { - return DEFAULT_LOGS_OTEL_ENABLED; - } - } - - /** - * Gets an OpenTelemetry property. - * - *

Checks system properties, environment variables, and the optional OpenTelemetry config file. - * If the equivalent Datadog property is also set then log a warning and return {@code null}. - */ - private String getOtelProperty(String otelSysProp, String ddSysProp) { - String otelValue = getOtelProperty(otelSysProp); - if (null == otelValue) { - return null; - } - String ddValue = getDatadogProperty(ddSysProp); - if (null != ddValue) { - String otelEnvVar = toEnvVar(otelSysProp); - log.warn("Both {} and {} are set, ignoring {}", toEnvVar(ddSysProp), otelEnvVar, otelEnvVar); - OtelEnvMetricCollectorProvider.get() - .setHidingOtelEnvVarMetric(toEnvVarLowerCase(otelSysProp), toEnvVarLowerCase(ddSysProp)); - return null; - } - return otelValue; - } - - /** - * Gets an OpenTelemetry property. - * - *

Checks system properties, environment variables, and the optional OpenTelemetry config file. - */ - private String getOtelProperty(String sysProp) { - String value = getProperty(sysProp); - if (null == value && null != otelConfigFile) { - value = otelConfigFile.getProperty(sysProp); - } - return value; - } - - /** - * Gets an OpenTelemetry OTLP property, checking the signal specific key before the general one. - * - *

Checks system properties, environment variables, and the optional OpenTelemetry config file. - * If the equivalent Datadog property is also set then log a warning and return {@code null}. - */ - private String getOtelOtlpProperty(String signal, String subkey, String ddSysProp) { - String otelKey = "otel.exporter.otlp." + signal + "." + subkey; - String otelValue = getOtelProperty(otelKey); - if (null == otelValue) { - // fall back to general configuration - otelKey = "otel.exporter.otlp." + subkey; - otelValue = getOtelProperty(otelKey); - // special case when using general endpoint as fallback: append appropriate suffix - if ("endpoint".equals(subkey) && otelValue != null && !otelValue.startsWith("unix://")) { - if ("logs".equals(signal) - && !"grpc".equalsIgnoreCase(otelEnvironment.get(OTLP_LOGS_PROTOCOL))) { - otelValue = otelValue + (otelValue.endsWith("/") ? "v1/logs" : "/v1/logs"); - } - if ("metrics".equals(signal) - && !"grpc".equalsIgnoreCase(otelEnvironment.get(OTLP_METRICS_PROTOCOL))) { - otelValue = otelValue + (otelValue.endsWith("/") ? "v1/metrics" : "/v1/metrics"); - } - if ("traces".equals(signal) - && !"grpc".equalsIgnoreCase(otelEnvironment.get(OTLP_TRACES_PROTOCOL))) { - otelValue = otelValue + (otelValue.endsWith("/") ? "v1/traces" : "/v1/traces"); - } - } + if (logsOtelEnabled()) { + setupLogsOtelEnvironment(); + } else { + mapDataCollection("logs"); + } } - if (null == otelValue) { - return null; + + private void setupTraceOtelEnvironment() { + String propagators = getOtelProperty("otel.propagators", "dd." + TRACE_PROPAGATION_STYLE); + String tracesSampler = getOtelProperty("otel.traces.sampler", "dd." + TRACE_SAMPLE_RATE); + String requestHeaders = getOtelHeaders("request-headers", "dd." + REQUEST_HEADER_TAGS); + String responseHeaders = getOtelHeaders("response-headers", "dd." + RESPONSE_HEADER_TAGS); + String extensions = getOtelProperty("otel.javaagent.extensions", "dd." + TRACE_EXTENSIONS_PATH); + capture(TRACE_PROPAGATION_STYLE, mapPropagationStyle(propagators)); + capture(TRACE_SAMPLE_RATE, mapSampleRate(tracesSampler)); + capture(REQUEST_HEADER_TAGS, mapHeaderTags("http.request.header.", requestHeaders)); + capture(RESPONSE_HEADER_TAGS, mapHeaderTags("http.response.header.", responseHeaders)); + capture(TRACE_EXTENSIONS_PATH, extensions); + capture( + OTEL_TRACES_SPAN_METRICS_ENABLED, + getOtelProperty("otel.traces.span.metrics.enabled", "dd." + OTEL_TRACES_SPAN_METRICS_ENABLED)); + + String exporter = getOtelProperty("otel.traces.exporter"); + if ("otlp".equalsIgnoreCase(exporter)) { // traces defaults to non-OTLP (i.e. datadog) + capture(TRACE_OTEL_EXPORTER, "otlp"); + capture(OTLP_TRACES_HEADERS, getOtelOtlpProperty("traces", "headers", "dd." + OTLP_TRACES_HEADERS)); + capture(OTLP_TRACES_PROTOCOL, getOtelOtlpProperty("traces", "protocol", "dd." + OTLP_TRACES_PROTOCOL)); + capture( + OTLP_TRACES_COMPRESSION, + getOtelOtlpProperty("traces", "compression", "dd." + OTLP_TRACES_COMPRESSION)); + capture(OTLP_TRACES_TIMEOUT, getOtelOtlpProperty("traces", "timeout", "dd." + OTLP_TRACES_TIMEOUT)); + capture(OTLP_TRACES_ENDPOINT, getOtelOtlpProperty("traces", "endpoint", "dd." + OTLP_TRACES_ENDPOINT)); + } else { + capture(TRACE_ENABLED, mapDataCollection("traces")); + } } - String ddValue = getDatadogProperty(ddSysProp); - if (null != ddValue) { - String otelEnvVar = toEnvVar(otelKey); - log.warn("Both {} and {} are set, ignoring {}", toEnvVar(ddSysProp), otelEnvVar, otelEnvVar); - OtelEnvMetricCollectorProvider.get() - .setHidingOtelEnvVarMetric(toEnvVarLowerCase(otelKey), toEnvVarLowerCase(ddSysProp)); - return null; + + private void setupMetricsOtelEnvironment() { + capture(METRICS_OTEL_INTERVAL, getOtelProperty("otel.metric.export.interval", "dd." + METRICS_OTEL_INTERVAL)); + capture(METRICS_OTEL_TIMEOUT, getOtelProperty("otel.metric.export.timeout", "dd." + METRICS_OTEL_TIMEOUT)); + capture( + METRICS_OTEL_CARDINALITY_LIMIT, + getOtelProperty("otel.java.metrics.cardinality.limit", "dd." + METRICS_OTEL_CARDINALITY_LIMIT)); + capture( + METRICS_OTEL_EXPERIMENTAL_ENABLED, + getOtelProperty( + "otel.instrumentation.runtime-telemetry.emit-experimental-metrics", + "dd." + METRICS_OTEL_EXPERIMENTAL_ENABLED)); + + String exporter = getOtelProperty("otel.metrics.exporter"); + if (exporter == null || "otlp".equalsIgnoreCase(exporter)) { // metrics defaults to OTLP + capture(METRICS_OTEL_EXPORTER, "otlp"); + capture(OTLP_METRICS_HEADERS, getOtelOtlpProperty("metrics", "headers", "dd." + OTLP_METRICS_HEADERS)); + capture(OTLP_METRICS_PROTOCOL, getOtelOtlpProperty("metrics", "protocol", "dd." + OTLP_METRICS_PROTOCOL)); + capture( + OTLP_METRICS_COMPRESSION, + getOtelOtlpProperty("metrics", "compression", "dd." + OTLP_METRICS_COMPRESSION)); + capture(OTLP_METRICS_TIMEOUT, getOtelOtlpProperty("metrics", "timeout", "dd." + OTLP_METRICS_TIMEOUT)); + capture(OTLP_METRICS_ENDPOINT, getOtelOtlpProperty("metrics", "endpoint", "dd." + OTLP_METRICS_ENDPOINT)); + capture( + OTLP_METRICS_TEMPORALITY_PREFERENCE, + getOtelProperty( + "otel.exporter.otlp.metrics.temporality.preference", + "dd." + OTLP_METRICS_TEMPORALITY_PREFERENCE)); + } else { + capture(RUNTIME_METRICS_ENABLED, mapDataCollection("metrics")); + } } - return otelValue; - } - - /** - * Gets a Datadog property. - * - *

Checks system properties, environment variables, and the optional Datadog config file. - */ - private String getDatadogProperty(String sysProp) { - String value = getProperty(sysProp); - if (null == value && null != datadogConfigFile) { - value = datadogConfigFile.getProperty(sysProp); + + private void setupLogsOtelEnvironment() { + capture(LOGS_OTEL_INTERVAL, getOtelProperty("otel.blrp.schedule.delay", "dd." + LOGS_OTEL_INTERVAL)); + capture(LOGS_OTEL_TIMEOUT, getOtelProperty("otel.blrp.export.timeout", "dd." + LOGS_OTEL_TIMEOUT)); + capture(LOGS_OTEL_QUEUE_SIZE, getOtelProperty("otel.blrp.max.queue.size", "dd." + LOGS_OTEL_QUEUE_SIZE)); + capture(LOGS_OTEL_BATCH_SIZE, getOtelProperty("otel.blrp.max.export.batch.size", "dd." + LOGS_OTEL_BATCH_SIZE)); + String exporter = getOtelProperty("otel.logs.exporter"); + if (exporter == null || "otlp".equalsIgnoreCase(exporter)) { // logs defaults to OTLP + capture(LOGS_OTEL_EXPORTER, "otlp"); + capture(OTLP_LOGS_HEADERS, getOtelOtlpProperty("logs", "headers", "dd." + OTLP_LOGS_HEADERS)); + capture(OTLP_LOGS_PROTOCOL, getOtelOtlpProperty("logs", "protocol", "dd." + OTLP_LOGS_PROTOCOL)); + capture(OTLP_LOGS_COMPRESSION, getOtelOtlpProperty("logs", "compression", "dd." + OTLP_LOGS_COMPRESSION)); + capture(OTLP_LOGS_TIMEOUT, getOtelOtlpProperty("logs", "timeout", "dd." + OTLP_LOGS_TIMEOUT)); + capture(OTLP_LOGS_ENDPOINT, getOtelOtlpProperty("logs", "endpoint", "dd." + OTLP_LOGS_ENDPOINT)); + } else { + mapDataCollection("logs"); + } } - return value; - } - - /** - * Gets a general property. - * - *

Checks system properties and environment variables. - */ - private static String getProperty(String sysProp) { - // Always validate through ConfigHelper so STRICT_TEST mode can detect unsupported configs - String value = ConfigHelper.env(toEnvVar(sysProp)); - // System property takes precedence over environment variable - String sysPropValue = SystemProperties.get(sysProp); - if (sysPropValue != null) { - value = sysPropValue; + + private boolean traceOtelEnabled() { + String enabled = getDatadogProperty("dd." + TRACE_OTEL_ENABLED); + if (null != enabled) { + return Boolean.parseBoolean(enabled); + } else { + return DEFAULT_TRACE_OTEL_ENABLED; + } } - return value; - } - /** Captures a mapped OpenTelemetry property. */ - private void capture(String key, String value) { - if (null != value) { - otelEnvironment.put(key, value); + private boolean metricsOtelEnabled() { + String enabled = getDatadogProperty("dd." + METRICS_OTEL_ENABLED); + if (null != enabled) { + return Boolean.parseBoolean(enabled); + } else { + return DEFAULT_METRICS_OTEL_ENABLED; + } } - } - - /** Loads the optional OpenTelemetry configuration file. */ - private static Properties loadOtelConfigFile() { - String path = getProperty("otel.javaagent.configuration-file"); - if (null != path && !path.isEmpty()) { - // Inflate '~' prefix as home folder - String home; - if (path.charAt(0) == '~' && (home = SystemProperties.get("user.home")) != null) { - path = home + path.substring(1); - } - File file = new File(path); - if (file.exists()) { - try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { - Properties properties = new Properties(); - properties.load(in); - return properties; - } catch (Throwable e) { - log.warn("Problem reading OTEL_JAVAAGENT_CONFIGURATION_FILE {} - {}", path, e.toString()); - } - } + + private boolean logsOtelEnabled() { + String enabled = getDatadogProperty("dd." + LOGS_OTEL_ENABLED); + if (null != enabled) { + return Boolean.parseBoolean(enabled); + } else { + return DEFAULT_LOGS_OTEL_ENABLED; + } } - return null; - } - - /** Parses a comma-separated list of items. */ - private static List parseOtelList(String value) { - List list = new ArrayList<>(); - int start = 0; - while (start < value.length()) { - int end = value.indexOf(',', start); - if (end < 0) { - end = value.length(); - } - if (end > start) { - list.add(value.substring(start, end)); - } - start = end + 1; + + /** + * Gets an OpenTelemetry property. + * + *

Checks system properties, environment variables, and the optional OpenTelemetry config file. + * If the equivalent Datadog property is also set then log a warning and return {@code null}. + */ + private String getOtelProperty(String otelSysProp, String ddSysProp) { + String otelValue = getOtelProperty(otelSysProp); + if (null == otelValue) { + return null; + } + String ddValue = getDatadogProperty(ddSysProp); + if (null != ddValue) { + String otelEnvVar = toEnvVar(otelSysProp); + log.warn("Both {} and {} are set, ignoring {}", toEnvVar(ddSysProp), otelEnvVar, otelEnvVar); + OtelEnvMetricCollectorProvider.get() + .setHidingOtelEnvVarMetric(toEnvVarLowerCase(otelSysProp), toEnvVarLowerCase(ddSysProp)); + return null; + } + return otelValue; + } + + /** + * Gets an OpenTelemetry property. + * + *

Checks system properties, environment variables, and the optional OpenTelemetry config file. + */ + private String getOtelProperty(String sysProp) { + String value = getProperty(sysProp); + if (null == value && null != otelConfigFile) { + value = otelConfigFile.getProperty(sysProp); + } + return value; + } + + /** + * Gets an OpenTelemetry OTLP property, checking the signal specific key before the general one. + * + *

Checks system properties, environment variables, and the optional OpenTelemetry config file. + * If the equivalent Datadog property is also set then log a warning and return {@code null}. + */ + private String getOtelOtlpProperty(String signal, String subkey, String ddSysProp) { + String otelKey = "otel.exporter.otlp." + signal + "." + subkey; + String otelValue = getOtelProperty(otelKey); + if (null == otelValue) { + // fall back to general configuration + otelKey = "otel.exporter.otlp." + subkey; + otelValue = getOtelProperty(otelKey); + // special case when using general endpoint as fallback: append appropriate suffix + if ("endpoint".equals(subkey) && otelValue != null && !otelValue.startsWith("unix://")) { + if ("logs".equals(signal) && !"grpc".equalsIgnoreCase(otelEnvironment.get(OTLP_LOGS_PROTOCOL))) { + otelValue = otelValue + (otelValue.endsWith("/") ? "v1/logs" : "/v1/logs"); + } + if ("metrics".equals(signal) && !"grpc".equalsIgnoreCase(otelEnvironment.get(OTLP_METRICS_PROTOCOL))) { + otelValue = otelValue + (otelValue.endsWith("/") ? "v1/metrics" : "/v1/metrics"); + } + if ("traces".equals(signal) && !"grpc".equalsIgnoreCase(otelEnvironment.get(OTLP_TRACES_PROTOCOL))) { + otelValue = otelValue + (otelValue.endsWith("/") ? "v1/traces" : "/v1/traces"); + } + } + } + if (null == otelValue) { + return null; + } + String ddValue = getDatadogProperty(ddSysProp); + if (null != ddValue) { + String otelEnvVar = toEnvVar(otelKey); + log.warn("Both {} and {} are set, ignoring {}", toEnvVar(ddSysProp), otelEnvVar, otelEnvVar); + OtelEnvMetricCollectorProvider.get() + .setHidingOtelEnvVarMetric(toEnvVarLowerCase(otelKey), toEnvVarLowerCase(ddSysProp)); + return null; + } + return otelValue; + } + + /** + * Gets a Datadog property. + * + *

Checks system properties, environment variables, and the optional Datadog config file. + */ + private String getDatadogProperty(String sysProp) { + String value = getProperty(sysProp); + if (null == value && null != datadogConfigFile) { + value = datadogConfigFile.getProperty(sysProp); + } + return value; + } + + /** + * Gets a general property. + * + *

Checks system properties and environment variables. + */ + private static String getProperty(String sysProp) { + // Always validate through ConfigHelper so STRICT_TEST mode can detect unsupported configs + String value = ConfigHelper.env(toEnvVar(sysProp)); + // System property takes precedence over environment variable + String sysPropValue = SystemProperties.get(sysProp); + if (sysPropValue != null) { + value = sysPropValue; + } + return value; } - return list; - } - - /** Parses a comma-separated list of key=value entries. */ - private static Map parseOtelMap(String value) { - Map map = new LinkedHashMap<>(); - int start = 0; - while (start < value.length()) { - int end = value.indexOf(',', start); - if (end < 0) { - end = value.length(); - } - if (end > start) { - String entry = value.substring(start, end); - int eq = entry.indexOf('='); - if (eq > 0) { - map.put(entry.substring(0, eq), entry.substring(eq + 1)); - } - } - start = end + 1; + + /** Captures a mapped OpenTelemetry property. */ + private void capture(String key, String value) { + if (null != value) { + otelEnvironment.put(key, value); + } } - return map; - } - - /** Renders the map as a comma-separated list of key:value entries. */ - private static String renderDatadogMap(Map map, int maxEntries) { - StringBuilder buf = new StringBuilder(); - - int entries = 0; - for (Map.Entry entry : map.entrySet()) { - buf.append(entry.getKey()).append(':').append(entry.getValue()).append(','); - if (++entries >= maxEntries) { - break; - } + + /** Loads the optional OpenTelemetry configuration file. */ + private static Properties loadOtelConfigFile() { + String path = getProperty("otel.javaagent.configuration-file"); + if (null != path && !path.isEmpty()) { + // Inflate '~' prefix as home folder + String home; + if (path.charAt(0) == '~' && (home = SystemProperties.get("user.home")) != null) { + path = home + path.substring(1); + } + File file = new File(path); + if (file.exists()) { + try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { + Properties properties = new Properties(); + properties.load(in); + return properties; + } catch (Throwable e) { + log.warn("Problem reading OTEL_JAVAAGENT_CONFIGURATION_FILE {} - {}", path, e.toString()); + } + } + } + return null; + } + + /** Parses a comma-separated list of items. */ + private static List parseOtelList(String value) { + List list = new ArrayList<>(); + int start = 0; + while (start < value.length()) { + int end = value.indexOf(',', start); + if (end < 0) { + end = value.length(); + } + if (end > start) { + list.add(value.substring(start, end)); + } + start = end + 1; + } + return list; + } + + /** Parses a comma-separated list of key=value entries. */ + private static Map parseOtelMap(String value) { + Map map = new LinkedHashMap<>(); + int start = 0; + while (start < value.length()) { + int end = value.indexOf(',', start); + if (end < 0) { + end = value.length(); + } + if (end > start) { + String entry = value.substring(start, end); + int eq = entry.indexOf('='); + if (eq > 0) { + map.put(entry.substring(0, eq), entry.substring(eq + 1)); + } + } + start = end + 1; + } + return map; } - // remove trailing comma, mapping empty conversion to null - return buf.length() > 1 ? buf.substring(0, buf.length() - 1) : null; - } + /** Renders the map as a comma-separated list of key:value entries. */ + private static String renderDatadogMap(Map map, int maxEntries) { + StringBuilder buf = new StringBuilder(); - /** Maps OpenTelemetry propagators to a list of accepted propagation styles. */ - private static String mapPropagationStyle(String propagators) { - if (null == propagators) { - return null; - } + int entries = 0; + for (Map.Entry entry : map.entrySet()) { + buf.append(entry.getKey()).append(':').append(entry.getValue()).append(','); + if (++entries >= maxEntries) { + break; + } + } - StringBuilder buf = new StringBuilder(); - for (String style : parseOtelList(propagators)) { - if ("b3".equalsIgnoreCase(style)) { - buf.append("b3single,"); // force use of OpenTelemetry alias - } else { - try { - buf.append(TracePropagationStyle.valueOfDisplayName(style)).append(','); - } catch (IllegalArgumentException e) { - log.warn("OTEL_PROPAGATORS={} is not supported", style); - OtelEnvMetricCollectorProvider.get() - .setInvalidOtelEnvVarMetric("otel_propagators", "dd_trace_propagation_style"); - } - } + // remove trailing comma, mapping empty conversion to null + return buf.length() > 1 ? buf.substring(0, buf.length() - 1) : null; } - // remove trailing comma, mapping empty conversion to null - return buf.length() > 1 ? buf.substring(0, buf.length() - 1) : null; - } + /** Maps OpenTelemetry propagators to a list of accepted propagation styles. */ + private static String mapPropagationStyle(String propagators) { + if (null == propagators) { + return null; + } - /** Maps known OpenTelemetry samplers to a trace sample rate. */ - private String mapSampleRate(String tracesSampler) { - if (null == tracesSampler) { - return null; - } + StringBuilder buf = new StringBuilder(); + for (String style : parseOtelList(propagators)) { + if ("b3".equalsIgnoreCase(style)) { + buf.append("b3single,"); // force use of OpenTelemetry alias + } else { + try { + buf.append(TracePropagationStyle.valueOfDisplayName(style)).append(','); + } catch (IllegalArgumentException e) { + log.warn("OTEL_PROPAGATORS={} is not supported", style); + OtelEnvMetricCollectorProvider.get() + .setInvalidOtelEnvVarMetric("otel_propagators", "dd_trace_propagation_style"); + } + } + } - if ("traceidratio".equalsIgnoreCase(tracesSampler) - || "always_on".equalsIgnoreCase(tracesSampler) - || "always_off".equalsIgnoreCase(tracesSampler)) { - log.warn( - "OTEL_TRACES_SAMPLER changed from {} to parentbased_{}; only parent based sampling is supported.", - tracesSampler, - tracesSampler); - tracesSampler = "parentbased_" + tracesSampler; + // remove trailing comma, mapping empty conversion to null + return buf.length() > 1 ? buf.substring(0, buf.length() - 1) : null; } - if ("parentbased_traceidratio".equalsIgnoreCase(tracesSampler)) { - return getOtelProperty("otel.traces.sampler.arg"); - } else if ("parentbased_always_on".equalsIgnoreCase(tracesSampler)) { - return "1.0"; - } else if ("parentbased_always_off".equalsIgnoreCase(tracesSampler)) { - return "0.0"; - } + /** Maps known OpenTelemetry samplers to a trace sample rate. */ + private String mapSampleRate(String tracesSampler) { + if (null == tracesSampler) { + return null; + } - log.warn("OTEL_TRACES_SAMPLER={} is not supported", tracesSampler); - OtelEnvMetricCollectorProvider.get() - .setInvalidOtelEnvVarMetric("otel_traces_sampler", "dd_trace_sample_rate"); - return null; - } - - /** Maps an OpenTelemetry exporter setting to the equivalent Datadog collection setting. */ - private String mapDataCollection(String signal) { - String exporter = getOtelProperty("otel." + signal + ".exporter"); - if (null == exporter) { - return null; - } + if ("traceidratio".equalsIgnoreCase(tracesSampler) + || "always_on".equalsIgnoreCase(tracesSampler) + || "always_off".equalsIgnoreCase(tracesSampler)) { + log.warn( + "OTEL_TRACES_SAMPLER changed from {} to parentbased_{}; only parent based sampling is supported.", + tracesSampler, + tracesSampler); + tracesSampler = "parentbased_" + tracesSampler; + } - if ("none".equalsIgnoreCase(exporter)) { - return "false"; // "none" maps to disable data collection - } + if ("parentbased_traceidratio".equalsIgnoreCase(tracesSampler)) { + return getOtelProperty("otel.traces.sampler.arg"); + } else if ("parentbased_always_on".equalsIgnoreCase(tracesSampler)) { + return "1.0"; + } else if ("parentbased_always_off".equalsIgnoreCase(tracesSampler)) { + return "0.0"; + } - log.warn("OTEL_{}_EXPORTER={} is not supported", signal, exporter.toUpperCase(Locale.ROOT)); - OtelEnvMetricCollectorProvider.get() - .setUnsupportedOtelEnvVarMetric("otel_" + signal + "_exporter"); - - return null; - } - - /** Merges the OpenTelemetry client and server headers to capture into a single list. */ - private String getOtelHeaders(String otelSuffix, String ddSysProp) { - String clientTags = - getOtelProperty("otel.instrumentation.http.client.capture-" + otelSuffix, ddSysProp); - String serverTags = - getOtelProperty("otel.instrumentation.http.server.capture-" + otelSuffix, ddSysProp); - if (null == clientTags) { - return serverTags; - } else if (null == serverTags) { - return clientTags; - } else { - return clientTags + ',' + serverTags; + log.warn("OTEL_TRACES_SAMPLER={} is not supported", tracesSampler); + OtelEnvMetricCollectorProvider.get().setInvalidOtelEnvVarMetric("otel_traces_sampler", "dd_trace_sample_rate"); + return null; } - } - - /** - * Maps OpenTelemetry list of headers to Datadog header tags, preserving the expected tag name. - */ - private static String mapHeaderTags(String tagPrefix, String headers) { - if (null == headers) { - return null; + + /** Maps an OpenTelemetry exporter setting to the equivalent Datadog collection setting. */ + private String mapDataCollection(String signal) { + String exporter = getOtelProperty("otel." + signal + ".exporter"); + if (null == exporter) { + return null; + } + + if ("none".equalsIgnoreCase(exporter)) { + return "false"; // "none" maps to disable data collection + } + + log.warn("OTEL_{}_EXPORTER={} is not supported", signal, exporter.toUpperCase(Locale.ROOT)); + OtelEnvMetricCollectorProvider.get().setUnsupportedOtelEnvVarMetric("otel_" + signal + "_exporter"); + + return null; } - StringBuilder buf = new StringBuilder(); - for (String header : parseOtelList(headers)) { - buf.append(header).append(':').append(tagPrefix).append(header).append(','); + /** Merges the OpenTelemetry client and server headers to capture into a single list. */ + private String getOtelHeaders(String otelSuffix, String ddSysProp) { + String clientTags = getOtelProperty("otel.instrumentation.http.client.capture-" + otelSuffix, ddSysProp); + String serverTags = getOtelProperty("otel.instrumentation.http.server.capture-" + otelSuffix, ddSysProp); + if (null == clientTags) { + return serverTags; + } else if (null == serverTags) { + return clientTags; + } else { + return clientTags + ',' + serverTags; + } } - // remove trailing comma, mapping empty conversion to null - return buf.length() > 1 ? buf.substring(0, buf.length() - 1) : null; - } + /** + * Maps OpenTelemetry list of headers to Datadog header tags, preserving the expected tag name. + */ + private static String mapHeaderTags(String tagPrefix, String headers) { + if (null == headers) { + return null; + } + + StringBuilder buf = new StringBuilder(); + for (String header : parseOtelList(headers)) { + buf.append(header).append(':').append(tagPrefix).append(header).append(','); + } + + // remove trailing comma, mapping empty conversion to null + return buf.length() > 1 ? buf.substring(0, buf.length() - 1) : null; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java index 77d48385905..bff148c4eca 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java @@ -6,29 +6,29 @@ import java.util.Properties; final class PropertiesConfigSource extends ConfigProvider.Source { - // start key with underscore, so it isn't visible using the public 'get' method - static final String CONFIG_FILE_STATUS = "_dd.config.file.status"; - - private final Properties props; - private final boolean useSystemPropertyFormat; - - public PropertiesConfigSource(Properties props, boolean useSystemPropertyFormat) { - assert props != null; - this.props = props; - this.useSystemPropertyFormat = useSystemPropertyFormat; - } - - public String getConfigFileStatus() { - return props.getProperty(CONFIG_FILE_STATUS); - } - - @Override - protected String get(String key) { - return props.getProperty(useSystemPropertyFormat ? propertyNameToSystemPropertyName(key) : key); - } - - @Override - public ConfigOrigin origin() { - return ConfigOrigin.JVM_PROP; - } + // start key with underscore, so it isn't visible using the public 'get' method + static final String CONFIG_FILE_STATUS = "_dd.config.file.status"; + + private final Properties props; + private final boolean useSystemPropertyFormat; + + public PropertiesConfigSource(Properties props, boolean useSystemPropertyFormat) { + assert props != null; + this.props = props; + this.useSystemPropertyFormat = useSystemPropertyFormat; + } + + public String getConfigFileStatus() { + return props.getProperty(CONFIG_FILE_STATUS); + } + + @Override + protected String get(String key) { + return props.getProperty(useSystemPropertyFormat ? propertyNameToSystemPropertyName(key) : key); + } + + @Override + public ConfigOrigin origin() { + return ConfigOrigin.JVM_PROP; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigParser.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigParser.java index 90fc10f10ac..27e6663259c 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigParser.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigParser.java @@ -21,251 +21,248 @@ import org.slf4j.LoggerFactory; public class StableConfigParser { - private static final Logger log = LoggerFactory.getLogger(StableConfigParser.class); + private static final Logger log = LoggerFactory.getLogger(StableConfigParser.class); - private static final String ENVIRONMENT_VARIABLES_PREFIX = "environment_variables['"; - private static final String PROCESS_ARGUMENTS_PREFIX = "process_arguments['"; - static final int MAX_FILE_SIZE_BYTES = 256 * 1024; // 256 KB in bytes; - private static final String UNDEFINED_VALUE = ""; + private static final String ENVIRONMENT_VARIABLES_PREFIX = "environment_variables['"; + private static final String PROCESS_ARGUMENTS_PREFIX = "process_arguments['"; + static final int MAX_FILE_SIZE_BYTES = 256 * 1024; // 256 KB in bytes; + private static final String UNDEFINED_VALUE = ""; - /** - * Parses a configuration file and returns a stable configuration object. - * - *

This method reads a configuration file from the given file path, parses the YAML content, - * and identifies configurations for the process using a combination of apm_configuration_default - * and apm_configuration_rules. If a matching rule is found in apm_configuration_rules, it returns - * a {@link StableConfigSource.StableConfig} object with the merged configuration. If no matching - * rule is found, it returns the default configuration. If neither a matching rule nor a default - * configuration is found, an empty configuration is returned. - * - * @param filePath The path to the YAML configuration file to be parsed. - * @return A {@link StableConfigSource.StableConfig} object containing the stable configuration. - * @throws IOException If there is an error reading the file or parsing the YAML content. - */ - public static StableConfigSource.StableConfig parse(String filePath) throws IOException { - try { - Path path = Paths.get(filePath); + /** + * Parses a configuration file and returns a stable configuration object. + * + *

This method reads a configuration file from the given file path, parses the YAML content, + * and identifies configurations for the process using a combination of apm_configuration_default + * and apm_configuration_rules. If a matching rule is found in apm_configuration_rules, it returns + * a {@link StableConfigSource.StableConfig} object with the merged configuration. If no matching + * rule is found, it returns the default configuration. If neither a matching rule nor a default + * configuration is found, an empty configuration is returned. + * + * @param filePath The path to the YAML configuration file to be parsed. + * @return A {@link StableConfigSource.StableConfig} object containing the stable configuration. + * @throws IOException If there is an error reading the file or parsing the YAML content. + */ + public static StableConfigSource.StableConfig parse(String filePath) throws IOException { + try { + Path path = Paths.get(filePath); - // If file is over size limit, drop - if (Files.size(path) > MAX_FILE_SIZE_BYTES) { - log.warn( - "Configuration file {} exceeds max size {} bytes; dropping.", - filePath, - MAX_FILE_SIZE_BYTES); - return StableConfigSource.StableConfig.EMPTY; - } - - String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + // If file is over size limit, drop + if (Files.size(path) > MAX_FILE_SIZE_BYTES) { + log.warn("Configuration file {} exceeds max size {} bytes; dropping.", filePath, MAX_FILE_SIZE_BYTES); + return StableConfigSource.StableConfig.EMPTY; + } - String processedContent = processTemplate(content); - Object parsedYaml = YamlParser.parse(processedContent); - StableConfig data = new StableConfig(parsedYaml); + String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - String configId = data.getConfigId(); - Map configMap = data.getApmConfigurationDefault(); - List rules = data.getApmConfigurationRules(); - - if (!rules.isEmpty()) { - for (Rule rule : rules) { - // Use the first matching rule - if (doesRuleMatch(rule)) { - // Merge configs found in apm_configuration_rules with those found in - // apm_configuration_default - Map mergedConfigMap = new LinkedHashMap<>(configMap); - mergedConfigMap.putAll(rule.getConfiguration()); - return new StableConfigSource.StableConfig(configId, mergedConfigMap); - } - } - log.debug("No matching rule found in stable configuration file {}", filePath); - } - // If configs were found in apm_configuration_default, use them - if (!configMap.isEmpty()) { - return new StableConfigSource.StableConfig(configId, configMap); - } + String processedContent = processTemplate(content); + Object parsedYaml = YamlParser.parse(processedContent); + StableConfig data = new StableConfig(parsedYaml); - // If there's a configId but no configMap, use configId but return an empty map - if (configId != null) { - return new StableConfigSource.StableConfig(configId, Collections.emptyMap()); - } - } catch (IOException e) { - log.debug("Failed to read the stable configuration file: {}", filePath, e); - } - return StableConfigSource.StableConfig.EMPTY; - } - - /** - * Checks if the rule's selectors match the current process. All must match for a "true" return - * value. - */ - private static boolean doesRuleMatch(Rule rule) { - for (Selector selector : rule.getSelectors()) { - if (!selectorMatch( - selector.getOrigin(), selector.getMatches(), selector.getOperator(), selector.getKey())) { - return false; // Return false immediately if any selector doesn't match - } - } - return true; // Return true if all selectors match - } + String configId = data.getConfigId(); + Map configMap = data.getApmConfigurationDefault(); + List rules = data.getApmConfigurationRules(); - private static boolean matchOperator(String value, String operator, List matches) { - if (value == null || operator == null) { - return false; - } - if ("exists".equals(operator)) { - return true; - } - if (matches == null || matches.isEmpty()) { - return false; - } - value = value.toLowerCase(Locale.ROOT); + if (!rules.isEmpty()) { + for (Rule rule : rules) { + // Use the first matching rule + if (doesRuleMatch(rule)) { + // Merge configs found in apm_configuration_rules with those found in + // apm_configuration_default + Map mergedConfigMap = new LinkedHashMap<>(configMap); + mergedConfigMap.putAll(rule.getConfiguration()); + return new StableConfigSource.StableConfig(configId, mergedConfigMap); + } + } + log.debug("No matching rule found in stable configuration file {}", filePath); + } + // If configs were found in apm_configuration_default, use them + if (!configMap.isEmpty()) { + return new StableConfigSource.StableConfig(configId, configMap); + } - Predicate comparator; - switch (operator) { - case "equals": - comparator = value::equals; - break; - case "starts_with": - comparator = value::startsWith; - break; - case "ends_with": - comparator = value::endsWith; - break; - case "contains": - comparator = value::contains; - break; - default: - return false; + // If there's a configId but no configMap, use configId but return an empty map + if (configId != null) { + return new StableConfigSource.StableConfig(configId, Collections.emptyMap()); + } + } catch (IOException e) { + log.debug("Failed to read the stable configuration file: {}", filePath, e); + } + return StableConfigSource.StableConfig.EMPTY; } - for (String match : matches) { - if (match == null) { - continue; - } - match = match.toLowerCase(Locale.ROOT); - if (comparator.test(match)) { - return true; - } + /** + * Checks if the rule's selectors match the current process. All must match for a "true" return + * value. + */ + private static boolean doesRuleMatch(Rule rule) { + for (Selector selector : rule.getSelectors()) { + if (!selectorMatch( + selector.getOrigin(), selector.getMatches(), selector.getOperator(), selector.getKey())) { + return false; // Return false immediately if any selector doesn't match + } + } + return true; // Return true if all selectors match } - return false; - } - // We do all the case insensitivity modifications in this function, because each selector will - // be viewed just once - static boolean selectorMatch(String origin, List matches, String operator, String key) { - if (operator == null) { - return false; - } - operator = operator.toLowerCase(Locale.ROOT); - switch (origin.toLowerCase(Locale.ROOT)) { - case "language": + private static boolean matchOperator(String value, String operator, List matches) { + if (value == null || operator == null) { + return false; + } if ("exists".equals(operator)) { - return false; + return true; } - return matchOperator("java", operator, matches); - case "environment_variables": - if (key == null) { - return false; + if (matches == null || matches.isEmpty()) { + return false; } - String envValue = ConfigHelper.env(key.toUpperCase(Locale.ROOT)); - return matchOperator(envValue, operator, matches); - case "process_arguments": - if (key == null) { - return false; + value = value.toLowerCase(Locale.ROOT); + + Predicate comparator; + switch (operator) { + case "equals": + comparator = value::equals; + break; + case "starts_with": + comparator = value::startsWith; + break; + case "ends_with": + comparator = value::endsWith; + break; + case "contains": + comparator = value::contains; + break; + default: + return false; } - // TODO: flesh out the meaning of each operator for process_arguments - if (!key.startsWith("-D")) { - log.warn( - "Ignoring unsupported process_arguments entry in selector match, '{}'. Only system properties specified with the '-D' prefix are supported.", - key); - return false; + + for (String match : matches) { + if (match == null) { + continue; + } + match = match.toLowerCase(Locale.ROOT); + if (comparator.test(match)) { + return true; + } } - String argValue = SystemProperties.get(key.substring(2)); - return matchOperator(argValue, operator, matches); - case "tags": - // TODO: Support this down the line (Must define the source of "tags" first) - return false; - default: return false; } - } - static String processTemplate(String content) throws IOException { - // Do nothing if there are no variables to process - int openIndex = content.indexOf("{{"); - if (openIndex == -1) { - return content; + // We do all the case insensitivity modifications in this function, because each selector will + // be viewed just once + static boolean selectorMatch(String origin, List matches, String operator, String key) { + if (operator == null) { + return false; + } + operator = operator.toLowerCase(Locale.ROOT); + switch (origin.toLowerCase(Locale.ROOT)) { + case "language": + if ("exists".equals(operator)) { + return false; + } + return matchOperator("java", operator, matches); + case "environment_variables": + if (key == null) { + return false; + } + String envValue = ConfigHelper.env(key.toUpperCase(Locale.ROOT)); + return matchOperator(envValue, operator, matches); + case "process_arguments": + if (key == null) { + return false; + } + // TODO: flesh out the meaning of each operator for process_arguments + if (!key.startsWith("-D")) { + log.warn( + "Ignoring unsupported process_arguments entry in selector match, '{}'. Only system properties specified with the '-D' prefix are supported.", + key); + return false; + } + String argValue = SystemProperties.get(key.substring(2)); + return matchOperator(argValue, operator, matches); + case "tags": + // TODO: Support this down the line (Must define the source of "tags" first) + return false; + default: + return false; + } } - StringBuilder result = new StringBuilder(content.length()); + static String processTemplate(String content) throws IOException { + // Do nothing if there are no variables to process + int openIndex = content.indexOf("{{"); + if (openIndex == -1) { + return content; + } - // Add everything before the opening braces - result.append(content, 0, openIndex); + StringBuilder result = new StringBuilder(content.length()); - while (true) { + // Add everything before the opening braces + result.append(content, 0, openIndex); - // Find the closing braces - int closeIndex = content.indexOf("}}", openIndex); - if (closeIndex == -1) { - throw new IOException("Unterminated template in config"); - } + while (true) { - // Extract the template variable - String templateVar = content.substring(openIndex + 2, closeIndex).trim(); + // Find the closing braces + int closeIndex = content.indexOf("}}", openIndex); + if (closeIndex == -1) { + throw new IOException("Unterminated template in config"); + } - // Process the template variable and get its value - String value = processTemplateVar(templateVar); + // Extract the template variable + String templateVar = content.substring(openIndex + 2, closeIndex).trim(); - // Add the processed value - result.append(value); + // Process the template variable and get its value + String value = processTemplateVar(templateVar); - // Continue with the next template variable - openIndex = content.indexOf("{{", closeIndex); - if (openIndex == -1) { - // Stop and add everything left after the final closing braces - result.append(content, closeIndex + 2, content.length()); - break; - } else { - // Add everything between the last braces and the next - result.append(content, closeIndex + 2, openIndex); - } - } + // Add the processed value + result.append(value); + + // Continue with the next template variable + openIndex = content.indexOf("{{", closeIndex); + if (openIndex == -1) { + // Stop and add everything left after the final closing braces + result.append(content, closeIndex + 2, content.length()); + break; + } else { + // Add everything between the last braces and the next + result.append(content, closeIndex + 2, openIndex); + } + } - return result.toString(); - } + return result.toString(); + } - private static String processTemplateVar(String templateVar) throws IOException { - if (templateVar.startsWith(ENVIRONMENT_VARIABLES_PREFIX) && templateVar.endsWith("']")) { - String envVar = - templateVar - .substring(ENVIRONMENT_VARIABLES_PREFIX.length(), templateVar.length() - 2) - .trim(); - if (envVar.isEmpty()) { - throw new IOException("Empty environment variable name in template"); - } - String value = ConfigHelper.env(envVar.toUpperCase(Locale.ROOT)); - if (value == null || value.isEmpty()) { - return UNDEFINED_VALUE; - } - return value; - } else if (templateVar.startsWith(PROCESS_ARGUMENTS_PREFIX) && templateVar.endsWith("']")) { - String processArg = - templateVar.substring(PROCESS_ARGUMENTS_PREFIX.length(), templateVar.length() - 2).trim(); - if (processArg.isEmpty()) { - throw new IOException("Empty process argument in template"); - } - if (!processArg.startsWith("-D")) { - log.warn( - "Ignoring unsupported process_arguments entry in template variable, '{}'. Only system properties specified with the '-D' prefix are supported.", - processArg); - return UNDEFINED_VALUE; - } - String value = SystemProperties.get(processArg.substring(2)); - if (value == null || value.isEmpty()) { - return UNDEFINED_VALUE; - } - return value; - } else { - return UNDEFINED_VALUE; + private static String processTemplateVar(String templateVar) throws IOException { + if (templateVar.startsWith(ENVIRONMENT_VARIABLES_PREFIX) && templateVar.endsWith("']")) { + String envVar = templateVar + .substring(ENVIRONMENT_VARIABLES_PREFIX.length(), templateVar.length() - 2) + .trim(); + if (envVar.isEmpty()) { + throw new IOException("Empty environment variable name in template"); + } + String value = ConfigHelper.env(envVar.toUpperCase(Locale.ROOT)); + if (value == null || value.isEmpty()) { + return UNDEFINED_VALUE; + } + return value; + } else if (templateVar.startsWith(PROCESS_ARGUMENTS_PREFIX) && templateVar.endsWith("']")) { + String processArg = templateVar + .substring(PROCESS_ARGUMENTS_PREFIX.length(), templateVar.length() - 2) + .trim(); + if (processArg.isEmpty()) { + throw new IOException("Empty process argument in template"); + } + if (!processArg.startsWith("-D")) { + log.warn( + "Ignoring unsupported process_arguments entry in template variable, '{}'. Only system properties specified with the '-D' prefix are supported.", + processArg); + return UNDEFINED_VALUE; + } + String value = SystemProperties.get(processArg.substring(2)); + if (value == null || value.isEmpty()) { + return UNDEFINED_VALUE; + } + return value; + } else { + return UNDEFINED_VALUE; + } } - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigSource.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigSource.java index 84d0835f984..21ded20e743 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigSource.java @@ -13,93 +13,88 @@ import org.slf4j.LoggerFactory; public final class StableConfigSource extends ConfigProvider.Source { - private static final Logger log = LoggerFactory.getLogger(StableConfigSource.class); + private static final Logger log = LoggerFactory.getLogger(StableConfigSource.class); - public static final String LOCAL_STABLE_CONFIG_PATH = - "/etc/datadog-agent/application_monitoring.yaml"; - public static final String FLEET_STABLE_CONFIG_PATH = - "/etc/datadog-agent/managed/datadog-agent/stable/application_monitoring.yaml"; - public static final StableConfigSource LOCAL = - new StableConfigSource(LOCAL_STABLE_CONFIG_PATH, ConfigOrigin.LOCAL_STABLE_CONFIG); - public static final StableConfigSource FLEET = - new StableConfigSource( - StableConfigSource.FLEET_STABLE_CONFIG_PATH, ConfigOrigin.FLEET_STABLE_CONFIG); + public static final String LOCAL_STABLE_CONFIG_PATH = "/etc/datadog-agent/application_monitoring.yaml"; + public static final String FLEET_STABLE_CONFIG_PATH = + "/etc/datadog-agent/managed/datadog-agent/stable/application_monitoring.yaml"; + public static final StableConfigSource LOCAL = + new StableConfigSource(LOCAL_STABLE_CONFIG_PATH, ConfigOrigin.LOCAL_STABLE_CONFIG); + public static final StableConfigSource FLEET = + new StableConfigSource(StableConfigSource.FLEET_STABLE_CONFIG_PATH, ConfigOrigin.FLEET_STABLE_CONFIG); - private final ConfigOrigin fileOrigin; - private final StableConfig config; + private final ConfigOrigin fileOrigin; + private final StableConfig config; - StableConfigSource(String filePath, ConfigOrigin origin) { - StableConfig cfg = StableConfig.EMPTY; - this.fileOrigin = origin; - try { - File file = new File(filePath); - log.debug("Stable configuration file found at path: {}", file); - if (Files.exists(file)) { - cfg = StableConfigParser.parse(filePath); - } - } catch (Throwable e) { - if (e instanceof StableConfigMappingException - || e instanceof IllegalArgumentException - || e instanceof ClassCastException - || e instanceof NullPointerException) { - log.warn( - "YAML mapping error in stable configuration file: {}, error: {}", - filePath, - e.getMessage()); - } else if (log.isDebugEnabled()) { - log.error("Unexpected error while reading stable configuration file: {}", filePath, e); - } else { - log.error( - "Unexpected error while reading stable configuration file: {}, error: {}", - filePath, - e.getMessage()); - } + StableConfigSource(String filePath, ConfigOrigin origin) { + StableConfig cfg = StableConfig.EMPTY; + this.fileOrigin = origin; + try { + File file = new File(filePath); + log.debug("Stable configuration file found at path: {}", file); + if (Files.exists(file)) { + cfg = StableConfigParser.parse(filePath); + } + } catch (Throwable e) { + if (e instanceof StableConfigMappingException + || e instanceof IllegalArgumentException + || e instanceof ClassCastException + || e instanceof NullPointerException) { + log.warn("YAML mapping error in stable configuration file: {}, error: {}", filePath, e.getMessage()); + } else if (log.isDebugEnabled()) { + log.error("Unexpected error while reading stable configuration file: {}", filePath, e); + } else { + log.error( + "Unexpected error while reading stable configuration file: {}, error: {}", + filePath, + e.getMessage()); + } + } + this.config = cfg; } - this.config = cfg; - } - @Override - public String get(String key) { - if (this.config == StableConfig.EMPTY) { - return null; + @Override + public String get(String key) { + if (this.config == StableConfig.EMPTY) { + return null; + } + return this.config.get(propertyNameToEnvironmentVariableName(key)); } - return this.config.get(propertyNameToEnvironmentVariableName(key)); - } - @Override - public ConfigOrigin origin() { - return fileOrigin; - } + @Override + public ConfigOrigin origin() { + return fileOrigin; + } - public Set getKeys() { - return this.config.getKeys(); - } + public Set getKeys() { + return this.config.getKeys(); + } - public String getConfigId() { - return this.config.getConfigId(); - } + public String getConfigId() { + return this.config.getConfigId(); + } - public static class StableConfig { - public static final StableConfig EMPTY = new StableConfig(null, Collections.emptyMap()); - private final Map apmConfiguration; - private final String configId; + public static class StableConfig { + public static final StableConfig EMPTY = new StableConfig(null, Collections.emptyMap()); + private final Map apmConfiguration; + private final String configId; - public StableConfig(String configId, Map configMap) { - this.configId = configId; - this.apmConfiguration = configMap; - } + public StableConfig(String configId, Map configMap) { + this.configId = configId; + this.apmConfiguration = configMap; + } - public String get(String key) { - Object value = this.apmConfiguration.get(key); - return (value == null) ? null : String.valueOf(value); - } + public String get(String key) { + Object value = this.apmConfiguration.get(key); + return (value == null) ? null : String.valueOf(value); + } - public Set getKeys() { - return this.apmConfiguration.keySet(); - } + public Set getKeys() { + return this.apmConfiguration.keySet(); + } - public String getConfigId() { - return this.configId; + public String getConfigId() { + return this.configId; + } } - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java index 853c74c3285..0e00ca70b0e 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java @@ -7,13 +7,13 @@ import datadog.trace.api.ConfigOrigin; public final class SystemPropertiesConfigSource extends ConfigProvider.Source { - @Override - protected String get(String key) { - return SystemProperties.get(propertyNameToSystemPropertyName(key)); - } + @Override + protected String get(String key) { + return SystemProperties.get(propertyNameToSystemPropertyName(key)); + } - @Override - public ConfigOrigin origin() { - return JVM_PROP; - } + @Override + public ConfigOrigin origin() { + return JVM_PROP; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariables.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariables.java index c8208ee709e..a64477e53fc 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariables.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariables.java @@ -21,139 +21,133 @@ public class CiEnvironmentVariables { - private static final Logger logger = LoggerFactory.getLogger(CiEnvironmentVariables.class); + private static final Logger logger = LoggerFactory.getLogger(CiEnvironmentVariables.class); - static final String CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_URL = - "dd.civisibility.remote.env.vars.provider.url"; - static final String CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_KEY = - "dd.civisibility.remote.env.vars.provider.key"; + static final String CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_URL = "dd.civisibility.remote.env.vars.provider.url"; + static final String CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_KEY = "dd.civisibility.remote.env.vars.provider.key"; - static final String DD_ENV_VARS_PROVIDER_KEY_HEADER = "DD-Env-Vars-Provider-Key"; - static final String ACCEPT_HEADER = "Accept"; + static final String DD_ENV_VARS_PROVIDER_KEY_HEADER = "DD-Env-Vars-Provider-Key"; + static final String ACCEPT_HEADER = "Accept"; - private static final int CONNECT_TIMEOUT_MILLIS = 5000; - private static final int READ_TIMEOUT_MILLIS = 10000; + private static final int CONNECT_TIMEOUT_MILLIS = 5000; + private static final int READ_TIMEOUT_MILLIS = 10000; - private static final Map REMOTE_ENVIRONMENT; + private static final Map REMOTE_ENVIRONMENT; - static { - String url = getConfigValue(CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_URL); - String key = getConfigValue(CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_KEY); - if (url != null && key != null) { - REMOTE_ENVIRONMENT = - getRemoteEnvironmentWithRetries(url, key, new RetryPolicy(500, 5, 2), null); - } else { - REMOTE_ENVIRONMENT = null; + static { + String url = getConfigValue(CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_URL); + String key = getConfigValue(CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_KEY); + if (url != null && key != null) { + REMOTE_ENVIRONMENT = getRemoteEnvironmentWithRetries(url, key, new RetryPolicy(500, 5, 2), null); + } else { + REMOTE_ENVIRONMENT = null; + } } - } - private static String getConfigValue(String propertyName) { - String propertyValue = System.getProperty(propertyName); - if (propertyValue != null) { - return propertyValue; - } - return ConfigHelper.get().getEnvironmentVariable(ConfigStrings.toEnvVar(propertyName)); - } - - static Map getRemoteEnvironmentWithRetries( - String url, String key, RetryPolicy retryPolicy, Map fallbackValue) { - return doWithBackoffRetries(() -> getRemoteEnvironment(url, key), retryPolicy, fallbackValue); - } - - static final class RetryPolicy { - long delayMillis; - int maxAttempts; - double backoffFactor; - - public RetryPolicy(long delayMillis, int maxAttempts, double backoffFactor) { - this.delayMillis = delayMillis; - this.maxAttempts = maxAttempts; - this.backoffFactor = backoffFactor; + private static String getConfigValue(String propertyName) { + String propertyValue = System.getProperty(propertyName); + if (propertyValue != null) { + return propertyValue; + } + return ConfigHelper.get().getEnvironmentVariable(ConfigStrings.toEnvVar(propertyName)); } - } - - private static T doWithBackoffRetries( - Callable action, RetryPolicy retryPolicy, T fallbackValue) { - long delayMillis = retryPolicy.delayMillis; - for (int i = 0; i < retryPolicy.maxAttempts; i++) { - if (Thread.currentThread().isInterrupted()) { - logger.warn("Interrupted while trying to read remote environment"); - return fallbackValue; - } - try { - return action.call(); - - } catch (Exception e) { - logger.warn("Error while trying to read remote environment", e); - sleep(delayMillis); - delayMillis = Math.round(delayMillis * retryPolicy.backoffFactor); - } + + static Map getRemoteEnvironmentWithRetries( + String url, String key, RetryPolicy retryPolicy, Map fallbackValue) { + return doWithBackoffRetries(() -> getRemoteEnvironment(url, key), retryPolicy, fallbackValue); } - return fallbackValue; - } - - private static void sleep(long delayMillis) { - try { - Thread.sleep(delayMillis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + + static final class RetryPolicy { + long delayMillis; + int maxAttempts; + double backoffFactor; + + public RetryPolicy(long delayMillis, int maxAttempts, double backoffFactor) { + this.delayMillis = delayMillis; + this.maxAttempts = maxAttempts; + this.backoffFactor = backoffFactor; + } } - } - - private static Map getRemoteEnvironment(String url, String key) throws Exception { - HttpURLConnection conn = null; - try { - URL target = new URL(url); - conn = (HttpURLConnection) target.openConnection(); - conn.setRequestMethod("GET"); - conn.setRequestProperty(DD_ENV_VARS_PROVIDER_KEY_HEADER, key); - conn.setRequestProperty(ACCEPT_HEADER, "text/plain"); - conn.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); - conn.setReadTimeout(READ_TIMEOUT_MILLIS); - - int code = conn.getResponseCode(); - if (code >= 200 && code < 300) { - Properties properties = new Properties(); - try (Reader r = new InputStreamReader(conn.getInputStream(), StandardCharsets.ISO_8859_1)) { - properties.load(r); + + private static T doWithBackoffRetries(Callable action, RetryPolicy retryPolicy, T fallbackValue) { + long delayMillis = retryPolicy.delayMillis; + for (int i = 0; i < retryPolicy.maxAttempts; i++) { + if (Thread.currentThread().isInterrupted()) { + logger.warn("Interrupted while trying to read remote environment"); + return fallbackValue; + } + try { + return action.call(); + + } catch (Exception e) { + logger.warn("Error while trying to read remote environment", e); + sleep(delayMillis); + delayMillis = Math.round(delayMillis * retryPolicy.backoffFactor); + } } - return asMap(properties); - - } else { - try (BufferedReader r = - new BufferedReader( - new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8))) { - String body = r.lines().collect(Collectors.joining("\n")); - throw new IOException( - String.format("Remote environment request failed (HTTP %d) %s", code, body)); + return fallbackValue; + } + + private static void sleep(long delayMillis) { + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } - } + } - } finally { - if (conn != null) { - conn.disconnect(); - } + private static Map getRemoteEnvironment(String url, String key) throws Exception { + HttpURLConnection conn = null; + try { + URL target = new URL(url); + conn = (HttpURLConnection) target.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty(DD_ENV_VARS_PROVIDER_KEY_HEADER, key); + conn.setRequestProperty(ACCEPT_HEADER, "text/plain"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + conn.setReadTimeout(READ_TIMEOUT_MILLIS); + + int code = conn.getResponseCode(); + if (code >= 200 && code < 300) { + Properties properties = new Properties(); + try (Reader r = new InputStreamReader(conn.getInputStream(), StandardCharsets.ISO_8859_1)) { + properties.load(r); + } + return asMap(properties); + + } else { + try (BufferedReader r = + new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8))) { + String body = r.lines().collect(Collectors.joining("\n")); + throw new IOException(String.format("Remote environment request failed (HTTP %d) %s", code, body)); + } + } + + } finally { + if (conn != null) { + conn.disconnect(); + } + } } - } - private static Map asMap(Properties properties) { - Map map = new HashMap<>(); - for (String name : properties.stringPropertyNames()) { - map.put(name, properties.getProperty(name)); + private static Map asMap(Properties properties) { + Map map = new HashMap<>(); + for (String name : properties.stringPropertyNames()) { + map.put(name, properties.getProperty(name)); + } + return map; } - return map; - } - @Nullable - public static String get(String name) { - if (REMOTE_ENVIRONMENT == null) { - return null; + @Nullable + public static String get(String name) { + if (REMOTE_ENVIRONMENT == null) { + return null; + } + return REMOTE_ENVIRONMENT.get(name); } - return REMOTE_ENVIRONMENT.get(name); - } - @Nullable - public static Map getAll() { - return REMOTE_ENVIRONMENT != null ? Collections.unmodifiableMap(REMOTE_ENVIRONMENT) : null; - } + @Nullable + public static Map getAll() { + return REMOTE_ENVIRONMENT != null ? Collections.unmodifiableMap(REMOTE_ENVIRONMENT) : null; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Rule.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Rule.java index 4c62baf6069..86f636360eb 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Rule.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Rule.java @@ -15,67 +15,67 @@ * configuration files */ public final class Rule { - private final List selectors; - private final Map configuration; + private final List selectors; + private final Map configuration; - public Rule() { - this.selectors = emptyList(); - this.configuration = emptyMap(); - } - - public Rule(List selectors, Map configuration) { - this.selectors = selectors; - this.configuration = configuration; - } - - public static Rule from(Map map) { - Object selectorsObj = map.get("selectors"); - if (selectorsObj == null) { - throwStableConfigMappingException("Missing 'selectors' in rule:", map); + public Rule() { + this.selectors = emptyList(); + this.configuration = emptyMap(); } - if (!(selectorsObj instanceof List)) { - throwStableConfigMappingException( - "'selectors' must be a list, but got: " + selectorsObj.getClass().getSimpleName() + ": ", - selectorsObj); + public Rule(List selectors, Map configuration) { + this.selectors = selectors; + this.configuration = configuration; } - Object configObj = map.get("configuration"); - if (configObj == null) { - throwStableConfigMappingException("Missing 'configuration' in rule:", map); - } - if (!(configObj instanceof Map)) { - throwStableConfigMappingException( - "'configuration' must be a map, but got: " + configObj.getClass().getSimpleName() + ": ", - configObj); - } + public static Rule from(Map map) { + Object selectorsObj = map.get("selectors"); + if (selectorsObj == null) { + throwStableConfigMappingException("Missing 'selectors' in rule:", map); + } - List selectors = - ((List) selectorsObj) - .stream() - .filter(Objects::nonNull) - .map( - s -> { - if (!(s instanceof Map)) { - throwStableConfigMappingException( - "Each selector must be a map, but got: " - + s.getClass().getSimpleName() - + ": ", - s); - } + if (!(selectorsObj instanceof List)) { + throwStableConfigMappingException( + "'selectors' must be a list, but got: " + + selectorsObj.getClass().getSimpleName() + ": ", + selectorsObj); + } - return Selector.from((Map) s); - }) - .collect(toList()); + Object configObj = map.get("configuration"); + if (configObj == null) { + throwStableConfigMappingException("Missing 'configuration' in rule:", map); + } + if (!(configObj instanceof Map)) { + throwStableConfigMappingException( + "'configuration' must be a map, but got: " + + configObj.getClass().getSimpleName() + ": ", + configObj); + } - return new Rule(unmodifiableList(selectors), (Map) configObj); - } + List selectors = ((List) selectorsObj) + .stream() + .filter(Objects::nonNull) + .map(s -> { + if (!(s instanceof Map)) { + throwStableConfigMappingException( + "Each selector must be a map, but got: " + + s.getClass().getSimpleName() + + ": ", + s); + } - public List getSelectors() { - return selectors; - } + return Selector.from((Map) s); + }) + .collect(toList()); - public Map getConfiguration() { - return configuration; - } + return new Rule(unmodifiableList(selectors), (Map) configObj); + } + + public List getSelectors() { + return selectors; + } + + public Map getConfiguration() { + return configuration; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Selector.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Selector.java index b06362cd0cd..5cb3aa9347f 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Selector.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/Selector.java @@ -7,70 +7,72 @@ import java.util.Map; public final class Selector { - private final String origin; - private final String key; - private final List matches; - private final String operator; + private final String origin; + private final String key; + private final List matches; + private final String operator; - public Selector(String origin, String key, List matches, String operator) { - this.origin = origin; - this.key = key; - this.matches = matches; - this.operator = operator; - } - - public static Selector from(Map map) { - Object originObj = map.get("origin"); - if (originObj == null) { - throwStableConfigMappingException("Missing 'origin' in selector:", map); - } - if (!(originObj instanceof String)) { - throwStableConfigMappingException( - "'origin' must be a string, but got: " + originObj.getClass().getSimpleName() + ": ", - originObj); + public Selector(String origin, String key, List matches, String operator) { + this.origin = origin; + this.key = key; + this.matches = matches; + this.operator = operator; } - String origin = (String) originObj; - Object keyObj = map.get("key"); - String key = (keyObj instanceof String) ? (String) keyObj : null; + public static Selector from(Map map) { + Object originObj = map.get("origin"); + if (originObj == null) { + throwStableConfigMappingException("Missing 'origin' in selector:", map); + } + if (!(originObj instanceof String)) { + throwStableConfigMappingException( + "'origin' must be a string, but got: " + + originObj.getClass().getSimpleName() + ": ", + originObj); + } + String origin = (String) originObj; - Object matchesObj = map.get("matches"); - if (matchesObj != null && !(matchesObj instanceof List)) { - throwStableConfigMappingException( - "'matches' must be a list, but got: " + matchesObj.getClass().getSimpleName() + ": ", - matchesObj); - } - List rawMatches = (List) matchesObj; - List matches = - rawMatches != null ? Collections.unmodifiableList(rawMatches) : Collections.emptyList(); + Object keyObj = map.get("key"); + String key = (keyObj instanceof String) ? (String) keyObj : null; - Object operatorObj = map.get("operator"); - if (operatorObj == null) { - throwStableConfigMappingException("Missing 'operator' in selector:", map); - } - if (!(operatorObj instanceof String)) { - throwStableConfigMappingException( - "'operator' must be a string, but got: " + operatorObj.getClass().getSimpleName() + ": ", - operatorObj); - } - String operator = (String) operatorObj; + Object matchesObj = map.get("matches"); + if (matchesObj != null && !(matchesObj instanceof List)) { + throwStableConfigMappingException( + "'matches' must be a list, but got: " + + matchesObj.getClass().getSimpleName() + ": ", + matchesObj); + } + List rawMatches = (List) matchesObj; + List matches = rawMatches != null ? Collections.unmodifiableList(rawMatches) : Collections.emptyList(); - return new Selector(origin, key, matches, operator); - } + Object operatorObj = map.get("operator"); + if (operatorObj == null) { + throwStableConfigMappingException("Missing 'operator' in selector:", map); + } + if (!(operatorObj instanceof String)) { + throwStableConfigMappingException( + "'operator' must be a string, but got: " + + operatorObj.getClass().getSimpleName() + ": ", + operatorObj); + } + String operator = (String) operatorObj; - public String getOrigin() { - return origin; - } + return new Selector(origin, key, matches, operator); + } - public String getKey() { - return key; - } + public String getOrigin() { + return origin; + } - public List getMatches() { - return matches; - } + public String getKey() { + return key; + } - public String getOperator() { - return operator; - } + public List getMatches() { + return matches; + } + + public String getOperator() { + return operator; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfig.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfig.java index acac0da93dc..bbe5cb91570 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfig.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfig.java @@ -11,58 +11,56 @@ import java.util.Map; public final class StableConfig { - private final String configId; - private final Map apmConfigurationDefault; - private final List apmConfigurationRules; + private final String configId; + private final Map apmConfigurationDefault; + private final List apmConfigurationRules; - public StableConfig(Object yaml) { - Map map = (Map) yaml; - this.configId = map.get("config_id") == null ? null : String.valueOf(map.get("config_id")); + public StableConfig(Object yaml) { + Map map = (Map) yaml; + this.configId = map.get("config_id") == null ? null : String.valueOf(map.get("config_id")); - // getOrDefault returns null if key exists with null value, so we need explicit null check - Map apmConfigDefault = - (Map) map.get("apm_configuration_default"); - this.apmConfigurationDefault = - unmodifiableMap(apmConfigDefault != null ? apmConfigDefault : emptyMap()); + // getOrDefault returns null if key exists with null value, so we need explicit null check + Map apmConfigDefault = (Map) map.get("apm_configuration_default"); + this.apmConfigurationDefault = unmodifiableMap(apmConfigDefault != null ? apmConfigDefault : emptyMap()); - this.apmConfigurationRules = parseRules(map); - } + this.apmConfigurationRules = parseRules(map); + } - // test only - private StableConfig(String configId, Map apmConfigurationDefault) { - this.configId = configId; - this.apmConfigurationDefault = apmConfigurationDefault; - this.apmConfigurationRules = new ArrayList<>(); - } + // test only + private StableConfig(String configId, Map apmConfigurationDefault) { + this.configId = configId; + this.apmConfigurationDefault = apmConfigurationDefault; + this.apmConfigurationRules = new ArrayList<>(); + } - public String getConfigId() { - return configId; - } + public String getConfigId() { + return configId; + } - public Map getApmConfigurationDefault() { - return apmConfigurationDefault; - } + public Map getApmConfigurationDefault() { + return apmConfigurationDefault; + } - public List getApmConfigurationRules() { - return apmConfigurationRules; - } + public List getApmConfigurationRules() { + return apmConfigurationRules; + } - private List parseRules(Map map) { - Object rulesObj = map.get("apm_configuration_rules"); - if (rulesObj instanceof List) { - List rulesList = (List) rulesObj; - List rules = new ArrayList<>(); - for (Object ruleObj : rulesList) { - if (ruleObj instanceof Map) { - rules.add(Rule.from((Map) ruleObj)); - } else { - throwStableConfigMappingException( - "Rule must be a map, but got: " + ruleObj.getClass().getSimpleName() + ": ", ruleObj); - return emptyList(); + private List parseRules(Map map) { + Object rulesObj = map.get("apm_configuration_rules"); + if (rulesObj instanceof List) { + List rulesList = (List) rulesObj; + List rules = new ArrayList<>(); + for (Object ruleObj : rulesList) { + if (ruleObj instanceof Map) { + rules.add(Rule.from((Map) ruleObj)); + } else { + throwStableConfigMappingException( + "Rule must be a map, but got: " + ruleObj.getClass().getSimpleName() + ": ", ruleObj); + return emptyList(); + } + } + return unmodifiableList(rules); } - } - return unmodifiableList(rules); + return emptyList(); } - return emptyList(); - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfigMappingException.java b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfigMappingException.java index 5b0f10a3cda..cadca318252 100644 --- a/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfigMappingException.java +++ b/utils/config-utils/src/main/java/datadog/trace/bootstrap/config/provider/stableconfig/StableConfigMappingException.java @@ -1,40 +1,38 @@ package datadog.trace.bootstrap.config.provider.stableconfig; public class StableConfigMappingException extends RuntimeException { - private static final int MAX_LEN = 100; + private static final int MAX_LEN = 100; - public StableConfigMappingException(String message) { - super(message); - } + public StableConfigMappingException(String message) { + super(message); + } - /** - * Safely converts an object to a string for error reporting, truncating the result if it exceeds - * a maximum length. - * - * @param value the object to convert to a string - * @return a string representation of the object, truncated if necessary - */ - static String safeToString(Object value) { - if (value == null) return "null"; - String str = value.toString(); - if (str.length() > MAX_LEN) { - int partLen = MAX_LEN / 2; - return str.substring(0, partLen) - + "...(truncated)..." - + str.substring(str.length() - partLen); + /** + * Safely converts an object to a string for error reporting, truncating the result if it exceeds + * a maximum length. + * + * @param value the object to convert to a string + * @return a string representation of the object, truncated if necessary + */ + static String safeToString(Object value) { + if (value == null) return "null"; + String str = value.toString(); + if (str.length() > MAX_LEN) { + int partLen = MAX_LEN / 2; + return str.substring(0, partLen) + "...(truncated)..." + str.substring(str.length() - partLen); + } + return str; } - return str; - } - /** - * Throws a StableConfigMappingException with a message that includes a safe string representation - * of the provided value. - * - * @param message the error message to include - * @param value the value to include in the error message, safely stringified - * @throws StableConfigMappingException always thrown by this method - */ - public static void throwStableConfigMappingException(String message, Object value) { - throw new StableConfigMappingException(message + " " + safeToString(value)); - } + /** + * Throws a StableConfigMappingException with a message that includes a safe string representation + * of the provided value. + * + * @param message the error message to include + * @param value the value to include in the error message, safely stringified + * @throws StableConfigMappingException always thrown by this method + */ + public static void throwStableConfigMappingException(String message, Object value) { + throw new StableConfigMappingException(message + " " + safeToString(value)); + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/config/inversion/ConfigHelper.java b/utils/config-utils/src/main/java/datadog/trace/config/inversion/ConfigHelper.java index e58c71fa664..71becdd695f 100644 --- a/utils/config-utils/src/main/java/datadog/trace/config/inversion/ConfigHelper.java +++ b/utils/config-utils/src/main/java/datadog/trace/config/inversion/ConfigHelper.java @@ -14,169 +14,167 @@ import org.slf4j.LoggerFactory; public class ConfigHelper { - private ConfigHelper() {} + private ConfigHelper() {} - /** Config Inversion strictness policy for enforcement of undocumented environment variables */ - public enum StrictnessPolicy { - STRICT_TEST, - WARNING, - TEST; + /** Config Inversion strictness policy for enforcement of undocumented environment variables */ + public enum StrictnessPolicy { + STRICT_TEST, + WARNING, + TEST; - private String displayName; + private String displayName; - StrictnessPolicy() { - this.displayName = name().toLowerCase(Locale.ROOT); - } + StrictnessPolicy() { + this.displayName = name().toLowerCase(Locale.ROOT); + } - @Override - public String toString() { - if (displayName == null) { - displayName = name().toLowerCase(Locale.ROOT); - } - return displayName; + @Override + public String toString() { + if (displayName == null) { + displayName = name().toLowerCase(Locale.ROOT); + } + return displayName; + } } - } - - private static final Logger log = LoggerFactory.getLogger(ConfigHelper.class); - private static final ConfigHelper INSTANCE = new ConfigHelper(); + private static final Logger log = LoggerFactory.getLogger(ConfigHelper.class); - private StrictnessPolicy configInversionStrict = StrictnessPolicy.WARNING; + private static final ConfigHelper INSTANCE = new ConfigHelper(); - private static final String DD_PREFIX = "DD_"; - private static final String OTEL_PREFIX = "OTEL_"; + private StrictnessPolicy configInversionStrict = StrictnessPolicy.WARNING; - // Cache for configs, init value is EmptyMap - private Map configs = Collections.emptyMap(); + private static final String DD_PREFIX = "DD_"; + private static final String OTEL_PREFIX = "OTEL_"; - // Default to production source - private SupportedConfigurationSource configSource = new SupportedConfigurationSource(); + // Cache for configs, init value is EmptyMap + private Map configs = Collections.emptyMap(); - // Collects unsupported config keys encountered in STRICT_TEST mode - private final Set unsupportedConfigs = ConcurrentHashMap.newKeySet(); + // Default to production source + private SupportedConfigurationSource configSource = new SupportedConfigurationSource(); - public static ConfigHelper get() { - return INSTANCE; - } + // Collects unsupported config keys encountered in STRICT_TEST mode + private final Set unsupportedConfigs = ConcurrentHashMap.newKeySet(); - public void setConfigInversionStrict(StrictnessPolicy configInversionStrict) { - this.configInversionStrict = configInversionStrict; - } + public static ConfigHelper get() { + return INSTANCE; + } - public StrictnessPolicy configInversionStrictFlag() { - return configInversionStrict; - } + public void setConfigInversionStrict(StrictnessPolicy configInversionStrict) { + this.configInversionStrict = configInversionStrict; + } - // Used only for testing purposes - void setConfigurationSource(SupportedConfigurationSource testSource) { - configSource = testSource; - } + public StrictnessPolicy configInversionStrictFlag() { + return configInversionStrict; + } - /** Resetting config cache. Useful for cleaning up after tests. */ - void resetCache() { - configs = Collections.emptyMap(); - } + // Used only for testing purposes + void setConfigurationSource(SupportedConfigurationSource testSource) { + configSource = testSource; + } - /** Reset all configuration data to the generated defaults. Useful for cleaning up after tests. */ - void resetToDefaults() { - configSource = new SupportedConfigurationSource(); - this.configInversionStrict = StrictnessPolicy.WARNING; - unsupportedConfigs.clear(); - resetCache(); - } + /** Resetting config cache. Useful for cleaning up after tests. */ + void resetCache() { + configs = Collections.emptyMap(); + } - /** Returns and clears the set of unsupported config keys encountered in STRICT_TEST mode. */ - public List drainUnsupportedConfigs() { - List result = new ArrayList<>(unsupportedConfigs); - unsupportedConfigs.clear(); - Collections.sort(result); - return result; - } + /** Reset all configuration data to the generated defaults. Useful for cleaning up after tests. */ + void resetToDefaults() { + configSource = new SupportedConfigurationSource(); + this.configInversionStrict = StrictnessPolicy.WARNING; + unsupportedConfigs.clear(); + resetCache(); + } - public static Map env() { - return get().getEnvironmentVariables(); - } + /** Returns and clears the set of unsupported config keys encountered in STRICT_TEST mode. */ + public List drainUnsupportedConfigs() { + List result = new ArrayList<>(unsupportedConfigs); + unsupportedConfigs.clear(); + Collections.sort(result); + return result; + } - public static String env(String key) { - return get().getEnvironmentVariable(key); - } + public static Map env() { + return get().getEnvironmentVariables(); + } - public Map getEnvironmentVariables() { - if (!configs.isEmpty()) { - return configs; + public static String env(String key) { + return get().getEnvironmentVariable(key); } - // Initial value is EmptyMap - configs = new HashMap<>(); - - Map env = EnvironmentVariables.getAll(); - for (Map.Entry entry : env.entrySet()) { - String key = entry.getKey(); - String value = entry.getValue(); - String primaryEnv = configSource.primaryEnvFromAlias(key); - if (key.startsWith(DD_PREFIX) || key.startsWith(OTEL_PREFIX) || primaryEnv != null) { - if (configSource.supported(key)) { - configs.put(key, value); - // If this environment variable is the alias of another, and we haven't processed the - // original environment variable yet, handle it here. - } else if (primaryEnv != null && !configs.containsKey(primaryEnv)) { - List aliases = configSource.getAliases(primaryEnv); - for (String alias : aliases) { - if (env.containsKey(alias)) { - configs.put(primaryEnv, env.get(alias)); - break; - } - } + public Map getEnvironmentVariables() { + if (!configs.isEmpty()) { + return configs; } - String envFromDeprecated = configSource.primaryEnvFromDeprecated(key); - if (envFromDeprecated != null) { - String warning = - "Environment variable " - + key - + " is deprecated. Please use " - + (primaryEnv != null ? primaryEnv : envFromDeprecated) - + " instead."; - log.warn(warning); + + // Initial value is EmptyMap + configs = new HashMap<>(); + + Map env = EnvironmentVariables.getAll(); + for (Map.Entry entry : env.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + String primaryEnv = configSource.primaryEnvFromAlias(key); + if (key.startsWith(DD_PREFIX) || key.startsWith(OTEL_PREFIX) || primaryEnv != null) { + if (configSource.supported(key)) { + configs.put(key, value); + // If this environment variable is the alias of another, and we haven't processed the + // original environment variable yet, handle it here. + } else if (primaryEnv != null && !configs.containsKey(primaryEnv)) { + List aliases = configSource.getAliases(primaryEnv); + for (String alias : aliases) { + if (env.containsKey(alias)) { + configs.put(primaryEnv, env.get(alias)); + break; + } + } + } + String envFromDeprecated = configSource.primaryEnvFromDeprecated(key); + if (envFromDeprecated != null) { + String warning = "Environment variable " + + key + + " is deprecated. Please use " + + (primaryEnv != null ? primaryEnv : envFromDeprecated) + + " instead."; + log.warn(warning); + } + } else { + configs.put(key, value); + } } - } else { - configs.put(key, value); - } + return configs; } - return configs; - } - public String getEnvironmentVariable(String name) { - if (configs != null && configs.containsKey(name)) { - return configs.get(name); - } + public String getEnvironmentVariable(String name) { + if (configs != null && configs.containsKey(name)) { + return configs.get(name); + } - if ((name.startsWith(DD_PREFIX) || name.startsWith(OTEL_PREFIX)) - && configSource.primaryEnvFromAlias(name) == null - && !configSource.supported(name)) { - if (configInversionStrict != StrictnessPolicy.TEST) { - ConfigInversionMetricCollectorProvider.get().setUndocumentedEnvVarMetric(name); - } - - if (configInversionStrict == StrictnessPolicy.STRICT_TEST) { - unsupportedConfigs.add(name); - throw new IllegalArgumentException( - "Unsupported configuration: " - + name - + " is not in GeneratedSupportedConfigurations. " - + "Add it to metadata/supported-configurations.json or remove the usage."); - } - } + if ((name.startsWith(DD_PREFIX) || name.startsWith(OTEL_PREFIX)) + && configSource.primaryEnvFromAlias(name) == null + && !configSource.supported(name)) { + if (configInversionStrict != StrictnessPolicy.TEST) { + ConfigInversionMetricCollectorProvider.get().setUndocumentedEnvVarMetric(name); + } + + if (configInversionStrict == StrictnessPolicy.STRICT_TEST) { + unsupportedConfigs.add(name); + throw new IllegalArgumentException("Unsupported configuration: " + + name + + " is not in GeneratedSupportedConfigurations. " + + "Add it to metadata/supported-configurations.json or remove the usage."); + } + } - String config = EnvironmentVariables.get(name); - List aliases; - if (config == null && (aliases = configSource.getAliases(name)) != null) { - for (String alias : aliases) { - String aliasValue = EnvironmentVariables.get(alias); - if (aliasValue != null) { - return aliasValue; + String config = EnvironmentVariables.get(name); + List aliases; + if (config == null && (aliases = configSource.getAliases(name)) != null) { + for (String alias : aliases) { + String aliasValue = EnvironmentVariables.get(alias); + if (aliasValue != null) { + return aliasValue; + } + } } - } + return config; } - return config; - } } diff --git a/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfiguration.java b/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfiguration.java index dfee6583d55..0a27e4276a5 100644 --- a/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfiguration.java +++ b/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfiguration.java @@ -3,42 +3,38 @@ import java.util.List; public class SupportedConfiguration { - private final String version; - private final String type; - private final String defaultValue; - private final List aliases; - private final List propertyKeys; - - public SupportedConfiguration( - String version, - String type, - String defaultValue, - List aliases, - List propertyKeys) { - this.version = version; - this.type = type; - this.defaultValue = defaultValue; - this.aliases = aliases; - this.propertyKeys = propertyKeys; - } - - public String version() { - return version; - } - - public String type() { - return type; - } - - public String defaultValue() { - return defaultValue; - } - - public List aliases() { - return aliases; - } - - public List propertyKeys() { - return propertyKeys; - } + private final String version; + private final String type; + private final String defaultValue; + private final List aliases; + private final List propertyKeys; + + public SupportedConfiguration( + String version, String type, String defaultValue, List aliases, List propertyKeys) { + this.version = version; + this.type = type; + this.defaultValue = defaultValue; + this.aliases = aliases; + this.propertyKeys = propertyKeys; + } + + public String version() { + return version; + } + + public String type() { + return type; + } + + public String defaultValue() { + return defaultValue; + } + + public List aliases() { + return aliases; + } + + public List propertyKeys() { + return propertyKeys; + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfigurationSource.java b/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfigurationSource.java index 6fda26a93d2..175b5a7aeb2 100644 --- a/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfigurationSource.java +++ b/utils/config-utils/src/main/java/datadog/trace/config/inversion/SupportedConfigurationSource.java @@ -9,39 +9,38 @@ */ class SupportedConfigurationSource { - /** - * @return Set of supported environment variable keys - */ - public boolean supported(String env) { - return GeneratedSupportedConfigurations.SUPPORTED.containsKey(env); - } + /** + * @return Set of supported environment variable keys + */ + public boolean supported(String env) { + return GeneratedSupportedConfigurations.SUPPORTED.containsKey(env); + } - /** - * @return List of aliases for an environment variable - */ - public List getAliases(String env) { - return GeneratedSupportedConfigurations.ALIASES.getOrDefault(env, Collections.emptyList()); - } + /** + * @return List of aliases for an environment variable + */ + public List getAliases(String env) { + return GeneratedSupportedConfigurations.ALIASES.getOrDefault(env, Collections.emptyList()); + } - /** - * @return Primary environment variable for a queried alias - */ - public String primaryEnvFromAlias(String alias) { - return GeneratedSupportedConfigurations.ALIAS_MAPPING.getOrDefault(alias, null); - } + /** + * @return Primary environment variable for a queried alias + */ + public String primaryEnvFromAlias(String alias) { + return GeneratedSupportedConfigurations.ALIAS_MAPPING.getOrDefault(alias, null); + } - /** - * @return Map of deprecated configurations - */ - public String primaryEnvFromDeprecated(String deprecated) { - return GeneratedSupportedConfigurations.DEPRECATED.getOrDefault(deprecated, null); - } + /** + * @return Map of deprecated configurations + */ + public String primaryEnvFromDeprecated(String deprecated) { + return GeneratedSupportedConfigurations.DEPRECATED.getOrDefault(deprecated, null); + } - /** - * @return Mapping from propertyKey to configuration - */ - public String envFromTelemetryKey(String telemetryKey) { - return GeneratedSupportedConfigurations.REVERSE_PROPERTY_KEYS_MAP.getOrDefault( - telemetryKey, null); - } + /** + * @return Mapping from propertyKey to configuration + */ + public String envFromTelemetryKey(String telemetryKey) { + return GeneratedSupportedConfigurations.REVERSE_PROPERTY_KEYS_MAP.getOrDefault(telemetryKey, null); + } } diff --git a/utils/config-utils/src/main/java/datadog/trace/util/ConfigStrings.java b/utils/config-utils/src/main/java/datadog/trace/util/ConfigStrings.java index e9d38ea4c72..3feb8311c82 100644 --- a/utils/config-utils/src/main/java/datadog/trace/util/ConfigStrings.java +++ b/utils/config-utils/src/main/java/datadog/trace/util/ConfigStrings.java @@ -5,87 +5,87 @@ public final class ConfigStrings { - private ConfigStrings() {} + private ConfigStrings() {} - public static String toEnvVar(String string) { - return string.replace('.', '_').replace('-', '_').toUpperCase(Locale.ROOT); - } - - public static String toEnvVarLowerCase(String string) { - return string.replace('.', '_').replace('-', '_').toLowerCase(Locale.ROOT); - } + public static String toEnvVar(String string) { + return string.replace('.', '_').replace('-', '_').toUpperCase(Locale.ROOT); + } - /** - * Converts the property name, e.g. 'service.name' into a public environment variable name, e.g. - * `DD_SERVICE_NAME`. - * - * @param setting The setting name, e.g. `service.name` - * @return The public facing environment variable name - */ - @Nonnull - public static String propertyNameToEnvironmentVariableName(final String setting) { - return "DD_" + toEnvVar(setting); - } + public static String toEnvVarLowerCase(String string) { + return string.replace('.', '_').replace('-', '_').toLowerCase(Locale.ROOT); + } - /** - * Converts a config key to its canonical {@code DD_} or {@code OTEL_} environment-variable form. - * - * @param key The config key in any form - * @return The canonical environment variable name - */ - @Nonnull - public static String toCanonicalEnvVar(final String key) { - if (key.startsWith("otel.") || key.startsWith("OTEL_")) { - return toEnvVar(key); + /** + * Converts the property name, e.g. 'service.name' into a public environment variable name, e.g. + * `DD_SERVICE_NAME`. + * + * @param setting The setting name, e.g. `service.name` + * @return The public facing environment variable name + */ + @Nonnull + public static String propertyNameToEnvironmentVariableName(final String setting) { + return "DD_" + toEnvVar(setting); } - final String env = toEnvVar(key); - return env.startsWith("DD_") ? env : "DD_" + env; - } - /** - * Converts the property name, e.g. 'service.name' into a public system property name, e.g. - * `dd.service.name`. - * - * @param setting The setting name, e.g. `service.name` - * @return The public facing system property name - */ - @Nonnull - public static String propertyNameToSystemPropertyName(final String setting) { - return "dd." + setting; - } + /** + * Converts a config key to its canonical {@code DD_} or {@code OTEL_} environment-variable form. + * + * @param key The config key in any form + * @return The canonical environment variable name + */ + @Nonnull + public static String toCanonicalEnvVar(final String key) { + if (key.startsWith("otel.") || key.startsWith("OTEL_")) { + return toEnvVar(key); + } + final String env = toEnvVar(key); + return env.startsWith("DD_") ? env : "DD_" + env; + } - @Nonnull - public static String normalizedHeaderTag(String str) { - if (str.isEmpty()) { - return ""; + /** + * Converts the property name, e.g. 'service.name' into a public system property name, e.g. + * `dd.service.name`. + * + * @param setting The setting name, e.g. `service.name` + * @return The public facing system property name + */ + @Nonnull + public static String propertyNameToSystemPropertyName(final String setting) { + return "dd." + setting; } - StringBuilder builder = new StringBuilder(str.length()); - int firstNonWhiteSpace = -1; - int lastNonWhitespace = -1; - for (int i = 0; i < str.length(); i++) { - char c = str.charAt(i); - if (Character.isWhitespace(c)) { - builder.append('_'); - } else { - firstNonWhiteSpace = firstNonWhiteSpace == -1 ? i : firstNonWhiteSpace; - lastNonWhitespace = i; - if (Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '/') { - builder.append(Character.toLowerCase(c)); + + @Nonnull + public static String normalizedHeaderTag(String str) { + if (str.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(str.length()); + int firstNonWhiteSpace = -1; + int lastNonWhitespace = -1; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (Character.isWhitespace(c)) { + builder.append('_'); + } else { + firstNonWhiteSpace = firstNonWhiteSpace == -1 ? i : firstNonWhiteSpace; + lastNonWhitespace = i; + if (Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '/') { + builder.append(Character.toLowerCase(c)); + } else { + builder.append('_'); + } + } + } + if (firstNonWhiteSpace == -1) { + return ""; } else { - builder.append('_'); + str = builder.substring(firstNonWhiteSpace, lastNonWhitespace + 1); + return str; } - } } - if (firstNonWhiteSpace == -1) { - return ""; - } else { - str = builder.substring(firstNonWhiteSpace, lastNonWhitespace + 1); - return str; - } - } - @Nonnull - public static String trim(final String string) { - return null == string ? "" : string.trim(); - } + @Nonnull + public static String trim(final String string) { + return null == string ? "" : string.trim(); + } } diff --git a/utils/config-utils/src/main/java/datadog/yaml/YamlParser.java b/utils/config-utils/src/main/java/datadog/yaml/YamlParser.java index 1284ef0833a..44c76eff243 100644 --- a/utils/config-utils/src/main/java/datadog/yaml/YamlParser.java +++ b/utils/config-utils/src/main/java/datadog/yaml/YamlParser.java @@ -4,15 +4,15 @@ import org.snakeyaml.engine.v2.api.LoadSettings; public class YamlParser { - /** - * Parses YAML content. Duplicate keys are not allowed and will result in a runtime exception.. - * - * @param content - text context to be parsed as YAML - * @return - a parsed representation as a composition of map and list objects. - */ - public static Object parse(String content) { - LoadSettings settings = LoadSettings.builder().build(); - Load yaml = new Load(settings); - return yaml.loadFromString(content); - } + /** + * Parses YAML content. Duplicate keys are not allowed and will result in a runtime exception.. + * + * @param content - text context to be parsed as YAML + * @return - a parsed representation as a composition of map and list objects. + */ + public static Object parse(String content) { + LoadSettings settings = LoadSettings.builder().build(); + Load yaml = new Load(settings); + return yaml.loadFromString(content); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java b/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java index 0b40400d963..78a9e1961d7 100644 --- a/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java @@ -10,93 +10,96 @@ public class ConfigSettingTest { - @TableTest({ - "scenario | key1 | value1 | origin1 | key2 | value2 | origin2 | expectedEqual", - "equal | key | value | DEFAULT | key | value | DEFAULT | true ", - "different key | key | value | ENV | key2 | value | ENV | false ", - "different value | key | value2 | JVM_PROP | key | value | JVM_PROP | false ", - "different origin | key | value | ENV | key | value | DEFAULT | false " - }) - void supportsEqualityCheck( - String key1, - Object value1, - ConfigOrigin origin1, - String key2, - Object value2, - ConfigOrigin origin2, - boolean expectedEqual) { - ConfigSetting cs1 = ConfigSetting.of(key1, value1, origin1); - ConfigSetting cs2 = ConfigSetting.of(key2, value2, origin2); + @TableTest({ + "scenario | key1 | value1 | origin1 | key2 | value2 | origin2 | expectedEqual", + "equal | key | value | DEFAULT | key | value | DEFAULT | true ", + "different key | key | value | ENV | key2 | value | ENV | false ", + "different value | key | value2 | JVM_PROP | key | value | JVM_PROP | false ", + "different origin | key | value | ENV | key | value | DEFAULT | false " + }) + void supportsEqualityCheck( + String key1, + Object value1, + ConfigOrigin origin1, + String key2, + Object value2, + ConfigOrigin origin2, + boolean expectedEqual) { + ConfigSetting cs1 = ConfigSetting.of(key1, value1, origin1); + ConfigSetting cs2 = ConfigSetting.of(key2, value2, origin2); - if (expectedEqual) { - assertEquals(cs1.hashCode(), cs2.hashCode()); - assertEquals(cs1, cs2); - assertEquals(cs2, cs1); - assertEquals(cs1.toString(), cs2.toString()); - } else { - assertNotEquals(cs1.hashCode(), cs2.hashCode()); - assertNotEquals(cs1, cs2); - assertNotEquals(cs2, cs1); - assertNotEquals(cs1.toString(), cs2.toString()); + if (expectedEqual) { + assertEquals(cs1.hashCode(), cs2.hashCode()); + assertEquals(cs1, cs2); + assertEquals(cs2, cs1); + assertEquals(cs1.toString(), cs2.toString()); + } else { + assertNotEquals(cs1.hashCode(), cs2.hashCode()); + assertNotEquals(cs1, cs2); + assertNotEquals(cs2, cs1); + assertNotEquals(cs1.toString(), cs2.toString()); + } } - } - @TableTest({ - "scenario | key | value | filteredValue", - "api key property | api-key | somevalue | ", - "api key system prop | dd.api-key | somevalue | ", - "api key env var | DD_API_KEY | somevalue | ", - "application key | application-key | somevalue | ", - "application alias | DD_APP_KEY | somevalue | ", - "otlp traces headers | otlp.traces.headers | somevalue | ", - "profiling API key | profiling.api-key | somevalue | ", - "profiling APIKEY | profiling.apikey | somevalue | ", - "profiling API env | DD_PROFILING_API_KEY | somevalue | ", - "profiling APIKEY env | DD_PROFILING_APIKEY | somevalue | ", - "proxy password | profiling.proxy.password | somevalue | ", - "session token prop | test.agent.session.token | somevalue | ", - "session token env | DD_TEST_AGENT_SESSION_TOKEN | somevalue | ", - "non-sensitive key | some.other.key | somevalue | somevalue " - }) - void filtersKeyValues(String key, String value, String filteredValue) { - assertEquals(filteredValue, ConfigSetting.of(key, value, ConfigOrigin.DEFAULT).stringValue()); - } + @TableTest({ + "scenario | key | value | filteredValue", + "api key property | api-key | somevalue | ", + "api key system prop | dd.api-key | somevalue | ", + "api key env var | DD_API_KEY | somevalue | ", + "application key | application-key | somevalue | ", + "application alias | DD_APP_KEY | somevalue | ", + "otlp traces headers | otlp.traces.headers | somevalue | ", + "profiling API key | profiling.api-key | somevalue | ", + "profiling APIKEY | profiling.apikey | somevalue | ", + "profiling API env | DD_PROFILING_API_KEY | somevalue | ", + "profiling APIKEY env | DD_PROFILING_APIKEY | somevalue | ", + "proxy password | profiling.proxy.password | somevalue | ", + "session token prop | test.agent.session.token | somevalue | ", + "session token env | DD_TEST_AGENT_SESSION_TOKEN | somevalue | ", + "non-sensitive key | some.other.key | somevalue | somevalue " + }) + void filtersKeyValues(String key, String value, String filteredValue) { + assertEquals( + filteredValue, + ConfigSetting.of(key, value, ConfigOrigin.DEFAULT).stringValue()); + } - @TableTest({ - "scenario | key | normalized ", - "property name | api-key | DD_API_KEY ", - "dd env var | DD_PROFILING_API_KEY | DD_PROFILING_API_KEY", - "otel env var | OTEL_SERVICE_NAME | OTEL_SERVICE_NAME " - }) - void storesNormalizedKeyName(String key, String normalized) { - assertEquals(normalized, ConfigSetting.of(key, "v", ConfigOrigin.DEFAULT).key); - } + @TableTest({ + "scenario | key | normalized ", + "property name | api-key | DD_API_KEY ", + "dd env var | DD_PROFILING_API_KEY | DD_PROFILING_API_KEY", + "otel env var | OTEL_SERVICE_NAME | OTEL_SERVICE_NAME " + }) + void storesNormalizedKeyName(String key, String normalized) { + assertEquals(normalized, ConfigSetting.of(key, "v", ConfigOrigin.DEFAULT).key); + } - @TableTest({ - "scenario | value | rendered", - "null | | ", - "boolean | true | true ", - "boolean | false | false ", - "integer | 1 | 1 ", - "double | 1.0 | 1.0 ", - "float | 2.33f | 2.33 ", - "string | string | string " - }) - void supportBasicTypes(@ConvertWith(BoxedValueConverter.class) Object value, String rendered) { - assertEquals(rendered, ConfigSetting.of("key", value, ConfigOrigin.DEFAULT).stringValue()); - } + @TableTest({ + "scenario | value | rendered", + "null | | ", + "boolean | true | true ", + "boolean | false | false ", + "integer | 1 | 1 ", + "double | 1.0 | 1.0 ", + "float | 2.33f | 2.33 ", + "string | string | string " + }) + void supportBasicTypes(@ConvertWith(BoxedValueConverter.class) Object value, String rendered) { + assertEquals( + rendered, ConfigSetting.of("key", value, ConfigOrigin.DEFAULT).stringValue()); + } - @TableTest({ - "scenario | value | rendered ", - "iterable | [1, 2, 3] | 1,2,3 ", - "decimals | [1.0, 22.23, 3.1415] | 1.0,22.23,3.1415 ", - "map | [a: 1, b: 2] | a:1,b:2 ", - "empty list | [] | '' ", - "empty map | [:] | '' ", - "bitset ranges | bits(33, 200-300, 303, 400-500) | 33,200-300,303,400-500" - }) - void convertIterableMapAndBitSetToString( - @ConvertWith(ConfigValueConverter.class) Object value, String rendered) { - assertEquals(rendered, ConfigSetting.of("key", value, ConfigOrigin.DEFAULT).stringValue()); - } + @TableTest({ + "scenario | value | rendered ", + "iterable | [1, 2, 3] | 1,2,3 ", + "decimals | [1.0, 22.23, 3.1415] | 1.0,22.23,3.1415 ", + "map | [a: 1, b: 2] | a:1,b:2 ", + "empty list | [] | '' ", + "empty map | [:] | '' ", + "bitset ranges | bits(33, 200-300, 303, 400-500) | 33,200-300,303,400-500" + }) + void convertIterableMapAndBitSetToString(@ConvertWith(ConfigValueConverter.class) Object value, String rendered) { + assertEquals( + rendered, ConfigSetting.of("key", value, ConfigOrigin.DEFAULT).stringValue()); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/api/env/CapturedEnvironmentTest.java b/utils/config-utils/src/test/java/datadog/trace/api/env/CapturedEnvironmentTest.java index 4b0c22204b7..00dfdc12f11 100644 --- a/utils/config-utils/src/test/java/datadog/trace/api/env/CapturedEnvironmentTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/api/env/CapturedEnvironmentTest.java @@ -14,91 +14,87 @@ public class CapturedEnvironmentTest { - @TableTest({ - "scenario | sunJavaCommand | envVars | expectedServiceName ", - "null command | null | [:] | ", - "empty command | '' | [:] | ", - "all blanks | ' ' | [:] | ", - "class in command | org.example.App -Dfoo=bar arg2 arg3 | [:] | org.example.App ", - "jar in command | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [:] | example ", - "real sun command | | [:] | datadog.trace.api.env.CapturedEnvironmentTest$ServiceNamePrinter", - "azure site name | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [DD_AZURE_APP_SERVICES: 1, WEBSITE_SITE_NAME: siteService] | siteService ", - "site name no azure | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [WEBSITE_SITE_NAME: siteService] | example ", - "azure flag no site | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [DD_AZURE_APP_SERVICES: true] | example " - }) - void capturesServiceName( - String sunJavaCommand, Map envVars, String expectedServiceName) - throws IOException, InterruptedException { - assertEquals(expectedServiceName, forkAndRunProperties(sunJavaCommand, envVars)); - } - - private static String forkAndRunProperties(String arg, Map envVars) - throws IOException, InterruptedException { - // Build the command to run a new Java process - List command = new ArrayList<>(); - command.add(System.getProperty("java.home") + separator + "bin" + separator + "java"); - command.add("-cp"); - command.add(System.getProperty("java.class.path")); - command.add(ServiceNamePrinter.class.getName()); - if (arg != null) { - command.add(arg); + @TableTest({ + "scenario | sunJavaCommand | envVars | expectedServiceName ", + "null command | null | [:] | ", + "empty command | '' | [:] | ", + "all blanks | ' ' | [:] | ", + "class in command | org.example.App -Dfoo=bar arg2 arg3 | [:] | org.example.App ", + "jar in command | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [:] | example ", + "real sun command | | [:] | datadog.trace.api.env.CapturedEnvironmentTest$ServiceNamePrinter", + "azure site name | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [DD_AZURE_APP_SERVICES: 1, WEBSITE_SITE_NAME: siteService] | siteService ", + "site name no azure | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [WEBSITE_SITE_NAME: siteService] | example ", + "azure flag no site | foo/bar/example.jar -Dfoo=bar arg2 arg3 | [DD_AZURE_APP_SERVICES: true] | example " + }) + void capturesServiceName(String sunJavaCommand, Map envVars, String expectedServiceName) + throws IOException, InterruptedException { + assertEquals(expectedServiceName, forkAndRunProperties(sunJavaCommand, envVars)); } - // Start the process - ProcessBuilder processBuilder = new ProcessBuilder(command); - processBuilder.environment().putAll(envVars); - Process process = processBuilder.start(); - // Read and parse output and error streams - String serviceName = ""; - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - if (!serviceName.isEmpty()) { - serviceName += "\n"; + + private static String forkAndRunProperties(String arg, Map envVars) + throws IOException, InterruptedException { + // Build the command to run a new Java process + List command = new ArrayList<>(); + command.add(System.getProperty("java.home") + separator + "bin" + separator + "java"); + command.add("-cp"); + command.add(System.getProperty("java.class.path")); + command.add(ServiceNamePrinter.class.getName()); + if (arg != null) { + command.add(arg); } - serviceName += line; - } - } - if ("null".equals(serviceName)) { - serviceName = null; - } - String error = ""; - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(process.getErrorStream()))) { - String line; - while ((line = reader.readLine()) != null) { - error += line + "\n"; - } - } - // Wait for the process to complete - int exitCode = process.waitFor(); - // Dumping state on error - if (exitCode != 0) { - System.out.println( - "Error printing service name. Exit code " - + exitCode - + " with service name: '" - + serviceName - + "' and error:\n" - + error); - throw new IllegalStateException("Process should exit without error"); + // Start the process + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.environment().putAll(envVars); + Process process = processBuilder.start(); + // Read and parse output and error streams + String serviceName = ""; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (!serviceName.isEmpty()) { + serviceName += "\n"; + } + serviceName += line; + } + } + if ("null".equals(serviceName)) { + serviceName = null; + } + String error = ""; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = reader.readLine()) != null) { + error += line + "\n"; + } + } + // Wait for the process to complete + int exitCode = process.waitFor(); + // Dumping state on error + if (exitCode != 0) { + System.out.println("Error printing service name. Exit code " + + exitCode + + " with service name: '" + + serviceName + + "' and error:\n" + + error); + throw new IllegalStateException("Process should exit without error"); + } + return serviceName; } - return serviceName; - } - public static class ServiceNamePrinter { - public static void main(String[] args) { - if (args.length > 0) { - String sunJavaCommand = args[0]; - if ("null".equals(sunJavaCommand)) { - System.clearProperty("sun.java.command"); - } else { - System.setProperty("sun.java.command", sunJavaCommand); + public static class ServiceNamePrinter { + public static void main(String[] args) { + if (args.length > 0) { + String sunJavaCommand = args[0]; + if ("null".equals(sunJavaCommand)) { + System.clearProperty("sun.java.command"); + } else { + System.setProperty("sun.java.command", sunJavaCommand); + } + } + CapturedEnvironment capturedEnv = CapturedEnvironment.get(); + Map props = capturedEnv.getProperties(); + System.out.println(props.get(GeneralConfig.SERVICE_NAME)); } - } - CapturedEnvironment capturedEnv = CapturedEnvironment.get(); - Map props = capturedEnv.getProperties(); - System.out.println(props.get(GeneralConfig.SERVICE_NAME)); } - } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsInjectorTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsInjectorTest.java index f70c00fce02..19ad6acf342 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsInjectorTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsInjectorTest.java @@ -7,19 +7,19 @@ public class AgentArgsInjectorTest { - @AfterEach - void clearInjectedProperties() { - System.clearProperty("arg1"); - System.clearProperty("arg2"); - } + @AfterEach + void clearInjectedProperties() { + System.clearProperty("arg1"); + System.clearProperty("arg2"); + } - @Test - void injectsAgentArgumentsAsSystemProperties() { - String agentArgs = "arg1=value1,arg2=value2"; + @Test + void injectsAgentArgumentsAsSystemProperties() { + String agentArgs = "arg1=value1,arg2=value2"; - AgentArgsInjector.injectAgentArgsConfig(agentArgs); + AgentArgsInjector.injectAgentArgsConfig(agentArgs); - assertEquals("value1", System.getProperty("arg1")); - assertEquals("value2", System.getProperty("arg2")); - } + assertEquals("value1", System.getProperty("arg1")); + assertEquals("value2", System.getProperty("arg2")); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsParserTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsParserTest.java index f89dd8025b5..fcf23a093d1 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsParserTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/AgentArgsParserTest.java @@ -9,27 +9,27 @@ public class AgentArgsParserTest { - @TableTest({ - "scenario | args | size | key | expectedValue ", - "single argument | key1=value1 | 1 | key1 | value1 ", - "multiple arguments | key1=value1,key2=value2 | 2 | key2 | value2 ", - "argument with spaces | key=value with spaces | 1 | key | value with spaces" - }) - void parsesArguments(String args, int size, String key, String expectedValue) { - Map properties = AgentArgsParser.parseAgentArgs(args); + @TableTest({ + "scenario | args | size | key | expectedValue ", + "single argument | key1=value1 | 1 | key1 | value1 ", + "multiple arguments | key1=value1,key2=value2 | 2 | key2 | value2 ", + "argument with spaces | key=value with spaces | 1 | key | value with spaces" + }) + void parsesArguments(String args, int size, String key, String expectedValue) { + Map properties = AgentArgsParser.parseAgentArgs(args); - assertNotNull(properties); - assertEquals(size, properties.size()); - assertEquals(expectedValue, properties.get(key)); - } + assertNotNull(properties); + assertEquals(size, properties.size()); + assertEquals(expectedValue, properties.get(key)); + } - @TableTest({ - "scenario | args ", - "null string | ", - "empty string | '' ", - "malformed | key=value,,,==" - }) - void returnsNullForInvalidArguments(String args) { - assertNull(AgentArgsParser.parseAgentArgs(args)); - } + @TableTest({ + "scenario | args ", + "null string | ", + "empty string | '' ", + "malformed | key=value,,,==" + }) + void returnsNullForInvalidArguments(String args) { + assertNull(AgentArgsParser.parseAgentArgs(args)); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/ConfigConverterTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/ConfigConverterTest.java index 863902ece26..6d067de8c18 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/ConfigConverterTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/ConfigConverterTest.java @@ -11,155 +11,153 @@ public class ConfigConverterTest { - @TableTest({ - "scenario | stringValue | expectedConvertedValue", - "true | true | true ", - "TRUE | TRUE | true ", - "True | True | true ", - "1 | 1 | true ", - "false | false | false ", - "null | | ", - "empty string | '' | ", - "0 | 0 | false " - }) - void convertBooleanProperties(String stringValue, Boolean expectedConvertedValue) { - assertEquals(expectedConvertedValue, ConfigConverter.valueOf(stringValue, Boolean.class)); - } + @TableTest({ + "scenario | stringValue | expectedConvertedValue", + "true | true | true ", + "TRUE | TRUE | true ", + "True | True | true ", + "1 | 1 | true ", + "false | false | false ", + "null | | ", + "empty string | '' | ", + "0 | 0 | false " + }) + void convertBooleanProperties(String stringValue, Boolean expectedConvertedValue) { + assertEquals(expectedConvertedValue, ConfigConverter.valueOf(stringValue, Boolean.class)); + } - @TableTest({ - "invalidValue", - "42.42 ", - "tru ", - "truee ", - "'true ' ", - "' true' ", - "' true ' ", - "' true ' ", - "notABool ", - "yes ", - "no ", - "on ", - "off " - }) - void convertBooleanPropertiesThrowsExceptionForInvalidValues(String invalidValue) { - ConfigConverter.InvalidBooleanValueException exception = - assertThrows( - ConfigConverter.InvalidBooleanValueException.class, - () -> ConfigConverter.valueOf(invalidValue, Boolean.class)); - assertTrue(exception.getMessage().contains("Invalid boolean value:")); - } + @TableTest({ + "invalidValue", + "42.42 ", + "tru ", + "truee ", + "'true ' ", + "' true' ", + "' true ' ", + "' true ' ", + "notABool ", + "yes ", + "no ", + "on ", + "off " + }) + void convertBooleanPropertiesThrowsExceptionForInvalidValues(String invalidValue) { + ConfigConverter.InvalidBooleanValueException exception = assertThrows( + ConfigConverter.InvalidBooleanValueException.class, + () -> ConfigConverter.valueOf(invalidValue, Boolean.class)); + assertTrue(exception.getMessage().contains("Invalid boolean value:")); + } - @TableTest({ - "mapString | expected ", - "a:1, a:2, a:3 | [a: '3'] ", - "a:b,c:d,e: | [a: b, c: d] ", - "a:1 a:2 a:3 | [a: '3'] ", - "a:b c:d e: | [a: b, c: d] ", - "a:a; | [a: a;] ", - "a:1, a:2, a:3 | [a: '3'] ", - "a:1 a:2 a:3 | [a: '3'] ", - "a:b,c:d,e: | [a: b, c: d] ", - "a:b c:d e: | [a: b, c: d] ", - "'key 1!:va|ue_1,' | [key 1!: 'va|ue_1'] ", - "'key 1!:va|ue_1 ' | [key 1!: 'va|ue_1'] ", - "' key1 :value1 ,\t key2: value2' | [key1: value1, key2: value2] ", - "a:b, b:c, c:d, d: e | [a: b, b: c, c: d, d: e] ", - "key1 :value1 \t key2: value2 | [key1: value1, key2: value2] ", - "dyno:web.1 dynotype:web appname:****** | [dyno: web.1, dynotype: web, appname: ******] ", - "is:val:id | [is: 'val:id'] ", - "a:b,is:val:id,x:y | [a: b, is: 'val:id', x: y] ", - "a:b:c:d | [a: 'b:c:d'] ", - "fooa:barb, foob:barc, fooc: bard, food: bare, | [fooa: barb, foob: barc, fooc: bard, food: bare]", - "a:b=c=d | [a: b=c=d] ", - "a: | [:] ", - "a:b,c,d | [:] ", - "a:b,c,d,k:v | [:] ", - "'' | [:] ", - "1 | [:] ", - "a | [:] ", - "a,1 | [:] ", - "!a | [:] ", - "' ' | [:] ", - ",,,, | [:] ", - ":,:,:,: | [:] ", - "': : : : ' | [:] ", - ":::: | [:] ", - "key1:val1 with_space:and_colon, key2:val2 | [:] " - }) - void parseMapProperly(String mapString, Map expected) { - assertEquals(expected, ConfigConverter.parseMap(mapString, "test")); - } + @TableTest({ + "mapString | expected ", + "a:1, a:2, a:3 | [a: '3'] ", + "a:b,c:d,e: | [a: b, c: d] ", + "a:1 a:2 a:3 | [a: '3'] ", + "a:b c:d e: | [a: b, c: d] ", + "a:a; | [a: a;] ", + "a:1, a:2, a:3 | [a: '3'] ", + "a:1 a:2 a:3 | [a: '3'] ", + "a:b,c:d,e: | [a: b, c: d] ", + "a:b c:d e: | [a: b, c: d] ", + "'key 1!:va|ue_1,' | [key 1!: 'va|ue_1'] ", + "'key 1!:va|ue_1 ' | [key 1!: 'va|ue_1'] ", + "' key1 :value1 ,\t key2: value2' | [key1: value1, key2: value2] ", + "a:b, b:c, c:d, d: e | [a: b, b: c, c: d, d: e] ", + "key1 :value1 \t key2: value2 | [key1: value1, key2: value2] ", + "dyno:web.1 dynotype:web appname:****** | [dyno: web.1, dynotype: web, appname: ******] ", + "is:val:id | [is: 'val:id'] ", + "a:b,is:val:id,x:y | [a: b, is: 'val:id', x: y] ", + "a:b:c:d | [a: 'b:c:d'] ", + "fooa:barb, foob:barc, fooc: bard, food: bare, | [fooa: barb, foob: barc, fooc: bard, food: bare]", + "a:b=c=d | [a: b=c=d] ", + "a: | [:] ", + "a:b,c,d | [:] ", + "a:b,c,d,k:v | [:] ", + "'' | [:] ", + "1 | [:] ", + "a | [:] ", + "a,1 | [:] ", + "!a | [:] ", + "' ' | [:] ", + ",,,, | [:] ", + ":,:,:,: | [:] ", + "': : : : ' | [:] ", + ":::: | [:] ", + "key1:val1 with_space:and_colon, key2:val2 | [:] " + }) + void parseMapProperly(String mapString, Map expected) { + assertEquals(expected, ConfigConverter.parseMap(mapString, "test")); + } - @TableTest({ - "mapString | separator | expected ", - "a=1, a=2, a=3 | = | [a: '3'] ", - "a=b,c=d,e= | = | [a: b, c: d] ", - "a;b,c;d,e; | ; | [a: b, c: d] ", - "a=1 a=2 a=3 | = | [a: '3'] ", - "a=b c=d e= | = | [a: b, c: d] ", - "a=b=c=d | = | [a: b=c=d] ", - "fooa=barb, foob=barc, fooc= bard, food= bare, | = | [fooa: barb, foob: barc, fooc: bard, food: bare]", - "a=b:c:d | = | [a: 'b:c:d'] ", - "a= | = | [:] ", - "==== | = | [:] " - }) - void parseMapWithSeparator(String mapString, char separator, Map expected) { - assertEquals(expected, ConfigConverter.parseMap(mapString, "test", separator)); - } + @TableTest({ + "mapString | separator | expected ", + "a=1, a=2, a=3 | = | [a: '3'] ", + "a=b,c=d,e= | = | [a: b, c: d] ", + "a;b,c;d,e; | ; | [a: b, c: d] ", + "a=1 a=2 a=3 | = | [a: '3'] ", + "a=b c=d e= | = | [a: b, c: d] ", + "a=b=c=d | = | [a: b=c=d] ", + "fooa=barb, foob=barc, fooc= bard, food= bare, | = | [fooa: barb, foob: barc, fooc: bard, food: bare]", + "a=b:c:d | = | [a: 'b:c:d'] ", + "a= | = | [:] ", + "==== | = | [:] " + }) + void parseMapWithSeparator(String mapString, char separator, Map expected) { + assertEquals(expected, ConfigConverter.parseMap(mapString, "test", separator)); + } - @TableTest({ - "mapString | separator | expected ", - "key1:value1,key2:value2 | : | [key1: value1, key2: value2] ", - "key1:value1 key2:value2 | : | [key1: value1, key2: value2] ", - "env:test aKey:aVal bKey:bVal cKey: | : | [env: test, aKey: aVal, bKey: bVal, cKey: ''] ", - "env:test,aKey:aVal,bKey:bVal,cKey: | : | [env: test, aKey: aVal, bKey: bVal, cKey: ''] ", - "env:test,aKey:aVal bKey:bVal cKey: | : | [env: test, aKey: 'aVal bKey:bVal cKey:'] ", - "env:test bKey :bVal dKey: dVal cKey: | : | [env: test, bKey: '', dKey: '', dVal: '', cKey: '']", - "env :test, aKey : aVal bKey:bVal cKey: | : | [env: test, aKey: 'aVal bKey:bVal cKey:'] ", - "env:keyWithA:Semicolon bKey:bVal cKey | : | [env: 'keyWithA:Semicolon', bKey: bVal, cKey: ''] ", - "env:keyWith: , , Lots:Of:Semicolons | : | [env: 'keyWith:', Lots: 'Of:Semicolons'] ", - "a:b,c,d | : | [a: b, c: '', d: ''] ", - "a,1 | : | [a: '', 1: ''] ", - "a:b:c:d | : | [a: 'b:c:d'] ", - "noDelimiters | : | [noDelimiters: ''] ", - "' ' | : | [:] ", - ",,,,,,,,,,,, | : | [:] ", - "', , , , , , ' | : | [:] " - }) - void parsingMapWithListOfArgSeparatorsForWithKeyValueSeparator( - String mapString, char separator, Map expected) { - // testing parsing for DD_TAGS - List separatorList = Arrays.asList(',', ' '); - assertEquals(expected, ConfigConverter.parseTraceTagsMap(mapString, separator, separatorList)); - } + @TableTest({ + "mapString | separator | expected ", + "key1:value1,key2:value2 | : | [key1: value1, key2: value2] ", + "key1:value1 key2:value2 | : | [key1: value1, key2: value2] ", + "env:test aKey:aVal bKey:bVal cKey: | : | [env: test, aKey: aVal, bKey: bVal, cKey: ''] ", + "env:test,aKey:aVal,bKey:bVal,cKey: | : | [env: test, aKey: aVal, bKey: bVal, cKey: ''] ", + "env:test,aKey:aVal bKey:bVal cKey: | : | [env: test, aKey: 'aVal bKey:bVal cKey:'] ", + "env:test bKey :bVal dKey: dVal cKey: | : | [env: test, bKey: '', dKey: '', dVal: '', cKey: '']", + "env :test, aKey : aVal bKey:bVal cKey: | : | [env: test, aKey: 'aVal bKey:bVal cKey:'] ", + "env:keyWithA:Semicolon bKey:bVal cKey | : | [env: 'keyWithA:Semicolon', bKey: bVal, cKey: ''] ", + "env:keyWith: , , Lots:Of:Semicolons | : | [env: 'keyWith:', Lots: 'Of:Semicolons'] ", + "a:b,c,d | : | [a: b, c: '', d: ''] ", + "a,1 | : | [a: '', 1: ''] ", + "a:b:c:d | : | [a: 'b:c:d'] ", + "noDelimiters | : | [noDelimiters: ''] ", + "' ' | : | [:] ", + ",,,,,,,,,,,, | : | [:] ", + "', , , , , , ' | : | [:] " + }) + void parsingMapWithListOfArgSeparatorsForWithKeyValueSeparator( + String mapString, char separator, Map expected) { + // testing parsing for DD_TAGS + List separatorList = Arrays.asList(',', ' '); + assertEquals(expected, ConfigConverter.parseTraceTagsMap(mapString, separator, separatorList)); + } - @TableTest({ - "mapString | expected | lowercaseKeys | defaultPrefix ", - "header1:one,header2:two | [header1: one, header2: two] | false | '' ", - "header1:one, header2:two | [header1: one, header2: two] | false | '' ", - "header1,header2:two | [header1: header1, header2: two] | false | '' ", - "Header1:one,header2:two | [header1: one, header2: two] | true | '' ", - "'\"header1:one,header2:two\"' | ['\"header1': one, header2: 'two\"'] | true | '' ", - "header1 | [header1: header1] | true | '' ", - ",header1:tag | [header1: tag] | true | '' ", - "header1:tag, | [header1: tag] | true | '' ", - "header:tag:value | [header: 'tag:value'] | true | '' ", - "'' | [:] | true | '' ", - " | [:] | true | '' ", - "* | [*: 'datadog.response.headers.'] | true | datadog.response.headers", - "*: | [:] | true | datadog.response.headers", - "*,header1:tag | [*: 'datadog.response.headers.'] | true | datadog.response.headers", - "header1:tag,* | [*: 'datadog.response.headers.'] | true | datadog.response.headers", - "1header,header2:two | [:] | true | '' ", - "header::tag | [:] | true | '' ", - ":tag | [:] | true | '' ", - "header:tag,:tag | [:] | true | '' " - }) - void testParseMapWithOptionalMappings( - String mapString, Map expected, boolean lowercaseKeys, String defaultPrefix) { - assertEquals( - expected, - ConfigConverter.parseMapWithOptionalMappings( - mapString, "test", defaultPrefix, lowercaseKeys)); - } + @TableTest({ + "mapString | expected | lowercaseKeys | defaultPrefix ", + "header1:one,header2:two | [header1: one, header2: two] | false | '' ", + "header1:one, header2:two | [header1: one, header2: two] | false | '' ", + "header1,header2:two | [header1: header1, header2: two] | false | '' ", + "Header1:one,header2:two | [header1: one, header2: two] | true | '' ", + "'\"header1:one,header2:two\"' | ['\"header1': one, header2: 'two\"'] | true | '' ", + "header1 | [header1: header1] | true | '' ", + ",header1:tag | [header1: tag] | true | '' ", + "header1:tag, | [header1: tag] | true | '' ", + "header:tag:value | [header: 'tag:value'] | true | '' ", + "'' | [:] | true | '' ", + " | [:] | true | '' ", + "* | [*: 'datadog.response.headers.'] | true | datadog.response.headers", + "*: | [:] | true | datadog.response.headers", + "*,header1:tag | [*: 'datadog.response.headers.'] | true | datadog.response.headers", + "header1:tag,* | [*: 'datadog.response.headers.'] | true | datadog.response.headers", + "1header,header2:two | [:] | true | '' ", + "header::tag | [:] | true | '' ", + ":tag | [:] | true | '' ", + "header:tag,:tag | [:] | true | '' " + }) + void testParseMapWithOptionalMappings( + String mapString, Map expected, boolean lowercaseKeys, String defaultPrefix) { + assertEquals( + expected, + ConfigConverter.parseMapWithOptionalMappings(mapString, "test", defaultPrefix, lowercaseKeys)); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSourceTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSourceTest.java index cbfdc31c3d5..4b34b41d3d6 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSourceTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSourceTest.java @@ -10,24 +10,24 @@ public class PropertiesConfigSourceTest extends DDJavaSpecification { - @Test - void throwsWhenPropertiesAreNull() { - assertThrows(AssertionError.class, () -> new PropertiesConfigSource(null, true)); - } + @Test + void throwsWhenPropertiesAreNull() { + assertThrows(AssertionError.class, () -> new PropertiesConfigSource(null, true)); + } - @TableTest({ - "scenario | usePrefix | abc | ddAbc | missing", - "no prefix | false | def | xyz | ", - "with prefix | true | xyz | | " - }) - void configPulledFromProperties(boolean usePrefix, String abc, String ddAbc, String missing) { - Properties props = new Properties(); - props.put("abc", "def"); - props.put("dd.abc", "xyz"); - PropertiesConfigSource source = new PropertiesConfigSource(props, usePrefix); + @TableTest({ + "scenario | usePrefix | abc | ddAbc | missing", + "no prefix | false | def | xyz | ", + "with prefix | true | xyz | | " + }) + void configPulledFromProperties(boolean usePrefix, String abc, String ddAbc, String missing) { + Properties props = new Properties(); + props.put("abc", "def"); + props.put("dd.abc", "xyz"); + PropertiesConfigSource source = new PropertiesConfigSource(props, usePrefix); - assertEquals(abc, source.get("abc")); - assertEquals(ddAbc, source.get("dd.abc")); - assertEquals(missing, source.get("missing")); - } + assertEquals(abc, source.get("abc")); + assertEquals(ddAbc, source.get("dd.abc")); + assertEquals(missing, source.get("missing")); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigMappingExceptionTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigMappingExceptionTest.java index 8b2476197f3..535d73c16c2 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigMappingExceptionTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigMappingExceptionTest.java @@ -11,50 +11,45 @@ public class StableConfigMappingExceptionTest { - @Test - void constructorsWorkAsExpected() { - StableConfigMappingException ex1 = new StableConfigMappingException("msg"); - StableConfigMappingException ex2 = new StableConfigMappingException("msg2"); - - assertEquals("msg", ex1.getMessage()); - assertNull(ex1.getCause()); - assertEquals("msg2", ex2.getMessage()); - } - - @Test - void safeToStringHandlesNull() { - StableConfigMappingException ex = - assertThrows( - StableConfigMappingException.class, - () -> StableConfigMappingException.throwStableConfigMappingException("msg", null)); - assertTrue(ex.getMessage().endsWith(" null")); - } - - @Test - void safeToStringHandlesShortString() { - StableConfigMappingException ex = - assertThrows( - StableConfigMappingException.class, - () -> - StableConfigMappingException.throwStableConfigMappingException( - "msg", "short string")); - assertTrue(ex.getMessage().endsWith(" short string")); - } - - @Test - void safeToStringHandlesLongString() { - char[] chars = new char[101]; - Arrays.fill(chars, 'a'); - String longStr = new String(chars); - - StableConfigMappingException ex = - assertThrows( - StableConfigMappingException.class, - () -> StableConfigMappingException.throwStableConfigMappingException("msg", longStr)); - - char[] halfChars = new char[50]; - Arrays.fill(halfChars, 'a'); - String half = new String(halfChars); - assertTrue(ex.getMessage().endsWith(" " + half + "...(truncated)..." + half)); - } + @Test + void constructorsWorkAsExpected() { + StableConfigMappingException ex1 = new StableConfigMappingException("msg"); + StableConfigMappingException ex2 = new StableConfigMappingException("msg2"); + + assertEquals("msg", ex1.getMessage()); + assertNull(ex1.getCause()); + assertEquals("msg2", ex2.getMessage()); + } + + @Test + void safeToStringHandlesNull() { + StableConfigMappingException ex = assertThrows( + StableConfigMappingException.class, + () -> StableConfigMappingException.throwStableConfigMappingException("msg", null)); + assertTrue(ex.getMessage().endsWith(" null")); + } + + @Test + void safeToStringHandlesShortString() { + StableConfigMappingException ex = assertThrows( + StableConfigMappingException.class, + () -> StableConfigMappingException.throwStableConfigMappingException("msg", "short string")); + assertTrue(ex.getMessage().endsWith(" short string")); + } + + @Test + void safeToStringHandlesLongString() { + char[] chars = new char[101]; + Arrays.fill(chars, 'a'); + String longStr = new String(chars); + + StableConfigMappingException ex = assertThrows( + StableConfigMappingException.class, + () -> StableConfigMappingException.throwStableConfigMappingException("msg", longStr)); + + char[] halfChars = new char[50]; + Arrays.fill(halfChars, 'a'); + String half = new String(halfChars); + assertTrue(ex.getMessage().endsWith(" " + half + "...(truncated)..." + half)); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigSourceTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigSourceTest.java index 8ca3989ba7f..33691e87f7e 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigSourceTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/StableConfigSourceTest.java @@ -36,315 +36,305 @@ @SuppressForbidden public class StableConfigSourceTest extends DDJavaSpecification { - @Test - void testFileDoesntExist() { - StableConfigSource config = - new StableConfigSource( - StableConfigSource.LOCAL_STABLE_CONFIG_PATH, ConfigOrigin.LOCAL_STABLE_CONFIG); - - assertEquals(0, config.getKeys().size()); - assertNull(config.getConfigId()); - } - - @Test - void testEmptyFile() throws IOException { - Path filePath = Files.createTempFile("testFile_", ".yaml"); - try { - StableConfigSource config = - new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); - assertEquals(0, config.getKeys().size()); - assertNull(config.getConfigId()); - } finally { - Files.delete(filePath); + @Test + void testFileDoesntExist() { + StableConfigSource config = + new StableConfigSource(StableConfigSource.LOCAL_STABLE_CONFIG_PATH, ConfigOrigin.LOCAL_STABLE_CONFIG); + + assertEquals(0, config.getKeys().size()); + assertNull(config.getConfigId()); } - } - - @TableTest({ - "scenario | configId | data ", - "corrupt yaml | | not_valid_stable_config ", - "invalid format | 12345 | this is not yaml format!" - }) - void testFileInvalidFormat(String configId, String data) throws IOException { - Path filePath = Files.createTempFile("testFile_", ".yaml"); - assertNotNull(filePath, "Failed to create: " + filePath); - try { - writeFileRaw(filePath, configId, data); - StableConfigSource stableCfg = - new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); - - assertNull(stableCfg.getConfigId()); - assertEquals(0, stableCfg.getKeys().size()); - } finally { - Files.delete(filePath); + + @Test + void testEmptyFile() throws IOException { + Path filePath = Files.createTempFile("testFile_", ".yaml"); + try { + StableConfigSource config = new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); + assertEquals(0, config.getKeys().size()); + assertNull(config.getConfigId()); + } finally { + Files.delete(filePath); + } } - } - - @Test - void testNullValuesInYaml() throws IOException { - Path filePath = Files.createTempFile("testFile_", ".yaml"); - assertNotNull(filePath, "Failed to create: " + filePath); - try { - // Test the scenario where YAML contains null values for apm_configuration_default and - // apm_configuration_rules - String yaml = "config_id: \"12345\"\napm_configuration_default:\napm_configuration_rules:\n"; - Files.write(filePath, yaml.getBytes()); - - StableConfigSource stableCfg = - new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); - - // Should not throw NullPointerException and should handle null values gracefully - assertEquals("12345", stableCfg.getConfigId()); - assertEquals(0, stableCfg.getKeys().size()); - } finally { - Files.delete(filePath); + + @TableTest({ + "scenario | configId | data ", + "corrupt yaml | | not_valid_stable_config ", + "invalid format | 12345 | this is not yaml format!" + }) + void testFileInvalidFormat(String configId, String data) throws IOException { + Path filePath = Files.createTempFile("testFile_", ".yaml"); + assertNotNull(filePath, "Failed to create: " + filePath); + try { + writeFileRaw(filePath, configId, data); + StableConfigSource stableCfg = + new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); + + assertNull(stableCfg.getConfigId()); + assertEquals(0, stableCfg.getKeys().size()); + } finally { + Files.delete(filePath); + } } - } - - @TableTest({ - "configId | defaultConfigs ", - "'' | [:] ", - "12345 | [DD_KEY_ONE: one, DD_KEY_TWO: two]" - }) - void testFileValidFormat(String configId, Map defaultConfigs) throws IOException { - Path filePath = Files.createTempFile("testFile_", ".yaml"); - try { - writeFileYaml(filePath, configId, defaultConfigs); - StableConfigSource stableCfg = - new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); - - assertEquals(configId.isEmpty() ? null : configId, stableCfg.getConfigId()); - assertEquals(defaultConfigs.keySet(), stableCfg.getKeys()); - defaultConfigs.forEach( - (key, value) -> assertEquals(value, stableCfg.get(key.substring("DD_".length())))); - } finally { - Files.delete(filePath); + + @Test + void testNullValuesInYaml() throws IOException { + Path filePath = Files.createTempFile("testFile_", ".yaml"); + assertNotNull(filePath, "Failed to create: " + filePath); + try { + // Test the scenario where YAML contains null values for apm_configuration_default and + // apm_configuration_rules + String yaml = "config_id: \"12345\"\napm_configuration_default:\napm_configuration_rules:\n"; + Files.write(filePath, yaml.getBytes()); + + StableConfigSource stableCfg = + new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); + + // Should not throw NullPointerException and should handle null values gracefully + assertEquals("12345", stableCfg.getConfigId()); + assertEquals(0, stableCfg.getKeys().size()); + } finally { + Files.delete(filePath); + } } - } - - @ParameterizedTest - @MethodSource("testParseInvalidLogsMappingErrorsArguments") - void testParseInvalidLogsMappingErrors(String yaml, String expectedLogSubstring) - throws IOException { - Logger logbackLogger = (Logger) LoggerFactory.getLogger(StableConfigSource.class); - ListAppender listAppender = new ListAppender<>(); - listAppender.start(); - logbackLogger.addAppender(listAppender); - - File tempFile = File.createTempFile("testFile_", ".yaml"); - try { - Files.write(tempFile.toPath(), yaml.getBytes()); - - StableConfigSource stableCfg = - new StableConfigSource(tempFile.getAbsolutePath(), ConfigOrigin.LOCAL_STABLE_CONFIG); - - assertNull(stableCfg.getConfigId()); - assertEquals(0, stableCfg.getKeys().size()); - boolean hasExpectedLog = - listAppender.list.stream() - .anyMatch( - event -> - "WARN".equals(event.getLevel().toString()) - && event.getFormattedMessage().contains(expectedLogSubstring)); - assertTrue(hasExpectedLog, "Expected WARN log containing: " + expectedLogSubstring); - } finally { - tempFile.delete(); - logbackLogger.detachAppender(listAppender); + + @TableTest({ + "configId | defaultConfigs ", + "'' | [:] ", + "12345 | [DD_KEY_ONE: one, DD_KEY_TWO: two]" + }) + void testFileValidFormat(String configId, Map defaultConfigs) throws IOException { + Path filePath = Files.createTempFile("testFile_", ".yaml"); + try { + writeFileYaml(filePath, configId, defaultConfigs); + StableConfigSource stableCfg = + new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); + + assertEquals(configId.isEmpty() ? null : configId, stableCfg.getConfigId()); + assertEquals(defaultConfigs.keySet(), stableCfg.getKeys()); + defaultConfigs.forEach((key, value) -> assertEquals(value, stableCfg.get(key.substring("DD_".length())))); + } finally { + Files.delete(filePath); + } + } + + @ParameterizedTest + @MethodSource("testParseInvalidLogsMappingErrorsArguments") + void testParseInvalidLogsMappingErrors(String yaml, String expectedLogSubstring) throws IOException { + Logger logbackLogger = (Logger) LoggerFactory.getLogger(StableConfigSource.class); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logbackLogger.addAppender(listAppender); + + File tempFile = File.createTempFile("testFile_", ".yaml"); + try { + Files.write(tempFile.toPath(), yaml.getBytes()); + + StableConfigSource stableCfg = + new StableConfigSource(tempFile.getAbsolutePath(), ConfigOrigin.LOCAL_STABLE_CONFIG); + + assertNull(stableCfg.getConfigId()); + assertEquals(0, stableCfg.getKeys().size()); + boolean hasExpectedLog = listAppender.list.stream() + .anyMatch(event -> "WARN".equals(event.getLevel().toString()) + && event.getFormattedMessage().contains(expectedLogSubstring)); + assertTrue(hasExpectedLog, "Expected WARN log containing: " + expectedLogSubstring); + } finally { + tempFile.delete(); + logbackLogger.detachAppender(listAppender); + } + } + + static Stream testParseInvalidLogsMappingErrorsArguments() { + return Stream.of( + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - key: \"someKey\"\n" + + " matches: [\"someValue\"]\n" + + " operator: equals\n" + + " configuration:\n" + + " DD_SERVICE: \"test\"\n", + "Missing 'origin' in selector"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - origin: process_arguments\n" + + " key: \"-Dfoo\"\n" + + " matches: [\"bar\"]\n" + + " operator: equals\n", + "Missing 'configuration' in rule"), + arguments( + "apm_configuration_rules:\n" + + " - configuration:\n" + + " DD_SERVICE: \"test\"\n", + "Missing 'selectors' in rule"), + arguments( + "apm_configuration_rules:\n" + + " - selectors: \"not-a-list\"\n" + + " configuration:\n" + + " DD_SERVICE: \"test\"\n", + "'selectors' must be a list, but got: String"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - \"not-a-map\"\n" + + " configuration:\n" + + " DD_SERVICE: \"test\"\n", + "Each selector must be a map, but got: String"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - origin: process_arguments\n" + + " key: \"-Dfoo\"\n" + + " matches: [\"bar\"]\n" + + " operator: equals\n" + + " configuration: \"not-a-map\"\n", + "'configuration' must be a map, but got: String"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - origin: process_arguments\n" + + " key: \"-Dfoo\"\n" + + " matches: [\"bar\"]\n" + + " operator: equals\n" + + " configuration: 12345\n", + "'configuration' must be a map, but got: Integer"), + arguments( + "apm_configuration_rules:\n" + " - \"not-a-map\"\n", + "Rule must be a map, but got: String"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - origin: process_arguments\n" + + " key: \"-Dfoo\"\n" + + " matches: \"not-a-list\"\n" + + " operator: equals\n" + + " configuration:\n" + + " DD_SERVICE: \"test\"\n", + "'matches' must be a list, but got: String"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - origin: process_arguments\n" + + " key: \"-Dfoo\"\n" + + " matches: [\"bar\"]\n" + + " configuration:\n" + + " DD_SERVICE: \"test\"\n", + "Missing 'operator' in selector"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " - origin: process_arguments\n" + + " key: \"-Dfoo\"\n" + + " matches: [\"bar\"]\n" + + " operator: 12345\n" + + " configuration:\n" + + " DD_SERVICE: \"test\"\n", + "'operator' must be a string, but got: Integer"), + arguments( + "apm_configuration_rules:\n" + + " - selectors:\n" + + " # origin is missing entirely, should trigger NullPointerException\n" + + " - key: \"-Dfoo\"\n" + + " matches: [\"bar\"]\n" + + " operator: equals\n", + "YAML mapping error in stable configuration file")); } - } - - static Stream testParseInvalidLogsMappingErrorsArguments() { - return Stream.of( - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - key: \"someKey\"\n" - + " matches: [\"someValue\"]\n" - + " operator: equals\n" - + " configuration:\n" - + " DD_SERVICE: \"test\"\n", - "Missing 'origin' in selector"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - origin: process_arguments\n" - + " key: \"-Dfoo\"\n" - + " matches: [\"bar\"]\n" - + " operator: equals\n", - "Missing 'configuration' in rule"), - arguments( - "apm_configuration_rules:\n" - + " - configuration:\n" - + " DD_SERVICE: \"test\"\n", - "Missing 'selectors' in rule"), - arguments( - "apm_configuration_rules:\n" - + " - selectors: \"not-a-list\"\n" - + " configuration:\n" - + " DD_SERVICE: \"test\"\n", - "'selectors' must be a list, but got: String"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - \"not-a-map\"\n" - + " configuration:\n" - + " DD_SERVICE: \"test\"\n", - "Each selector must be a map, but got: String"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - origin: process_arguments\n" - + " key: \"-Dfoo\"\n" - + " matches: [\"bar\"]\n" - + " operator: equals\n" - + " configuration: \"not-a-map\"\n", - "'configuration' must be a map, but got: String"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - origin: process_arguments\n" - + " key: \"-Dfoo\"\n" - + " matches: [\"bar\"]\n" - + " operator: equals\n" - + " configuration: 12345\n", - "'configuration' must be a map, but got: Integer"), - arguments( - "apm_configuration_rules:\n" + " - \"not-a-map\"\n", - "Rule must be a map, but got: String"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - origin: process_arguments\n" - + " key: \"-Dfoo\"\n" - + " matches: \"not-a-list\"\n" - + " operator: equals\n" - + " configuration:\n" - + " DD_SERVICE: \"test\"\n", - "'matches' must be a list, but got: String"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - origin: process_arguments\n" - + " key: \"-Dfoo\"\n" - + " matches: [\"bar\"]\n" - + " configuration:\n" - + " DD_SERVICE: \"test\"\n", - "Missing 'operator' in selector"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " - origin: process_arguments\n" - + " key: \"-Dfoo\"\n" - + " matches: [\"bar\"]\n" - + " operator: 12345\n" - + " configuration:\n" - + " DD_SERVICE: \"test\"\n", - "'operator' must be a string, but got: Integer"), - arguments( - "apm_configuration_rules:\n" - + " - selectors:\n" - + " # origin is missing entirely, should trigger NullPointerException\n" - + " - key: \"-Dfoo\"\n" - + " matches: [\"bar\"]\n" - + " operator: equals\n", - "YAML mapping error in stable configuration file")); - } - - @Test - @SuppressForbidden - void testConfigIdExistsInConfigCollectorWhenUsingStableConfigSource() throws Exception { - Path filePath = Files.createTempFile("testFile_", ".yaml"); - String expectedConfigId = "123"; - - // Create YAML content with config_id and some configuration - String yamlContent = - "config_id: " - + expectedConfigId - + "\napm_configuration_default:\n DD_SERVICE: test-service\n DD_ENV: test-env\n"; - Files.write(filePath, yamlContent.getBytes()); - - // Clear any existing collected config - ConfigCollector.get().collect(); - - try { - StableConfigSource stableConfigSource = - new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); - - // Create ConfigProvider via reflection (constructor is private) - Constructor constructor = - ConfigProvider.class.getDeclaredConstructor(ConfigProvider.Source[].class); - constructor.setAccessible(true); - ConfigProvider configProvider = - constructor.newInstance((Object) new ConfigProvider.Source[] {stableConfigSource}); - - // Trigger config collection by getting a value - configProvider.getString("SERVICE", "default-service"); - - Map> collectedConfigs = - ConfigCollector.get().collect(); - Map localStableConfigs = - collectedConfigs.get(ConfigOrigin.LOCAL_STABLE_CONFIG); - assertNotNull(localStableConfigs, "No configs collected for LOCAL_STABLE_CONFIG origin"); - ConfigSetting serviceSetting = localStableConfigs.get("SERVICE"); - assertNotNull(serviceSetting, "No SERVICE setting collected"); - assertEquals(expectedConfigId, serviceSetting.configId); - assertEquals("test-service", serviceSetting.value); - assertEquals(ConfigOrigin.LOCAL_STABLE_CONFIG, serviceSetting.origin); - } finally { - Files.delete(filePath); + + @Test + @SuppressForbidden + void testConfigIdExistsInConfigCollectorWhenUsingStableConfigSource() throws Exception { + Path filePath = Files.createTempFile("testFile_", ".yaml"); + String expectedConfigId = "123"; + + // Create YAML content with config_id and some configuration + String yamlContent = "config_id: " + + expectedConfigId + + "\napm_configuration_default:\n DD_SERVICE: test-service\n DD_ENV: test-env\n"; + Files.write(filePath, yamlContent.getBytes()); + + // Clear any existing collected config + ConfigCollector.get().collect(); + + try { + StableConfigSource stableConfigSource = + new StableConfigSource(filePath.toString(), ConfigOrigin.LOCAL_STABLE_CONFIG); + + // Create ConfigProvider via reflection (constructor is private) + Constructor constructor = + ConfigProvider.class.getDeclaredConstructor(ConfigProvider.Source[].class); + constructor.setAccessible(true); + ConfigProvider configProvider = + constructor.newInstance((Object) new ConfigProvider.Source[] {stableConfigSource}); + + // Trigger config collection by getting a value + configProvider.getString("SERVICE", "default-service"); + + Map> collectedConfigs = + ConfigCollector.get().collect(); + Map localStableConfigs = collectedConfigs.get(ConfigOrigin.LOCAL_STABLE_CONFIG); + assertNotNull(localStableConfigs, "No configs collected for LOCAL_STABLE_CONFIG origin"); + ConfigSetting serviceSetting = localStableConfigs.get("SERVICE"); + assertNotNull(serviceSetting, "No SERVICE setting collected"); + assertEquals(expectedConfigId, serviceSetting.configId); + assertEquals("test-service", serviceSetting.value); + assertEquals(ConfigOrigin.LOCAL_STABLE_CONFIG, serviceSetting.origin); + } finally { + Files.delete(filePath); + } } - } - - @Test - void testStableConfigGetHandlesPresentAndMissingKeys() { - Map configMap = new HashMap<>(); - configMap.put("DD_SERVICE", "test-service"); - configMap.put("DD_PORT", 8126); - - StableConfigSource.StableConfig config = - new StableConfigSource.StableConfig("config-123", configMap); - - // Present String value: hits the non-null branch of the ternary in get() - assertEquals("test-service", config.get("DD_SERVICE")); - // Present non-String value: exercises String.valueOf on a non-null, non-String object - assertEquals("8126", config.get("DD_PORT")); - // Missing key: hits the null branch of the ternary in get() - assertNull(config.get("DD_MISSING")); - - assertEquals("config-123", config.getConfigId()); - assertEquals(configMap.keySet(), config.getKeys()); - } - - @Test - void testStableConfigEmpty() { - StableConfigSource.StableConfig empty = StableConfigSource.StableConfig.EMPTY; - assertNull(empty.get("DD_SERVICE")); - assertNull(empty.getConfigId()); - assertEquals(0, empty.getKeys().size()); - } - - private static void writeFileYaml( - Path filePath, String configId, Map defaultConfigs) throws IOException { - Map yamlData = new HashMap<>(); - - if (configId != null && !configId.isEmpty()) { - yamlData.put("config_id", configId); + + @Test + void testStableConfigGetHandlesPresentAndMissingKeys() { + Map configMap = new HashMap<>(); + configMap.put("DD_SERVICE", "test-service"); + configMap.put("DD_PORT", 8126); + + StableConfigSource.StableConfig config = new StableConfigSource.StableConfig("config-123", configMap); + + // Present String value: hits the non-null branch of the ternary in get() + assertEquals("test-service", config.get("DD_SERVICE")); + // Present non-String value: exercises String.valueOf on a non-null, non-String object + assertEquals("8126", config.get("DD_PORT")); + // Missing key: hits the null branch of the ternary in get() + assertNull(config.get("DD_MISSING")); + + assertEquals("config-123", config.getConfigId()); + assertEquals(configMap.keySet(), config.getKeys()); } - if (defaultConfigs != null && !defaultConfigs.isEmpty()) { - yamlData.put("apm_configuration_default", defaultConfigs); + @Test + void testStableConfigEmpty() { + StableConfigSource.StableConfig empty = StableConfigSource.StableConfig.EMPTY; + assertNull(empty.get("DD_SERVICE")); + assertNull(empty.getConfigId()); + assertEquals(0, empty.getKeys().size()); } - DumpSettings settings = DumpSettings.builder().build(); - Dump dump = new Dump(settings); - String yamlContent = dump.dumpToString(yamlData); + private static void writeFileYaml(Path filePath, String configId, Map defaultConfigs) + throws IOException { + Map yamlData = new HashMap<>(); - try (FileWriter writer = new FileWriter(filePath.toFile())) { - writer.write(yamlContent); + if (configId != null && !configId.isEmpty()) { + yamlData.put("config_id", configId); + } + + if (defaultConfigs != null && !defaultConfigs.isEmpty()) { + yamlData.put("apm_configuration_default", defaultConfigs); + } + + DumpSettings settings = DumpSettings.builder().build(); + Dump dump = new Dump(settings); + String yamlContent = dump.dumpToString(yamlData); + + try (FileWriter writer = new FileWriter(filePath.toFile())) { + writer.write(yamlContent); + } } - } - private static void writeFileRaw(Path filePath, String configId, String data) throws IOException { - String content = configId + "\n" + data; - Files.write(filePath, content.getBytes(), StandardOpenOption.WRITE); - } + private static void writeFileRaw(Path filePath, String configId, String data) throws IOException { + String content = configId + "\n" + data; + Files.write(filePath, content.getBytes(), StandardOpenOption.WRITE); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariablesTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariablesTest.java index 8a913b0901b..972d78e767f 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariablesTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/civisibility/CiEnvironmentVariablesTest.java @@ -15,62 +15,52 @@ public class CiEnvironmentVariablesTest { - private static final String SECRET_KEY = "secret-key"; + private static final String SECRET_KEY = "secret-key"; - private static MockWebServer server; + private static MockWebServer server; - private static final AtomicInteger failedResponses = new AtomicInteger(0); + private static final AtomicInteger failedResponses = new AtomicInteger(0); - @BeforeAll - public static void startServer() throws Exception { - server = new MockWebServer(); - server.setDispatcher( - new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest req) { - if (failedResponses.getAndDecrement() > 0) { - return new MockResponse().setResponseCode(500); + @BeforeAll + public static void startServer() throws Exception { + server = new MockWebServer(); + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest req) { + if (failedResponses.getAndDecrement() > 0) { + return new MockResponse().setResponseCode(500); + } + if (SECRET_KEY.equals(req.getHeader(CiEnvironmentVariables.DD_ENV_VARS_PROVIDER_KEY_HEADER))) { + return new MockResponse().setResponseCode(200).setBody("a=1\nb=2"); + } + return new MockResponse().setResponseCode(403); } - if (SECRET_KEY.equals( - req.getHeader(CiEnvironmentVariables.DD_ENV_VARS_PROVIDER_KEY_HEADER))) { - return new MockResponse().setResponseCode(200).setBody("a=1\nb=2"); - } - return new MockResponse().setResponseCode(403); - } }); - server.start(); - } + server.start(); + } - @AfterAll - public static void stopServer() throws Exception { - server.shutdown(); - } + @AfterAll + public static void stopServer() throws Exception { + server.shutdown(); + } - @Test - void testGetEnvironment() { - failedResponses.set(1); // to test retries + @Test + void testGetEnvironment() { + failedResponses.set(1); // to test retries - Map env = - CiEnvironmentVariables.getRemoteEnvironmentWithRetries( - server.url("/").toString(), - SECRET_KEY, - new CiEnvironmentVariables.RetryPolicy(2, 3, 2), - null); - assertEquals(2, env.size()); - assertEquals("1", env.get("a")); - assertEquals("2", env.get("b")); - } + Map env = CiEnvironmentVariables.getRemoteEnvironmentWithRetries( + server.url("/").toString(), SECRET_KEY, new CiEnvironmentVariables.RetryPolicy(2, 3, 2), null); + assertEquals(2, env.size()); + assertEquals("1", env.get("a")); + assertEquals("2", env.get("b")); + } - @Test - void testFailedGetEnvironment() { - failedResponses.set(3); + @Test + void testFailedGetEnvironment() { + failedResponses.set(3); - Map env = - CiEnvironmentVariables.getRemoteEnvironmentWithRetries( - server.url("/").toString(), - SECRET_KEY, - new CiEnvironmentVariables.RetryPolicy(2, 3, 2), - null); - assertNull(env); - } + Map env = CiEnvironmentVariables.getRemoteEnvironmentWithRetries( + server.url("/").toString(), SECRET_KEY, new CiEnvironmentVariables.RetryPolicy(2, 3, 2), null); + assertNull(env); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/stableconfig/RuleTest.java b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/stableconfig/RuleTest.java index d84c653f3d4..157b824d69c 100644 --- a/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/stableconfig/RuleTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/bootstrap/config/provider/stableconfig/RuleTest.java @@ -13,73 +13,73 @@ class RuleTest { - @Test - void testNoArgConstructorYieldsEmptyState() { - Rule rule = new Rule(); - assertTrue(rule.getSelectors().isEmpty()); - assertTrue(rule.getConfiguration().isEmpty()); - } - - @Test - void testConstructorExposesValuesViaGetters() { - Selector selector = new Selector("process_arguments", "-Dfoo", asList("bar"), "equals"); - Map configuration = new HashMap<>(); - configuration.put("DD_SERVICE", "test"); - - Rule rule = new Rule(asList(selector), configuration); - - assertEquals(1, rule.getSelectors().size()); - assertSame(selector, rule.getSelectors().get(0)); - assertSame(configuration, rule.getConfiguration()); - } - - @Test - void testFromParsesValidRule() { - Map selectorMap = new LinkedHashMap<>(); - selectorMap.put("origin", "process_arguments"); - selectorMap.put("key", "-Dfoo"); - selectorMap.put("matches", asList("bar")); - selectorMap.put("operator", "equals"); - - Map configuration = new LinkedHashMap<>(); - configuration.put("DD_SERVICE", "test"); - - Map ruleMap = new LinkedHashMap<>(); - ruleMap.put("selectors", asList(selectorMap)); - ruleMap.put("configuration", configuration); - - Rule rule = Rule.from(ruleMap); - - List selectors = rule.getSelectors(); - assertEquals(1, selectors.size()); - Selector selector = selectors.get(0); - assertEquals("process_arguments", selector.getOrigin()); - assertEquals("-Dfoo", selector.getKey()); - assertEquals(asList("bar"), selector.getMatches()); - assertEquals("equals", selector.getOperator()); - - assertEquals("test", rule.getConfiguration().get("DD_SERVICE")); - } - - @Test - void testFromSkipsNullSelectorEntries() { - Map selectorMap = new LinkedHashMap<>(); - selectorMap.put("origin", "process_arguments"); - selectorMap.put("key", "-Dfoo"); - selectorMap.put("matches", asList("bar")); - selectorMap.put("operator", "equals"); - - Map configuration = new LinkedHashMap<>(); - configuration.put("DD_SERVICE", "test"); - - Map ruleMap = new LinkedHashMap<>(); - ruleMap.put("selectors", asList(null, selectorMap)); - ruleMap.put("configuration", configuration); - - Rule rule = Rule.from(ruleMap); - - // The null entry is filtered out, leaving only the valid selector. - assertEquals(1, rule.getSelectors().size()); - assertEquals("process_arguments", rule.getSelectors().get(0).getOrigin()); - } + @Test + void testNoArgConstructorYieldsEmptyState() { + Rule rule = new Rule(); + assertTrue(rule.getSelectors().isEmpty()); + assertTrue(rule.getConfiguration().isEmpty()); + } + + @Test + void testConstructorExposesValuesViaGetters() { + Selector selector = new Selector("process_arguments", "-Dfoo", asList("bar"), "equals"); + Map configuration = new HashMap<>(); + configuration.put("DD_SERVICE", "test"); + + Rule rule = new Rule(asList(selector), configuration); + + assertEquals(1, rule.getSelectors().size()); + assertSame(selector, rule.getSelectors().get(0)); + assertSame(configuration, rule.getConfiguration()); + } + + @Test + void testFromParsesValidRule() { + Map selectorMap = new LinkedHashMap<>(); + selectorMap.put("origin", "process_arguments"); + selectorMap.put("key", "-Dfoo"); + selectorMap.put("matches", asList("bar")); + selectorMap.put("operator", "equals"); + + Map configuration = new LinkedHashMap<>(); + configuration.put("DD_SERVICE", "test"); + + Map ruleMap = new LinkedHashMap<>(); + ruleMap.put("selectors", asList(selectorMap)); + ruleMap.put("configuration", configuration); + + Rule rule = Rule.from(ruleMap); + + List selectors = rule.getSelectors(); + assertEquals(1, selectors.size()); + Selector selector = selectors.get(0); + assertEquals("process_arguments", selector.getOrigin()); + assertEquals("-Dfoo", selector.getKey()); + assertEquals(asList("bar"), selector.getMatches()); + assertEquals("equals", selector.getOperator()); + + assertEquals("test", rule.getConfiguration().get("DD_SERVICE")); + } + + @Test + void testFromSkipsNullSelectorEntries() { + Map selectorMap = new LinkedHashMap<>(); + selectorMap.put("origin", "process_arguments"); + selectorMap.put("key", "-Dfoo"); + selectorMap.put("matches", asList("bar")); + selectorMap.put("operator", "equals"); + + Map configuration = new LinkedHashMap<>(); + configuration.put("DD_SERVICE", "test"); + + Map ruleMap = new LinkedHashMap<>(); + ruleMap.put("selectors", asList(null, selectorMap)); + ruleMap.put("configuration", configuration); + + Rule rule = Rule.from(ruleMap); + + // The null entry is filtered out, leaving only the valid selector. + assertEquals(1, rule.getSelectors().size()); + assertEquals("process_arguments", rule.getSelectors().get(0).getOrigin()); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/config/inversion/ConfigHelperTest.java b/utils/config-utils/src/test/java/datadog/trace/config/inversion/ConfigHelperTest.java index 357da91fa64..2d8c645c74b 100644 --- a/utils/config-utils/src/test/java/datadog/trace/config/inversion/ConfigHelperTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/config/inversion/ConfigHelperTest.java @@ -19,186 +19,184 @@ import org.junit.jupiter.api.Test; public class ConfigHelperTest { - // Test environment variables - private static final String DD_VAR = "DD_TEST_CONFIG"; - private static final String DD_VAR_VAL = "test_dd_var"; - private static final String OTEL_VAR = "OTEL_TEST_CONFIG"; - private static final String OTEL_VAR_VAL = "test_otel_var"; - private static final String REGULAR_VAR = "REGULAR_TEST_CONFIG"; - private static final String REGULAR_VAR_VAL = "test_regular_var"; - - private static final String ALIAS_DD_VAR = "DD_TEST_CONFIG_ALIAS"; - private static final String ALIAS_DD_VAL = "test_alias_val"; - private static final String NON_DD_ALIAS_VAR = "TEST_CONFIG_ALIAS"; - private static final String NON_DD_ALIAS_VAL = "test_alias_val_non_dd"; - - private static final String NEW_ALIAS_TARGET = "DD_NEW_ALIAS_TARGET"; - private static final String NEW_ALIAS_KEY_1 = "DD_NEW_ALIAS_KEY_1"; - private static final String NEW_ALIAS_KEY_2 = "DD_NEW_ALIAS_KEY_2"; - - private static ControllableEnvironmentVariables env; - - private static ConfigHelper.StrictnessPolicy strictness; - private static TestSupportedConfigurationSource testSource; - - @BeforeAll - static void setUp() { - env = ControllableEnvironmentVariables.setup(); - - // Set up test configurations using SupportedConfigurationSource - Set testSupported = new HashSet<>(Arrays.asList(DD_VAR, OTEL_VAR, REGULAR_VAR)); - - Map> testAliases = new HashMap<>(); - testAliases.put(DD_VAR, Arrays.asList(ALIAS_DD_VAR, NON_DD_ALIAS_VAR)); - testAliases.put(NEW_ALIAS_TARGET, Arrays.asList(NEW_ALIAS_KEY_1)); - - Map testAliasMapping = new HashMap<>(); - testAliasMapping.put(ALIAS_DD_VAR, DD_VAR); - testAliasMapping.put(NON_DD_ALIAS_VAR, DD_VAR); - testAliasMapping.put(NEW_ALIAS_KEY_2, NEW_ALIAS_TARGET); - - // Create and set test configuration source - testSource = - new TestSupportedConfigurationSource( - testSupported, testAliases, testAliasMapping, new HashMap<>()); - ConfigHelper.get().setConfigurationSource(testSource); - strictness = ConfigHelper.get().configInversionStrictFlag(); - ConfigHelper.get().setConfigInversionStrict(ConfigHelper.StrictnessPolicy.STRICT_TEST); - } - - @AfterAll - static void tearDown() { - ConfigHelper.get().resetToDefaults(); - ConfigHelper.get().setConfigInversionStrict(strictness); - } - - @AfterEach - void reset() { - ConfigHelper.get().resetCache(); - env.clear(); - } - - @Test - void testBasicConfigHelper() { - env.set(DD_VAR, DD_VAR_VAL); - env.set(OTEL_VAR, OTEL_VAR_VAL); - env.set(REGULAR_VAR, REGULAR_VAR_VAL); - - assertEquals(DD_VAR_VAL, ConfigHelper.env(DD_VAR)); - assertEquals(OTEL_VAR_VAL, ConfigHelper.env(OTEL_VAR)); - assertEquals(REGULAR_VAR_VAL, ConfigHelper.env(REGULAR_VAR)); - - Map result = ConfigHelper.env(); - assertEquals(DD_VAR_VAL, result.get(DD_VAR)); - assertEquals(OTEL_VAR_VAL, result.get(OTEL_VAR)); - assertEquals(REGULAR_VAR_VAL, result.get(REGULAR_VAR)); - } - - @Test - void testAliasSupport() { - env.set(ALIAS_DD_VAR, ALIAS_DD_VAL); - - assertEquals(ALIAS_DD_VAL, ConfigHelper.env(DD_VAR)); - Map result = ConfigHelper.env(); - assertEquals(ALIAS_DD_VAL, result.get(DD_VAR)); - assertFalse(result.containsKey(ALIAS_DD_VAR)); - } - - @Test - void testMainConfigPrecedence() { - // When both main variable and alias are set, main should take precedence - env.set(DD_VAR, DD_VAR_VAL); - env.set(ALIAS_DD_VAR, ALIAS_DD_VAL); - - assertEquals(DD_VAR_VAL, ConfigHelper.env(DD_VAR)); - Map result = ConfigHelper.env(); - assertEquals(DD_VAR_VAL, result.get(DD_VAR)); - assertFalse(result.containsKey(ALIAS_DD_VAR)); - } - - @Test - void testNonDDAliases() { - env.set(NON_DD_ALIAS_VAR, NON_DD_ALIAS_VAL); - - assertEquals(NON_DD_ALIAS_VAL, ConfigHelper.env(DD_VAR)); - Map result = ConfigHelper.env(); - assertEquals(NON_DD_ALIAS_VAL, result.get(DD_VAR)); - assertFalse(result.containsKey(NON_DD_ALIAS_VAR)); - } - - @Test - void testAliasesWithoutPresentAliases() { - Map result = ConfigHelper.env(); - assertFalse(result.containsKey(ALIAS_DD_VAR)); - } - - @Test - void testAliasWithEmptyList() { - Map> aliasMap = new HashMap<>(); - aliasMap.put("EMPTY_ALIAS_CONFIG", new ArrayList<>()); - - ConfigHelper.get() - .setConfigurationSource( - new TestSupportedConfigurationSource( - new HashSet<>(), aliasMap, new HashMap<>(), new HashMap<>())); - - assertNull(ConfigHelper.env("EMPTY_ALIAS_CONFIG")); - - // Cleanup - ConfigHelper.get().setConfigurationSource(testSource); - } - - @Test - void testAliasSkippedWhenBaseAlreadyPresent() { - env.set(DD_VAR, DD_VAR_VAL); - env.set(NON_DD_ALIAS_VAR, NON_DD_ALIAS_VAL); - - Map result = ConfigHelper.env(); - assertEquals(DD_VAR_VAL, result.get(DD_VAR)); - assertFalse(result.containsKey(NON_DD_ALIAS_VAR)); - } - - @Test - void testInconsistentAliasesAndAliasMapping() { - env.set(NEW_ALIAS_KEY_2, "some_value"); - - Map result = ConfigHelper.env(); - - assertFalse(result.containsKey(NEW_ALIAS_KEY_2)); - assertFalse(result.containsKey(NEW_ALIAS_TARGET)); - } - - @Test - void testStrictTestThrowsForUnsupportedConfig() { - env.set("DD_FAKE_VAR", "banana"); - - // STRICT_TEST mode should throw for unsupported DD_ variables - assertThrows(IllegalArgumentException.class, () -> ConfigHelper.env("DD_FAKE_VAR")); - } - - @Test - void testUnsupportedEnvWarningNotInTestMode() { - ConfigHelper.get().setConfigInversionStrict(ConfigHelper.StrictnessPolicy.TEST); - - env.set("DD_FAKE_VAR", "banana"); - - // Should allow unsupported variable in TEST mode - assertEquals("banana", ConfigHelper.env("DD_FAKE_VAR")); - - // Cleanup - ConfigHelper.get().setConfigInversionStrict(ConfigHelper.StrictnessPolicy.STRICT_TEST); - } - - @Test - void testCache() { - env.set(DD_VAR, DD_VAR_VAL); - - Map result = ConfigHelper.env(); - assertEquals(DD_VAR_VAL, result.get(DD_VAR)); - - // Ensure that the cached value is returned - env.set(DD_VAR, ALIAS_DD_VAL); - assertEquals(DD_VAR_VAL, result.get(DD_VAR)); - assertEquals(DD_VAR_VAL, ConfigHelper.env(DD_VAR)); - } + // Test environment variables + private static final String DD_VAR = "DD_TEST_CONFIG"; + private static final String DD_VAR_VAL = "test_dd_var"; + private static final String OTEL_VAR = "OTEL_TEST_CONFIG"; + private static final String OTEL_VAR_VAL = "test_otel_var"; + private static final String REGULAR_VAR = "REGULAR_TEST_CONFIG"; + private static final String REGULAR_VAR_VAL = "test_regular_var"; + + private static final String ALIAS_DD_VAR = "DD_TEST_CONFIG_ALIAS"; + private static final String ALIAS_DD_VAL = "test_alias_val"; + private static final String NON_DD_ALIAS_VAR = "TEST_CONFIG_ALIAS"; + private static final String NON_DD_ALIAS_VAL = "test_alias_val_non_dd"; + + private static final String NEW_ALIAS_TARGET = "DD_NEW_ALIAS_TARGET"; + private static final String NEW_ALIAS_KEY_1 = "DD_NEW_ALIAS_KEY_1"; + private static final String NEW_ALIAS_KEY_2 = "DD_NEW_ALIAS_KEY_2"; + + private static ControllableEnvironmentVariables env; + + private static ConfigHelper.StrictnessPolicy strictness; + private static TestSupportedConfigurationSource testSource; + + @BeforeAll + static void setUp() { + env = ControllableEnvironmentVariables.setup(); + + // Set up test configurations using SupportedConfigurationSource + Set testSupported = new HashSet<>(Arrays.asList(DD_VAR, OTEL_VAR, REGULAR_VAR)); + + Map> testAliases = new HashMap<>(); + testAliases.put(DD_VAR, Arrays.asList(ALIAS_DD_VAR, NON_DD_ALIAS_VAR)); + testAliases.put(NEW_ALIAS_TARGET, Arrays.asList(NEW_ALIAS_KEY_1)); + + Map testAliasMapping = new HashMap<>(); + testAliasMapping.put(ALIAS_DD_VAR, DD_VAR); + testAliasMapping.put(NON_DD_ALIAS_VAR, DD_VAR); + testAliasMapping.put(NEW_ALIAS_KEY_2, NEW_ALIAS_TARGET); + + // Create and set test configuration source + testSource = + new TestSupportedConfigurationSource(testSupported, testAliases, testAliasMapping, new HashMap<>()); + ConfigHelper.get().setConfigurationSource(testSource); + strictness = ConfigHelper.get().configInversionStrictFlag(); + ConfigHelper.get().setConfigInversionStrict(ConfigHelper.StrictnessPolicy.STRICT_TEST); + } + + @AfterAll + static void tearDown() { + ConfigHelper.get().resetToDefaults(); + ConfigHelper.get().setConfigInversionStrict(strictness); + } + + @AfterEach + void reset() { + ConfigHelper.get().resetCache(); + env.clear(); + } + + @Test + void testBasicConfigHelper() { + env.set(DD_VAR, DD_VAR_VAL); + env.set(OTEL_VAR, OTEL_VAR_VAL); + env.set(REGULAR_VAR, REGULAR_VAR_VAL); + + assertEquals(DD_VAR_VAL, ConfigHelper.env(DD_VAR)); + assertEquals(OTEL_VAR_VAL, ConfigHelper.env(OTEL_VAR)); + assertEquals(REGULAR_VAR_VAL, ConfigHelper.env(REGULAR_VAR)); + + Map result = ConfigHelper.env(); + assertEquals(DD_VAR_VAL, result.get(DD_VAR)); + assertEquals(OTEL_VAR_VAL, result.get(OTEL_VAR)); + assertEquals(REGULAR_VAR_VAL, result.get(REGULAR_VAR)); + } + + @Test + void testAliasSupport() { + env.set(ALIAS_DD_VAR, ALIAS_DD_VAL); + + assertEquals(ALIAS_DD_VAL, ConfigHelper.env(DD_VAR)); + Map result = ConfigHelper.env(); + assertEquals(ALIAS_DD_VAL, result.get(DD_VAR)); + assertFalse(result.containsKey(ALIAS_DD_VAR)); + } + + @Test + void testMainConfigPrecedence() { + // When both main variable and alias are set, main should take precedence + env.set(DD_VAR, DD_VAR_VAL); + env.set(ALIAS_DD_VAR, ALIAS_DD_VAL); + + assertEquals(DD_VAR_VAL, ConfigHelper.env(DD_VAR)); + Map result = ConfigHelper.env(); + assertEquals(DD_VAR_VAL, result.get(DD_VAR)); + assertFalse(result.containsKey(ALIAS_DD_VAR)); + } + + @Test + void testNonDDAliases() { + env.set(NON_DD_ALIAS_VAR, NON_DD_ALIAS_VAL); + + assertEquals(NON_DD_ALIAS_VAL, ConfigHelper.env(DD_VAR)); + Map result = ConfigHelper.env(); + assertEquals(NON_DD_ALIAS_VAL, result.get(DD_VAR)); + assertFalse(result.containsKey(NON_DD_ALIAS_VAR)); + } + + @Test + void testAliasesWithoutPresentAliases() { + Map result = ConfigHelper.env(); + assertFalse(result.containsKey(ALIAS_DD_VAR)); + } + + @Test + void testAliasWithEmptyList() { + Map> aliasMap = new HashMap<>(); + aliasMap.put("EMPTY_ALIAS_CONFIG", new ArrayList<>()); + + ConfigHelper.get() + .setConfigurationSource(new TestSupportedConfigurationSource( + new HashSet<>(), aliasMap, new HashMap<>(), new HashMap<>())); + + assertNull(ConfigHelper.env("EMPTY_ALIAS_CONFIG")); + + // Cleanup + ConfigHelper.get().setConfigurationSource(testSource); + } + + @Test + void testAliasSkippedWhenBaseAlreadyPresent() { + env.set(DD_VAR, DD_VAR_VAL); + env.set(NON_DD_ALIAS_VAR, NON_DD_ALIAS_VAL); + + Map result = ConfigHelper.env(); + assertEquals(DD_VAR_VAL, result.get(DD_VAR)); + assertFalse(result.containsKey(NON_DD_ALIAS_VAR)); + } + + @Test + void testInconsistentAliasesAndAliasMapping() { + env.set(NEW_ALIAS_KEY_2, "some_value"); + + Map result = ConfigHelper.env(); + + assertFalse(result.containsKey(NEW_ALIAS_KEY_2)); + assertFalse(result.containsKey(NEW_ALIAS_TARGET)); + } + + @Test + void testStrictTestThrowsForUnsupportedConfig() { + env.set("DD_FAKE_VAR", "banana"); + + // STRICT_TEST mode should throw for unsupported DD_ variables + assertThrows(IllegalArgumentException.class, () -> ConfigHelper.env("DD_FAKE_VAR")); + } + + @Test + void testUnsupportedEnvWarningNotInTestMode() { + ConfigHelper.get().setConfigInversionStrict(ConfigHelper.StrictnessPolicy.TEST); + + env.set("DD_FAKE_VAR", "banana"); + + // Should allow unsupported variable in TEST mode + assertEquals("banana", ConfigHelper.env("DD_FAKE_VAR")); + + // Cleanup + ConfigHelper.get().setConfigInversionStrict(ConfigHelper.StrictnessPolicy.STRICT_TEST); + } + + @Test + void testCache() { + env.set(DD_VAR, DD_VAR_VAL); + + Map result = ConfigHelper.env(); + assertEquals(DD_VAR_VAL, result.get(DD_VAR)); + + // Ensure that the cached value is returned + env.set(DD_VAR, ALIAS_DD_VAL); + assertEquals(DD_VAR_VAL, result.get(DD_VAR)); + assertEquals(DD_VAR_VAL, ConfigHelper.env(DD_VAR)); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/config/inversion/TestSupportedConfigurationSource.java b/utils/config-utils/src/test/java/datadog/trace/config/inversion/TestSupportedConfigurationSource.java index 3dee8925dd1..6b91990dd3e 100644 --- a/utils/config-utils/src/test/java/datadog/trace/config/inversion/TestSupportedConfigurationSource.java +++ b/utils/config-utils/src/test/java/datadog/trace/config/inversion/TestSupportedConfigurationSource.java @@ -7,39 +7,39 @@ /** Test implementation of SupportedConfigurationSource that uses custom configuration data */ class TestSupportedConfigurationSource extends SupportedConfigurationSource { - private final Set supported; - private final Map> aliases; - private final Map aliasMapping; - private final Map deprecated; + private final Set supported; + private final Map> aliases; + private final Map aliasMapping; + private final Map deprecated; - public TestSupportedConfigurationSource( - Set supported, - Map> aliases, - Map aliasMapping, - Map deprecated) { - this.supported = supported; - this.aliases = aliases; - this.aliasMapping = aliasMapping; - this.deprecated = deprecated; - } + public TestSupportedConfigurationSource( + Set supported, + Map> aliases, + Map aliasMapping, + Map deprecated) { + this.supported = supported; + this.aliases = aliases; + this.aliasMapping = aliasMapping; + this.deprecated = deprecated; + } - @Override - public boolean supported(String env) { - return supported.contains(env); - } + @Override + public boolean supported(String env) { + return supported.contains(env); + } - @Override - public List getAliases(String env) { - return aliases.getOrDefault(env, Collections.emptyList()); - } + @Override + public List getAliases(String env) { + return aliases.getOrDefault(env, Collections.emptyList()); + } - @Override - public String primaryEnvFromAlias(String alias) { - return aliasMapping.getOrDefault(alias, null); - } + @Override + public String primaryEnvFromAlias(String alias) { + return aliasMapping.getOrDefault(alias, null); + } - @Override - public String primaryEnvFromDeprecated(String deprecated) { - return this.deprecated.getOrDefault(deprecated, null); - } + @Override + public String primaryEnvFromDeprecated(String deprecated) { + return this.deprecated.getOrDefault(deprecated, null); + } } diff --git a/utils/config-utils/src/test/java/datadog/trace/util/ConfigStringsTest.java b/utils/config-utils/src/test/java/datadog/trace/util/ConfigStringsTest.java index d87c5581426..1bee54ee9df 100644 --- a/utils/config-utils/src/test/java/datadog/trace/util/ConfigStringsTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/util/ConfigStringsTest.java @@ -9,41 +9,41 @@ class ConfigStringsTest { - /** Dotted capital I (U+0130) that a Turkish-locale {@code toUpperCase()} produces from 'i'. */ - private static final char DOTTED_CAPITAL_I = 'İ'; + /** Dotted capital I (U+0130) that a Turkish-locale {@code toUpperCase()} produces from 'i'. */ + private static final char DOTTED_CAPITAL_I = 'İ'; - @Test - void toEnvVarUppercasesLowerIToAsciiIOnTurkishLocale() { - // Turkish is the locale where a locale-sensitive toUpperCase() maps 'i' -> 'İ' - // Forcing it as the default locale here proves the conversions are - // locale-independent (pinned to Locale.ROOT) rather than relying on the machine's locale. - Locale previousDefault = Locale.getDefault(); - Locale.setDefault(new Locale("tr", "TR")); - try { - String result = ConfigStrings.toEnvVar("dd.profiling.i"); + @Test + void toEnvVarUppercasesLowerIToAsciiIOnTurkishLocale() { + // Turkish is the locale where a locale-sensitive toUpperCase() maps 'i' -> 'İ' + // Forcing it as the default locale here proves the conversions are + // locale-independent (pinned to Locale.ROOT) rather than relying on the machine's locale. + Locale previousDefault = Locale.getDefault(); + Locale.setDefault(new Locale("tr", "TR")); + try { + String result = ConfigStrings.toEnvVar("dd.profiling.i"); - // Must be the plain ASCII 'I' (U+0049), not the Turkish dotted 'İ' (U+0130). - assertEquals("DD_PROFILING_I", result); - assertFalse( - result.indexOf(DOTTED_CAPITAL_I) >= 0, - "env var name must not contain the dotted capital I (U+0130)"); - } finally { - Locale.setDefault(previousDefault); + // Must be the plain ASCII 'I' (U+0049), not the Turkish dotted 'İ' (U+0130). + assertEquals("DD_PROFILING_I", result); + assertFalse( + result.indexOf(DOTTED_CAPITAL_I) >= 0, + "env var name must not contain the dotted capital I (U+0130)"); + } finally { + Locale.setDefault(previousDefault); + } } - } - @TableTest({ - "scenario | key | canonical ", - "property name | api-key | DD_API_KEY ", - "dotted property | profiling.proxy.password | DD_PROFILING_PROXY_PASSWORD", - "dd system property | dd.api-key | DD_API_KEY ", - "raw env var | DD_API_KEY | DD_API_KEY ", - "alias env var | DD_APP_KEY | DD_APP_KEY ", - "otel property | otel.exporter.otlp.headers | OTEL_EXPORTER_OTLP_HEADERS ", - "otel env var | OTEL_EXPORTER_OTLP_HEADERS | OTEL_EXPORTER_OTLP_HEADERS ", - "non-dd env var | AWS_LAMBDA_FUNCTION_NAME | DD_AWS_LAMBDA_FUNCTION_NAME" - }) - void canonicalizesKeysToEnvVarForm(String key, String canonical) { - assertEquals(canonical, ConfigStrings.toCanonicalEnvVar(key)); - } + @TableTest({ + "scenario | key | canonical ", + "property name | api-key | DD_API_KEY ", + "dotted property | profiling.proxy.password | DD_PROFILING_PROXY_PASSWORD", + "dd system property | dd.api-key | DD_API_KEY ", + "raw env var | DD_API_KEY | DD_API_KEY ", + "alias env var | DD_APP_KEY | DD_APP_KEY ", + "otel property | otel.exporter.otlp.headers | OTEL_EXPORTER_OTLP_HEADERS ", + "otel env var | OTEL_EXPORTER_OTLP_HEADERS | OTEL_EXPORTER_OTLP_HEADERS ", + "non-dd env var | AWS_LAMBDA_FUNCTION_NAME | DD_AWS_LAMBDA_FUNCTION_NAME" + }) + void canonicalizesKeysToEnvVarForm(String key, String canonical) { + assertEquals(canonical, ConfigStrings.toCanonicalEnvVar(key)); + } } diff --git a/utils/config-utils/src/testFixtures/java/datadog/trace/test/ConfigInversionExtension.java b/utils/config-utils/src/testFixtures/java/datadog/trace/test/ConfigInversionExtension.java index d8ba0035cd2..366f928f8f1 100644 --- a/utils/config-utils/src/testFixtures/java/datadog/trace/test/ConfigInversionExtension.java +++ b/utils/config-utils/src/testFixtures/java/datadog/trace/test/ConfigInversionExtension.java @@ -17,24 +17,23 @@ */ public class ConfigInversionExtension implements BeforeAllCallback, AfterAllCallback { - private StrictnessPolicy previousPolicy; + private StrictnessPolicy previousPolicy; - @Override - public void beforeAll(ExtensionContext ctx) { - previousPolicy = ConfigHelper.get().configInversionStrictFlag(); - ConfigHelper.get().setConfigInversionStrict(StrictnessPolicy.STRICT_TEST); - } + @Override + public void beforeAll(ExtensionContext ctx) { + previousPolicy = ConfigHelper.get().configInversionStrictFlag(); + ConfigHelper.get().setConfigInversionStrict(StrictnessPolicy.STRICT_TEST); + } - @Override - public void afterAll(ExtensionContext ctx) { - List unsupported = ConfigHelper.get().drainUnsupportedConfigs(); - ConfigHelper.get().setConfigInversionStrict(previousPolicy); + @Override + public void afterAll(ExtensionContext ctx) { + List unsupported = ConfigHelper.get().drainUnsupportedConfigs(); + ConfigHelper.get().setConfigInversionStrict(previousPolicy); - if (!unsupported.isEmpty()) { - throw new AssertionError( - "Unsupported configurations found during test. " - + "Add these to metadata/supported-configurations.json or opt out with StrictnessPolicy.TEST:\n " - + String.join("\n ", unsupported)); + if (!unsupported.isEmpty()) { + throw new AssertionError("Unsupported configurations found during test. " + + "Add these to metadata/supported-configurations.json or opt out with StrictnessPolicy.TEST:\n " + + String.join("\n ", unsupported)); + } } - } } diff --git a/utils/container-utils/src/main/java/datadog/common/container/ContainerInfo.java b/utils/container-utils/src/main/java/datadog/common/container/ContainerInfo.java index 61d174e97c5..2f48b6cb0af 100644 --- a/utils/container-utils/src/main/java/datadog/common/container/ContainerInfo.java +++ b/utils/container-utils/src/main/java/datadog/common/container/ContainerInfo.java @@ -21,296 +21,291 @@ * Qard/container-info */ @SuppressForbidden -@SuppressFBWarnings( - value = "DMI_HARDCODED_ABSOLUTE_FILENAME", - justification = "DEFAULT_CGROUP_MOUNT_PATH") +@SuppressFBWarnings(value = "DMI_HARDCODED_ABSOLUTE_FILENAME", justification = "DEFAULT_CGROUP_MOUNT_PATH") public class ContainerInfo { - private static final Logger log = LoggerFactory.getLogger(ContainerInfo.class); - - private static final Path CGROUP_DEFAULT_PROCFILE = Paths.get("/proc/self/cgroup"); - private static final Path DEFAULT_CGROUP_MOUNT_PATH = Paths.get("/sys/fs/cgroup"); - private static final Path HOST_GROUP_NAMESPACE = Paths.get("/proc/self/ns/cgroup"); - - // The second part is the PCF/Garden regexp. We assume no suffix ($) to avoid matching pod UIDs - // See https://github.com/DataDog/datadog-agent/blob/7.40.x/pkg/util/cgroups/reader.go#L50 - private static final String UUID_REGEX = - "[0-9a-f]{8}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{12}|[0-9a-f]{8}(?:-[0-9a-f]{4}){4}$"; - private static final String CONTAINER_REGEX = "[0-9a-f]{64}"; - private static final String TASK_REGEX = "[0-9a-f]{32}-\\d+"; - private static final Pattern LINE_PATTERN = Pattern.compile("(\\d+):([^:]*):(.+)$"); - private static final Pattern POD_PATTERN = - Pattern.compile("(?:.+)?pod(" + UUID_REGEX + ")(?:.slice)?$"); - private static final Pattern CONTAINER_PATTERN = - Pattern.compile( - "(?:.+)?(" + UUID_REGEX + "|" + CONTAINER_REGEX + "|" + TASK_REGEX + ")(?:.scope)?$"); - - private static final ContainerInfo INSTANCE; - - private static final long PROC_CGROUP_INIT_INO = 0xEFFFFFFBL; - private static final String CGROUPV1_BASE_CONTROLLER = "memory"; - private static final String CGROUPV2_BASE_CONTROLLER = ""; - - private static final String ENTITY_ID; - - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public String containerId; - - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public String containerTagsHash; - - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public String podId; - - @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") - public List cGroups = new ArrayList<>(); - - public String getContainerId() { - return containerId; - } - - public void setContainerId(String containerId) { - this.containerId = containerId; - } - - public String getContainerTagsHash() { - return containerTagsHash; - } - - public void setContainerTagsHash(String containerTagsHash) { - this.containerTagsHash = containerTagsHash; - } - - public String getPodId() { - return podId; - } - - public void setPodId(String podId) { - this.podId = podId; - } - - public List getCGroups() { - return cGroups; - } - - public void setcGroups(List cGroups) { - this.cGroups = cGroups; - } - - static { - ContainerInfo containerInfo = new ContainerInfo(); - if (ContainerInfo.isRunningInContainer()) { - try { - containerInfo = ContainerInfo.fromDefaultProcFile(); - } catch (final IOException | ParseException e) { - log.error("Unable to parse proc file"); - } - } + private static final Logger log = LoggerFactory.getLogger(ContainerInfo.class); - INSTANCE = containerInfo; + private static final Path CGROUP_DEFAULT_PROCFILE = Paths.get("/proc/self/cgroup"); + private static final Path DEFAULT_CGROUP_MOUNT_PATH = Paths.get("/sys/fs/cgroup"); + private static final Path HOST_GROUP_NAMESPACE = Paths.get("/proc/self/ns/cgroup"); - ENTITY_ID = readEntityID(containerInfo, isHostCgroupNamespace(), DEFAULT_CGROUP_MOUNT_PATH); - } + // The second part is the PCF/Garden regexp. We assume no suffix ($) to avoid matching pod UIDs + // See https://github.com/DataDog/datadog-agent/blob/7.40.x/pkg/util/cgroups/reader.go#L50 + private static final String UUID_REGEX = + "[0-9a-f]{8}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{12}|[0-9a-f]{8}(?:-[0-9a-f]{4}){4}$"; + private static final String CONTAINER_REGEX = "[0-9a-f]{64}"; + private static final String TASK_REGEX = "[0-9a-f]{32}-\\d+"; + private static final Pattern LINE_PATTERN = Pattern.compile("(\\d+):([^:]*):(.+)$"); + private static final Pattern POD_PATTERN = Pattern.compile("(?:.+)?pod(" + UUID_REGEX + ")(?:.slice)?$"); + private static final Pattern CONTAINER_PATTERN = + Pattern.compile("(?:.+)?(" + UUID_REGEX + "|" + CONTAINER_REGEX + "|" + TASK_REGEX + ")(?:.scope)?$"); - static @Nullable String readEntityID( - ContainerInfo containerInfo, boolean isHostCgroupNamespace, Path cgroupMountPath) { - String cid = containerInfo.getContainerId(); - if (cid != null && !cid.isEmpty()) { - return "cid-" + cid; - } - if (isHostCgroupNamespace) { - return null; - } - return getCgroupInode(cgroupMountPath, containerInfo.getCGroups()); - } - - /** Checks if the agent is running in the host cgroup namespace. */ - static boolean isHostCgroupNamespace() { - // Currently, host namespace inode number is hardcoded, which can be used to detect if it's - // running in host namespace or not (does not work when running in DinD) - // https://github.com/torvalds/linux/blob/5859a2b1991101d6b978f3feb5325dad39421f29/include/linux/proc_ns.h#L41-L49 - return readInode(HOST_GROUP_NAMESPACE) == PROC_CGROUP_INIT_INO; - } - - /** - * Returns the inode of the cgroup controller if it exists, otherwise an empty string. The inode - * is prefixed with "in-" and is used by the agent to retrieve the container ID. - */ - static @Nullable String getCgroupInode(Path cgroupMountPath, List cgroups) { - // It first tries to get the cGroupV1 "memory" controller inode, and if that fails, it tries to - // get the cGroupV2 inode. - for (String controller : Arrays.asList(CGROUPV1_BASE_CONTROLLER, CGROUPV2_BASE_CONTROLLER)) { - for (CGroupInfo cgroup : cgroups) { - if (cgroup.getControllers().contains(controller)) { - Path path = Paths.get(cgroupMountPath.toString(), controller, cgroup.getPath()); - long inode = readInode(path); - // ignore invalid and root inode - if (inode > 2) { - return "in-" + inode; - } - } - } - } - return null; - } - - /** - * @return 0 - if it couldn't read inode - */ - static long readInode(Path path) { - try { - return (long) Files.getAttribute(path, "unix:ino"); - } catch (Exception e) { - log.debug("Unable to read cgroup inode of {} because of {}", path, e.getClass()); - } - return 0; - } + private static final ContainerInfo INSTANCE; + + private static final long PROC_CGROUP_INIT_INO = 0xEFFFFFFBL; + private static final String CGROUPV1_BASE_CONTROLLER = "memory"; + private static final String CGROUPV2_BASE_CONTROLLER = ""; - public static class CGroupInfo { - public int id; - public String path; - public List controllers; + private static final String ENTITY_ID; + + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") public String containerId; + + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") + public String containerTagsHash; + + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") public String podId; - public int getId() { - return id; + @SuppressFBWarnings("PA_PUBLIC_PRIMITIVE_ATTRIBUTE") + public List cGroups = new ArrayList<>(); + + public String getContainerId() { + return containerId; } - public void setId(int id) { - this.id = id; + public void setContainerId(String containerId) { + this.containerId = containerId; } - public String getPath() { - return path; + public String getContainerTagsHash() { + return containerTagsHash; } - public void setPath(String path) { - this.path = path; + public void setContainerTagsHash(String containerTagsHash) { + this.containerTagsHash = containerTagsHash; } - public List getControllers() { - return controllers; + public String getPodId() { + return podId; } - public void setControllers(List controllers) { - this.controllers = controllers; + public void setPodId(String podId) { + this.podId = podId; } - public String getContainerId() { - return containerId; + public List getCGroups() { + return cGroups; } - public void setContainerId(String containerId) { - this.containerId = containerId; + public void setcGroups(List cGroups) { + this.cGroups = cGroups; } - public String getPodId() { - return podId; + static { + ContainerInfo containerInfo = new ContainerInfo(); + if (ContainerInfo.isRunningInContainer()) { + try { + containerInfo = ContainerInfo.fromDefaultProcFile(); + } catch (final IOException | ParseException e) { + log.error("Unable to parse proc file"); + } + } + + INSTANCE = containerInfo; + + ENTITY_ID = readEntityID(containerInfo, isHostCgroupNamespace(), DEFAULT_CGROUP_MOUNT_PATH); } - public void setPodId(String podId) { - this.podId = podId; + static @Nullable String readEntityID( + ContainerInfo containerInfo, boolean isHostCgroupNamespace, Path cgroupMountPath) { + String cid = containerInfo.getContainerId(); + if (cid != null && !cid.isEmpty()) { + return "cid-" + cid; + } + if (isHostCgroupNamespace) { + return null; + } + return getCgroupInode(cgroupMountPath, containerInfo.getCGroups()); } - } - public static ContainerInfo get() { - return INSTANCE; - } + /** Checks if the agent is running in the host cgroup namespace. */ + static boolean isHostCgroupNamespace() { + // Currently, host namespace inode number is hardcoded, which can be used to detect if it's + // running in host namespace or not (does not work when running in DinD) + // https://github.com/torvalds/linux/blob/5859a2b1991101d6b978f3feb5325dad39421f29/include/linux/proc_ns.h#L41-L49 + return readInode(HOST_GROUP_NAMESPACE) == PROC_CGROUP_INIT_INO; + } - public static String getEntityId() { - return ENTITY_ID; - } + /** + * Returns the inode of the cgroup controller if it exists, otherwise an empty string. The inode + * is prefixed with "in-" and is used by the agent to retrieve the container ID. + */ + static @Nullable String getCgroupInode(Path cgroupMountPath, List cgroups) { + // It first tries to get the cGroupV1 "memory" controller inode, and if that fails, it tries to + // get the cGroupV2 inode. + for (String controller : Arrays.asList(CGROUPV1_BASE_CONTROLLER, CGROUPV2_BASE_CONTROLLER)) { + for (CGroupInfo cgroup : cgroups) { + if (cgroup.getControllers().contains(controller)) { + Path path = Paths.get(cgroupMountPath.toString(), controller, cgroup.getPath()); + long inode = readInode(path); + // ignore invalid and root inode + if (inode > 2) { + return "in-" + inode; + } + } + } + } + return null; + } - public static boolean isRunningInContainer() { - return Files.isReadable(CGROUP_DEFAULT_PROCFILE); - } + /** + * @return 0 - if it couldn't read inode + */ + static long readInode(Path path) { + try { + return (long) Files.getAttribute(path, "unix:ino"); + } catch (Exception e) { + log.debug("Unable to read cgroup inode of {} because of {}", path, e.getClass()); + } + return 0; + } - public static ContainerInfo fromDefaultProcFile() throws IOException, ParseException { - return fromProcFile(CGROUP_DEFAULT_PROCFILE); - } + public static class CGroupInfo { + public int id; + public String path; + public List controllers; + public String containerId; + public String podId; - static ContainerInfo fromProcFile(Path path) throws IOException, ParseException { - final String content = new String(Files.readAllBytes(path)); - if (content.isEmpty()) { - log.debug("Proc file is empty"); - return new ContainerInfo(); - } - return parse(content); - } + public int getId() { + return id; + } - public static ContainerInfo parse(final String cgroupsContent) throws ParseException { - final ContainerInfo containerInfo = new ContainerInfo(); + public void setId(int id) { + this.id = id; + } - final String[] lines = cgroupsContent.split("\n"); - for (final String line : lines) { - final CGroupInfo cGroupInfo = parseLine(line); + public String getPath() { + return path; + } - containerInfo.getCGroups().add(cGroupInfo); + public void setPath(String path) { + this.path = path; + } + + public List getControllers() { + return controllers; + } + + public void setControllers(List controllers) { + this.controllers = controllers; + } + + public String getContainerId() { + return containerId; + } + + public void setContainerId(String containerId) { + this.containerId = containerId; + } + + public String getPodId() { + return podId; + } - if (cGroupInfo.getPodId() != null) { - containerInfo.setPodId(cGroupInfo.getPodId()); - } + public void setPodId(String podId) { + this.podId = podId; + } + } - replaceContainerIdIfBetter(containerInfo, cGroupInfo); + public static ContainerInfo get() { + return INSTANCE; } - return containerInfo; - } - - /** - * Keeps the best available container id candidate across cgroup lines. - * - *

Some environments expose both detailed cgroup-v1 controller paths and a cgroup-v2 membership - * path (0::...) where the tail can be task-level and less specific. - */ - private static void replaceContainerIdIfBetter( - ContainerInfo currentContainerInfo, CGroupInfo candidate) { - String candidateContainerId = candidate.getContainerId(); - if (candidateContainerId == null) return; - - String currentContainerId = currentContainerInfo.getContainerId(); - if (currentContainerId != null - && currentContainerId.matches(CONTAINER_REGEX) - && candidateContainerId.matches(TASK_REGEX) - && currentContainerInfo.getPodId() != null - && candidate.getPodId() == null) { - // Do not replace a pod-scoped container id with a task-scoped id from a less specific path. - // This protects hybrid cgroup output where trailing 0:: lines contain only task ids. - return; + public static String getEntityId() { + return ENTITY_ID; } - currentContainerInfo.setContainerId(candidateContainerId); - } + public static boolean isRunningInContainer() { + return Files.isReadable(CGROUP_DEFAULT_PROCFILE); + } - static CGroupInfo parseLine(final String line) throws ParseException { - final Matcher matcher = LINE_PATTERN.matcher(line); + public static ContainerInfo fromDefaultProcFile() throws IOException, ParseException { + return fromProcFile(CGROUP_DEFAULT_PROCFILE); + } - if (!matcher.matches()) { - throw new ParseException("Unable to match cgroup", 0); + static ContainerInfo fromProcFile(Path path) throws IOException, ParseException { + final String content = new String(Files.readAllBytes(path)); + if (content.isEmpty()) { + log.debug("Proc file is empty"); + return new ContainerInfo(); + } + return parse(content); } - final CGroupInfo cGroupInfo = new CGroupInfo(); - cGroupInfo.setId(Integer.parseInt(matcher.group(1))); - cGroupInfo.setControllers(Arrays.asList(matcher.group(2).split(","))); + public static ContainerInfo parse(final String cgroupsContent) throws ParseException { + final ContainerInfo containerInfo = new ContainerInfo(); - final String path = matcher.group(3); - final String[] pathParts = path.split("/"); + final String[] lines = cgroupsContent.split("\n"); + for (final String line : lines) { + final CGroupInfo cGroupInfo = parseLine(line); - cGroupInfo.setPath(path); + containerInfo.getCGroups().add(cGroupInfo); - if (pathParts.length >= 1) { - final Matcher containerIdMatcher = CONTAINER_PATTERN.matcher(pathParts[pathParts.length - 1]); - final String containerId = containerIdMatcher.matches() ? containerIdMatcher.group(1) : null; - cGroupInfo.setContainerId(containerId); + if (cGroupInfo.getPodId() != null) { + containerInfo.setPodId(cGroupInfo.getPodId()); + } + + replaceContainerIdIfBetter(containerInfo, cGroupInfo); + } + + return containerInfo; } - if (pathParts.length >= 2) { - final Matcher podIdMatcher = POD_PATTERN.matcher(pathParts[pathParts.length - 2]); - final String podId = podIdMatcher.matches() ? podIdMatcher.group(1) : null; - cGroupInfo.setPodId(podId); + /** + * Keeps the best available container id candidate across cgroup lines. + * + *

Some environments expose both detailed cgroup-v1 controller paths and a cgroup-v2 membership + * path (0::...) where the tail can be task-level and less specific. + */ + private static void replaceContainerIdIfBetter(ContainerInfo currentContainerInfo, CGroupInfo candidate) { + String candidateContainerId = candidate.getContainerId(); + if (candidateContainerId == null) return; + + String currentContainerId = currentContainerInfo.getContainerId(); + if (currentContainerId != null + && currentContainerId.matches(CONTAINER_REGEX) + && candidateContainerId.matches(TASK_REGEX) + && currentContainerInfo.getPodId() != null + && candidate.getPodId() == null) { + // Do not replace a pod-scoped container id with a task-scoped id from a less specific path. + // This protects hybrid cgroup output where trailing 0:: lines contain only task ids. + return; + } + + currentContainerInfo.setContainerId(candidateContainerId); } - return cGroupInfo; - } + static CGroupInfo parseLine(final String line) throws ParseException { + final Matcher matcher = LINE_PATTERN.matcher(line); + + if (!matcher.matches()) { + throw new ParseException("Unable to match cgroup", 0); + } + + final CGroupInfo cGroupInfo = new CGroupInfo(); + cGroupInfo.setId(Integer.parseInt(matcher.group(1))); + cGroupInfo.setControllers(Arrays.asList(matcher.group(2).split(","))); + + final String path = matcher.group(3); + final String[] pathParts = path.split("/"); + + cGroupInfo.setPath(path); + + if (pathParts.length >= 1) { + final Matcher containerIdMatcher = CONTAINER_PATTERN.matcher(pathParts[pathParts.length - 1]); + final String containerId = containerIdMatcher.matches() ? containerIdMatcher.group(1) : null; + cGroupInfo.setContainerId(containerId); + } + + if (pathParts.length >= 2) { + final Matcher podIdMatcher = POD_PATTERN.matcher(pathParts[pathParts.length - 2]); + final String podId = podIdMatcher.matches() ? podIdMatcher.group(1) : null; + cGroupInfo.setPodId(podId); + } + + return cGroupInfo; + } } diff --git a/utils/container-utils/src/main/java/datadog/common/container/ServerlessInfo.java b/utils/container-utils/src/main/java/datadog/common/container/ServerlessInfo.java index 8603deac8cf..89cccef7305 100644 --- a/utils/container-utils/src/main/java/datadog/common/container/ServerlessInfo.java +++ b/utils/container-utils/src/main/java/datadog/common/container/ServerlessInfo.java @@ -5,46 +5,43 @@ import java.io.File; public class ServerlessInfo { - private static final String AWS_FUNCTION_VARIABLE = "AWS_LAMBDA_FUNCTION_NAME"; - private static final String EXTENSION_PATH = "/opt/extensions/datadog-agent"; - private static final ServerlessInfo INSTANCE = new ServerlessInfo(); - - private final String functionName; - private final boolean hasExtension; - - private ServerlessInfo(final String extensionPath) { - this.functionName = ConfigHelper.env(AWS_FUNCTION_VARIABLE); - if (null == extensionPath) { - this.hasExtension = false; - } else { - File f = new File(extensionPath); - this.hasExtension = (f.exists() && !f.isDirectory()); + private static final String AWS_FUNCTION_VARIABLE = "AWS_LAMBDA_FUNCTION_NAME"; + private static final String EXTENSION_PATH = "/opt/extensions/datadog-agent"; + private static final ServerlessInfo INSTANCE = new ServerlessInfo(); + + private final String functionName; + private final boolean hasExtension; + + private ServerlessInfo(final String extensionPath) { + this.functionName = ConfigHelper.env(AWS_FUNCTION_VARIABLE); + if (null == extensionPath) { + this.hasExtension = false; + } else { + File f = new File(extensionPath); + this.hasExtension = (f.exists() && !f.isDirectory()); + } + } + + @SuppressFBWarnings({"DMI_HARDCODED_ABSOLUTE_FILENAME", "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR"}) + public ServerlessInfo() { + // TODO add more serverless configuration properties + // support envs other than AWS lambda + this(EXTENSION_PATH); + } + + public static ServerlessInfo get() { + return INSTANCE; + } + + public boolean isRunningInServerlessEnvironment() { + return functionName != null && !functionName.isEmpty(); + } + + public boolean hasExtension() { + return hasExtension; + } + + public String getFunctionName() { + return functionName; } - } - - @SuppressFBWarnings({ - "DMI_HARDCODED_ABSOLUTE_FILENAME", - "SING_SINGLETON_HAS_NONPRIVATE_CONSTRUCTOR" - }) - public ServerlessInfo() { - // TODO add more serverless configuration properties - // support envs other than AWS lambda - this(EXTENSION_PATH); - } - - public static ServerlessInfo get() { - return INSTANCE; - } - - public boolean isRunningInServerlessEnvironment() { - return functionName != null && !functionName.isEmpty(); - } - - public boolean hasExtension() { - return hasExtension; - } - - public String getFunctionName() { - return functionName; - } } diff --git a/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java b/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java index cb6fe8bf0d9..2e5b7470dca 100644 --- a/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java +++ b/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java @@ -27,7 +27,7 @@ @SuppressForbidden public class ContainerInfoTest extends DDJavaSpecification { - // spotless:off + // spotless:off @TableTest({ "id | controllers | path | containerId | podId | line", // Docker examples @@ -99,19 +99,19 @@ void cGroupInfoIsParsedFromIndividualLines( } // spotless:on - @ParameterizedTest - @MethodSource("containerInfoParsedFromFileContentArguments") - void containerInfoParsedFromFileContent( - String containerId, String podId, int size, String content) throws Exception { - ContainerInfo containerInfo = ContainerInfo.parse(content); + @ParameterizedTest + @MethodSource("containerInfoParsedFromFileContentArguments") + void containerInfoParsedFromFileContent(String containerId, String podId, int size, String content) + throws Exception { + ContainerInfo containerInfo = ContainerInfo.parse(content); - assertEquals(containerId, containerInfo.getContainerId()); - assertEquals(podId, containerInfo.getPodId()); - assertEquals(size, containerInfo.getCGroups().size()); - } + assertEquals(containerId, containerInfo.getContainerId()); + assertEquals(podId, containerInfo.getPodId()); + assertEquals(size, containerInfo.getCGroups().size()); + } - static Stream containerInfoParsedFromFileContentArguments() { - // spotless:off + static Stream containerInfoParsedFromFileContentArguments() { + // spotless:off return Stream.of( // Docker arguments("3726184226f5d3147c25fdeab5b60097e378e8a720503a5e19ecfdf29f869860", null, 13, @@ -211,55 +211,55 @@ static Stream containerInfoParsedFromFileContentArguments() { + "1:name=systemd:/system.slice/garden.service/garden/6f265890-5165-7fab-6b52-18d1") ); // spotless:on - } + } - @Test - void containerInfoFromEmptyFileIsEmpty() throws Exception { - File f = File.createTempFile("container-info-test-", "-empty-file"); - f.deleteOnExit(); - Path p = Paths.get(f.getPath()); + @Test + void containerInfoFromEmptyFileIsEmpty() throws Exception { + File f = File.createTempFile("container-info-test-", "-empty-file"); + f.deleteOnExit(); + Path p = Paths.get(f.getPath()); - ContainerInfo containerInfo = ContainerInfo.fromProcFile(p); + ContainerInfo containerInfo = ContainerInfo.fromProcFile(p); - assertNull(containerInfo.getContainerId()); - assertNull(containerInfo.getPodId()); - assertEquals(0, containerInfo.getCGroups().size()); - } + assertNull(containerInfo.getContainerId()); + assertNull(containerInfo.getPodId()); + assertEquals(0, containerInfo.getCGroups().size()); + } - @Test - void containerInfoThrowsParseExceptionWhenGivenMalformedProcfile() throws Exception { - File f = File.createTempFile("container-info-test-", "-malformed-file"); - f.deleteOnExit(); - Files.write(f.toPath(), "This is not valid".getBytes()); - Path p = Paths.get(f.getPath()); + @Test + void containerInfoThrowsParseExceptionWhenGivenMalformedProcfile() throws Exception { + File f = File.createTempFile("container-info-test-", "-malformed-file"); + f.deleteOnExit(); + Files.write(f.toPath(), "This is not valid".getBytes()); + Path p = Paths.get(f.getPath()); - assertThrows(ParseException.class, () -> ContainerInfo.fromProcFile(p)); - } + assertThrows(ParseException.class, () -> ContainerInfo.fromProcFile(p)); + } - @Test - void containerInfoToleratesMissingContainerIdAndPodIdInProcfile() throws Exception { - File f = File.createTempFile("container-info-test-", "-missing-container-id"); - f.deleteOnExit(); - Files.write(f.toPath(), "1:cpuset:fake-path".getBytes()); - Path p = Paths.get(f.getPath()); + @Test + void containerInfoToleratesMissingContainerIdAndPodIdInProcfile() throws Exception { + File f = File.createTempFile("container-info-test-", "-missing-container-id"); + f.deleteOnExit(); + Files.write(f.toPath(), "1:cpuset:fake-path".getBytes()); + Path p = Paths.get(f.getPath()); - ContainerInfo containerInfo = ContainerInfo.fromProcFile(p); + ContainerInfo containerInfo = ContainerInfo.fromProcFile(p); - assertNull(containerInfo.getContainerId()); - assertNull(containerInfo.getPodId()); - assertEquals(1, containerInfo.getCGroups().size()); - } + assertNull(containerInfo.getContainerId()); + assertNull(containerInfo.getPodId()); + assertEquals(1, containerInfo.getCGroups().size()); + } - @Test - void getInoPathShouldReturnSameValueAsLsIdPath() throws Exception { - File f = File.createTempFile("container-info-test-", "-inode-file"); - f.deleteOnExit(); - Path path = f.toPath(); + @Test + void getInoPathShouldReturnSameValueAsLsIdPath() throws Exception { + File f = File.createTempFile("container-info-test-", "-inode-file"); + f.deleteOnExit(); + Path path = f.toPath(); - assertEquals(readInode(path), ContainerInfo.readInode(path)); - } + assertEquals(readInode(path), ContainerInfo.readInode(path)); + } - // spotless:off + // spotless:off @TableTest({ "cid | isHostCgroupNamespace", "cid | true ", @@ -275,19 +275,19 @@ void readEntityIDReturnCidContainerIdIfContainerIdIsDefined( } // spotless:on - @TableTest({ - "cid", - " ", - "'' " - }) - void readEntityIDReturnNullIfContainerIdIsNotDefinedAndIsHostCgroupNamespace(String cid) { - ContainerInfo containerInfo = new ContainerInfo(); - containerInfo.setContainerId(cid); + @TableTest({ + "cid", + " ", + "'' " + }) + void readEntityIDReturnNullIfContainerIdIsNotDefinedAndIsHostCgroupNamespace(String cid) { + ContainerInfo containerInfo = new ContainerInfo(); + containerInfo.setContainerId(cid); - assertNull(ContainerInfo.readEntityID(containerInfo, true, Paths.get("/sys/fs/cgroup"))); - } + assertNull(ContainerInfo.readEntityID(containerInfo, true, Paths.get("/sys/fs/cgroup"))); + } - // spotless:off + // spotless:off @TableTest({ "controllers | hasEntityId", "['', memory] | true ", @@ -340,37 +340,38 @@ void readEntityIDReturnIdInoForMemoryController( } // spotless:on - @Test - void readEntityIDReturnIdInoForParentWhenPathIsSlash() throws Exception { - File mountPath = createTempDir(); - File memoryController = Files.createDirectory(mountPath.toPath().resolve("memory")).toFile(); - memoryController.deleteOnExit(); - long ino = readInode(memoryController.toPath()); - - ContainerInfo containerInfo = new ContainerInfo(); - ContainerInfo.CGroupInfo cGroupInfo = new ContainerInfo.CGroupInfo(); - cGroupInfo.setControllers(Arrays.asList("memory")); - cGroupInfo.setPath("/"); - containerInfo.setcGroups(Arrays.asList(cGroupInfo)); - - assertEquals("in-" + ino, ContainerInfo.readEntityID(containerInfo, false, mountPath.toPath())); - } + @Test + void readEntityIDReturnIdInoForParentWhenPathIsSlash() throws Exception { + File mountPath = createTempDir(); + File memoryController = + Files.createDirectory(mountPath.toPath().resolve("memory")).toFile(); + memoryController.deleteOnExit(); + long ino = readInode(memoryController.toPath()); + + ContainerInfo containerInfo = new ContainerInfo(); + ContainerInfo.CGroupInfo cGroupInfo = new ContainerInfo.CGroupInfo(); + cGroupInfo.setControllers(Arrays.asList("memory")); + cGroupInfo.setPath("/"); + containerInfo.setcGroups(Arrays.asList(cGroupInfo)); + + assertEquals("in-" + ino, ContainerInfo.readEntityID(containerInfo, false, mountPath.toPath())); + } - private static File createTempDir() throws IOException { - File dir = File.createTempFile("container-info-test-", "-sys-fs-cgroup"); - dir.delete(); - dir.mkdirs(); - dir.deleteOnExit(); - return dir; - } + private static File createTempDir() throws IOException { + File dir = File.createTempFile("container-info-test-", "-sys-fs-cgroup"); + dir.delete(); + dir.mkdirs(); + dir.deleteOnExit(); + return dir; + } - private static long readInode(Path path) throws IOException, InterruptedException { - ProcessBuilder pb = new ProcessBuilder("ls", "-id", path.toString()); - Process ps = pb.start(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(ps.getInputStream()))) { - String line = reader.readLine(); - ps.waitFor(); - return Long.parseLong(line.substring(0, line.indexOf(' '))); + private static long readInode(Path path) throws IOException, InterruptedException { + ProcessBuilder pb = new ProcessBuilder("ls", "-id", path.toString()); + Process ps = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(ps.getInputStream()))) { + String line = reader.readLine(); + ps.waitFor(); + return Long.parseLong(line.substring(0, line.indexOf(' '))); + } } - } } diff --git a/utils/container-utils/src/test/java/datadog/common/container/ServerlessInfoTest.java b/utils/container-utils/src/test/java/datadog/common/container/ServerlessInfoTest.java index 059a69424f5..c0beaa2bfa8 100644 --- a/utils/container-utils/src/test/java/datadog/common/container/ServerlessInfoTest.java +++ b/utils/container-utils/src/test/java/datadog/common/container/ServerlessInfoTest.java @@ -14,60 +14,58 @@ public class ServerlessInfoTest extends DDJavaSpecification { - // AWS_LAMBDA_FUNCTION_NAME is ServerlessInfo.AWS_FUNCTION_VARIABLE (private constant) - private static final String AWS_FUNCTION_VARIABLE = "AWS_LAMBDA_FUNCTION_NAME"; - - private static final ControllableEnvironmentVariables environmentVariables = - ControllableEnvironmentVariables.setup(); - - @AfterEach - void clearEnvVars() { - environmentVariables.clear(); - } - - @TableTest({ - "functionName | serverlessEnv", - " | false ", - "'' | false ", - "someName | true " - }) - void testServerlessDetection(String functionName, boolean serverlessEnv) { - environmentVariables.set(AWS_FUNCTION_VARIABLE, functionName); - - ServerlessInfo info = new ServerlessInfo(); - - assertEquals(serverlessEnv, info.isRunningInServerlessEnvironment()); - assertEquals(functionName, info.getFunctionName()); - } - - @Test - void testServerlessHasExtensionFalse() { - ServerlessInfo info = new ServerlessInfo(); - - assertFalse(info.hasExtension()); - } - - @Test - void testServerlessHasExtensionFalseSinceExtensionPathIsNull() throws Exception { - // ServerlessInfo(String extensionPath) is private — access via reflection - Constructor constructor = - ServerlessInfo.class.getDeclaredConstructor(String.class); - constructor.setAccessible(true); - ServerlessInfo info = constructor.newInstance((Object) null); - - assertFalse(info.hasExtension()); - } - - @Test - void testServerlessHasExtensionTrue() throws Exception { - File f = File.createTempFile("fake-", "extension"); - f.deleteOnExit(); - - Constructor constructor = - ServerlessInfo.class.getDeclaredConstructor(String.class); - constructor.setAccessible(true); - ServerlessInfo info = constructor.newInstance(f.getAbsolutePath()); - - assertTrue(info.hasExtension()); - } + // AWS_LAMBDA_FUNCTION_NAME is ServerlessInfo.AWS_FUNCTION_VARIABLE (private constant) + private static final String AWS_FUNCTION_VARIABLE = "AWS_LAMBDA_FUNCTION_NAME"; + + private static final ControllableEnvironmentVariables environmentVariables = + ControllableEnvironmentVariables.setup(); + + @AfterEach + void clearEnvVars() { + environmentVariables.clear(); + } + + @TableTest({ + "functionName | serverlessEnv", + " | false ", + "'' | false ", + "someName | true " + }) + void testServerlessDetection(String functionName, boolean serverlessEnv) { + environmentVariables.set(AWS_FUNCTION_VARIABLE, functionName); + + ServerlessInfo info = new ServerlessInfo(); + + assertEquals(serverlessEnv, info.isRunningInServerlessEnvironment()); + assertEquals(functionName, info.getFunctionName()); + } + + @Test + void testServerlessHasExtensionFalse() { + ServerlessInfo info = new ServerlessInfo(); + + assertFalse(info.hasExtension()); + } + + @Test + void testServerlessHasExtensionFalseSinceExtensionPathIsNull() throws Exception { + // ServerlessInfo(String extensionPath) is private — access via reflection + Constructor constructor = ServerlessInfo.class.getDeclaredConstructor(String.class); + constructor.setAccessible(true); + ServerlessInfo info = constructor.newInstance((Object) null); + + assertFalse(info.hasExtension()); + } + + @Test + void testServerlessHasExtensionTrue() throws Exception { + File f = File.createTempFile("fake-", "extension"); + f.deleteOnExit(); + + Constructor constructor = ServerlessInfo.class.getDeclaredConstructor(String.class); + constructor.setAccessible(true); + ServerlessInfo info = constructor.newInstance(f.getAbsolutePath()); + + assertTrue(info.hasExtension()); + } } diff --git a/utils/filesystem-utils/src/main/java/datadog/common/filesystem/Files.java b/utils/filesystem-utils/src/main/java/datadog/common/filesystem/Files.java index c0ebf34bc45..c820aff8172 100644 --- a/utils/filesystem-utils/src/main/java/datadog/common/filesystem/Files.java +++ b/utils/filesystem-utils/src/main/java/datadog/common/filesystem/Files.java @@ -6,25 +6,25 @@ /** Utility methods related to file operations. */ public final class Files { - private Files() { - // Prevent instantiation - } + private Files() { + // Prevent instantiation + } - /** - * Determines whether the given file exists on the filesystem. - * - *

This method wraps {@link File#exists()} and safely handles {@link SecurityException}s that - * may occur if the caller does not have the required permissions to examine the file. In such - * cases, this method returns {@code false}. - * - * @param file the file to check for existence; must not be {@code null} - * @return {@code true} if the file exists and can be queried, {@code false} otherwise - */ - public static boolean exists(@Nonnull File file) { - try { - return file.exists(); - } catch (SecurityException ignored) { - return false; + /** + * Determines whether the given file exists on the filesystem. + * + *

This method wraps {@link File#exists()} and safely handles {@link SecurityException}s that + * may occur if the caller does not have the required permissions to examine the file. In such + * cases, this method returns {@code false}. + * + * @param file the file to check for existence; must not be {@code null} + * @return {@code true} if the file exists and can be queried, {@code false} otherwise + */ + public static boolean exists(@Nonnull File file) { + try { + return file.exists(); + } catch (SecurityException ignored) { + return false; + } } - } } diff --git a/utils/filesystem-utils/src/test/java/datadog/common/filesystem/FilesTest.java b/utils/filesystem-utils/src/test/java/datadog/common/filesystem/FilesTest.java index daa50573910..88d94e432f1 100644 --- a/utils/filesystem-utils/src/test/java/datadog/common/filesystem/FilesTest.java +++ b/utils/filesystem-utils/src/test/java/datadog/common/filesystem/FilesTest.java @@ -12,64 +12,63 @@ public class FilesTest { - private SecurityManager originalSM; + private SecurityManager originalSM; - @Test - void existsReturnsTrueWhenFileExistsAndIsAccessible() throws IOException { - File file = File.createTempFile("test", "txt"); - file.deleteOnExit(); + @Test + void existsReturnsTrueWhenFileExistsAndIsAccessible() throws IOException { + File file = File.createTempFile("test", "txt"); + file.deleteOnExit(); - assertTrue(Files.exists(file)); - } + assertTrue(Files.exists(file)); + } - @Test - void existsReturnsFalseWhenFileDoesNotExist() throws IOException { - File file = File.createTempFile("missing", "txt"); - assertTrue(file.delete()); // ensure it does not exist + @Test + void existsReturnsFalseWhenFileDoesNotExist() throws IOException { + File file = File.createTempFile("missing", "txt"); + assertTrue(file.delete()); // ensure it does not exist - assertFalse(Files.exists(file)); - } + assertFalse(Files.exists(file)); + } - @Test - @DisabledIf("isJava18OrLater") - void existsReturnsFalseWhenSecurityManagerForbidsFileAccess() throws IOException { - File file = File.createTempFile("test", "txt"); - file.deleteOnExit(); + @Test + @DisabledIf("isJava18OrLater") + void existsReturnsFalseWhenSecurityManagerForbidsFileAccess() throws IOException { + File file = File.createTempFile("test", "txt"); + file.deleteOnExit(); - // --- install restrictive SecurityManager only in this test --- - SecurityManager originalSM = System.getSecurityManager(); + // --- install restrictive SecurityManager only in this test --- + SecurityManager originalSM = System.getSecurityManager(); - System.setSecurityManager( - new SecurityManager() { - @Override - public void checkRead(String filePath) { - // Deny only THIS file so classloading still works - if (filePath.equals(file.getAbsolutePath())) { - throw new SecurityException("Access denied"); + System.setSecurityManager(new SecurityManager() { + @Override + public void checkRead(String filePath) { + // Deny only THIS file so classloading still works + if (filePath.equals(file.getAbsolutePath())) { + throw new SecurityException("Access denied"); + } } - } - @Override - public void checkPermission(Permission perm) { - // allow everything else - } + @Override + public void checkPermission(Permission perm) { + // allow everything else + } - @Override - public void checkPermission(Permission perm, Object context) { - // allow everything else - } + @Override + public void checkPermission(Permission perm, Object context) { + // allow everything else + } }); - try { - boolean result = Files.exists(file); - assertFalse(result); - } finally { - // --- restore original security manager --- - System.setSecurityManager(originalSM); + try { + boolean result = Files.exists(file); + assertFalse(result); + } finally { + // --- restore original security manager --- + System.setSecurityManager(originalSM); + } } - } - static boolean isJava18OrLater() { - return JavaVirtualMachine.isJavaVersionAtLeast(18); - } + static boolean isJava18OrLater() { + return JavaVirtualMachine.isJavaVersionAtLeast(18); + } } diff --git a/utils/flare-utils/src/main/java/datadog/flare/TracerFlarePoller.java b/utils/flare-utils/src/main/java/datadog/flare/TracerFlarePoller.java index 49755cf24d0..19f1491c243 100644 --- a/utils/flare-utils/src/main/java/datadog/flare/TracerFlarePoller.java +++ b/utils/flare-utils/src/main/java/datadog/flare/TracerFlarePoller.java @@ -17,169 +17,164 @@ import okio.Okio; public final class TracerFlarePoller { - private static final String FLARE_LOG_LEVEL = "flare-log-level"; + private static final String FLARE_LOG_LEVEL = "flare-log-level"; - private Runnable stopPreparer; - private Runnable stopSubmitter; + private Runnable stopPreparer; + private Runnable stopSubmitter; - private TracerFlareService tracerFlareService; + private TracerFlareService tracerFlareService; - private final Map configAction = new HashMap<>(); - private static TracerFlarePoller INSTANCE; + private final Map configAction = new HashMap<>(); + private static TracerFlarePoller INSTANCE; - public static void start(SharedCommunicationObjects sco) { - if (null == INSTANCE) { - INSTANCE = new TracerFlarePoller(); + public static void start(SharedCommunicationObjects sco) { + if (null == INSTANCE) { + INSTANCE = new TracerFlarePoller(); + } + INSTANCE.doStart(sco); } - INSTANCE.doStart(sco); - } - public static void stop() { - if (null != INSTANCE) { - INSTANCE.doStop(); + public static void stop() { + if (null != INSTANCE) { + INSTANCE.doStop(); + } } - } - - private void doStart(SharedCommunicationObjects sco) { - Config config = Config.get(); - stopPreparer = new Preparer().register(config, sco); - stopSubmitter = new Submitter().register(config, sco); - tracerFlareService = new TracerFlareService(config, sco.agentHttpClient, sco.agentUrl); - } - - private void doStop() { - if (null != stopPreparer) { - stopPreparer.run(); - } - if (null != stopSubmitter) { - stopSubmitter.run(); - } - } - final class Preparer implements ProductListener { - private final JsonAdapter AGENT_CONFIG_LAYER_ADAPTER; - - { - Moshi MOSHI = new Moshi.Builder().build(); - AGENT_CONFIG_LAYER_ADAPTER = MOSHI.adapter(AgentConfigLayer.class); + private void doStart(SharedCommunicationObjects sco) { + Config config = Config.get(); + stopPreparer = new Preparer().register(config, sco); + stopSubmitter = new Submitter().register(config, sco); + tracerFlareService = new TracerFlareService(config, sco.agentHttpClient, sco.agentUrl); } - public Runnable register(Config config, SharedCommunicationObjects sco) { - ConfigurationPoller poller = sco.configurationPoller(config); - if (null != poller) { - poller.addListener(Product.AGENT_CONFIG, this); - return poller::stop; - } else { - return null; - } + private void doStop() { + if (null != stopPreparer) { + stopPreparer.run(); + } + if (null != stopSubmitter) { + stopSubmitter.run(); + } } - @Override - public void accept(ConfigKey configKey, byte[] content, PollingRateHinter hinter) - throws IOException { - AgentConfigLayer agentConfigLayer = - AGENT_CONFIG_LAYER_ADAPTER.fromJson( - Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); - if (null != agentConfigLayer - && null != agentConfigLayer.config - && null != agentConfigLayer.config.logLevel) { - configAction.put(configKey.getConfigId(), FLARE_LOG_LEVEL); - prepareForFlare(agentConfigLayer.config.logLevel); - } else { - cleanupAfterFlare(); - } + final class Preparer implements ProductListener { + private final JsonAdapter AGENT_CONFIG_LAYER_ADAPTER; + + { + Moshi MOSHI = new Moshi.Builder().build(); + AGENT_CONFIG_LAYER_ADAPTER = MOSHI.adapter(AgentConfigLayer.class); + } + + public Runnable register(Config config, SharedCommunicationObjects sco) { + ConfigurationPoller poller = sco.configurationPoller(config); + if (null != poller) { + poller.addListener(Product.AGENT_CONFIG, this); + return poller::stop; + } else { + return null; + } + } + + @Override + public void accept(ConfigKey configKey, byte[] content, PollingRateHinter hinter) throws IOException { + AgentConfigLayer agentConfigLayer = + AGENT_CONFIG_LAYER_ADAPTER.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); + if (null != agentConfigLayer + && null != agentConfigLayer.config + && null != agentConfigLayer.config.logLevel) { + configAction.put(configKey.getConfigId(), FLARE_LOG_LEVEL); + prepareForFlare(agentConfigLayer.config.logLevel); + } else { + cleanupAfterFlare(); + } + } + + @Override + public void remove(ConfigKey configKey, PollingRateHinter hinter) { + if (configAction.remove(configKey.getConfigId(), FLARE_LOG_LEVEL)) { + cleanupAfterFlare(); + } + } + + @Override + public void commit(PollingRateHinter hinter) {} } - @Override - public void remove(ConfigKey configKey, PollingRateHinter hinter) { - if (configAction.remove(configKey.getConfigId(), FLARE_LOG_LEVEL)) { - cleanupAfterFlare(); - } + final class Submitter implements ProductListener { + private final JsonAdapter AGENT_TASK_ADAPTER; + + { + Moshi MOSHI = new Moshi.Builder().build(); + AGENT_TASK_ADAPTER = MOSHI.adapter(AgentTask.class); + } + + public Runnable register(Config config, SharedCommunicationObjects sco) { + ConfigurationPoller poller = sco.configurationPoller(config); + if (null != poller) { + poller.addListener(Product.AGENT_TASK, this); + return poller::stop; + } else { + return null; + } + } + + @Override + public void accept(ConfigKey configKey, byte[] content, PollingRateHinter hinter) throws IOException { + AgentTask agentTask = + AGENT_TASK_ADAPTER.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); + + if (null != agentTask && null != agentTask.args && "tracer_flare".equals(agentTask.taskType)) { + sendFlare(agentTask.args); + } + } + + @Override + public void remove(ConfigKey configKey, PollingRateHinter hinter) {} + + @Override + public void commit(PollingRateHinter hinter) {} } - @Override - public void commit(PollingRateHinter hinter) {} - } - - final class Submitter implements ProductListener { - private final JsonAdapter AGENT_TASK_ADAPTER; - - { - Moshi MOSHI = new Moshi.Builder().build(); - AGENT_TASK_ADAPTER = MOSHI.adapter(AgentTask.class); + void prepareForFlare(String logLevel) { + tracerFlareService.prepareForFlare(logLevel); } - public Runnable register(Config config, SharedCommunicationObjects sco) { - ConfigurationPoller poller = sco.configurationPoller(config); - if (null != poller) { - poller.addListener(Product.AGENT_TASK, this); - return poller::stop; - } else { - return null; - } + void cleanupAfterFlare() { + tracerFlareService.cleanupAfterFlare(); } - @Override - public void accept(ConfigKey configKey, byte[] content, PollingRateHinter hinter) - throws IOException { - AgentTask agentTask = - AGENT_TASK_ADAPTER.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); - - if (null != agentTask - && null != agentTask.args - && "tracer_flare".equals(agentTask.taskType)) { - sendFlare(agentTask.args); - } + void sendFlare(AgentTaskArgs args) { + tracerFlareService.sendFlare(args.caseId, args.userHandle, args.hostname); } - @Override - public void remove(ConfigKey configKey, PollingRateHinter hinter) {} - - @Override - public void commit(PollingRateHinter hinter) {} - } - - void prepareForFlare(String logLevel) { - tracerFlareService.prepareForFlare(logLevel); - } + static final class AgentConfigLayer { + @Json(name = "name") + public String name; - void cleanupAfterFlare() { - tracerFlareService.cleanupAfterFlare(); - } - - void sendFlare(AgentTaskArgs args) { - tracerFlareService.sendFlare(args.caseId, args.userHandle, args.hostname); - } - - static final class AgentConfigLayer { - @Json(name = "name") - public String name; - - @Json(name = "config") - public AgentConfig config; - } + @Json(name = "config") + public AgentConfig config; + } - static final class AgentConfig { - @Json(name = "log_level") - public String logLevel; - } + static final class AgentConfig { + @Json(name = "log_level") + public String logLevel; + } - static final class AgentTask { - @Json(name = "task_type") - public String taskType; + static final class AgentTask { + @Json(name = "task_type") + public String taskType; - @Json(name = "args") - public AgentTaskArgs args; - } + @Json(name = "args") + public AgentTaskArgs args; + } - static final class AgentTaskArgs { - @Json(name = "case_id") - public String caseId; + static final class AgentTaskArgs { + @Json(name = "case_id") + public String caseId; - @Json(name = "user_handle") - public String userHandle; + @Json(name = "user_handle") + public String userHandle; - @Json(name = "hostname") - public String hostname; - } + @Json(name = "hostname") + public String hostname; + } } diff --git a/utils/flare-utils/src/main/java/datadog/flare/TracerFlareService.java b/utils/flare-utils/src/main/java/datadog/flare/TracerFlareService.java index dcb097287a3..40d9f47afc8 100644 --- a/utils/flare-utils/src/main/java/datadog/flare/TracerFlareService.java +++ b/utils/flare-utils/src/main/java/datadog/flare/TracerFlareService.java @@ -37,237 +37,232 @@ import org.slf4j.LoggerFactory; final class TracerFlareService { - private static final Logger log = LoggerFactory.getLogger(TracerFlareService.class); + private static final Logger log = LoggerFactory.getLogger(TracerFlareService.class); - private static final String FLARE_ENDPOINT = "tracer_flare/v1"; + private static final String FLARE_ENDPOINT = "tracer_flare/v1"; - private static final String REPORT_PREFIX = "dd-java-flare-"; + private static final String REPORT_PREFIX = "dd-java-flare-"; - private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream"); + private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream"); - private static final int MAX_LOGFILE_SIZE_MB = 15; + private static final int MAX_LOGFILE_SIZE_MB = 15; - private static final int MAX_LOGFILE_SIZE_BYTES = MAX_LOGFILE_SIZE_MB << 20; + private static final int MAX_LOGFILE_SIZE_BYTES = MAX_LOGFILE_SIZE_MB << 20; - private final AgentTaskScheduler scheduler = new AgentTaskScheduler(TRACER_FLARE); + private final AgentTaskScheduler scheduler = new AgentTaskScheduler(TRACER_FLARE); - private final Config config; - private final OkHttpClient okHttpClient; - private final HttpUrl flareUrl; + private final Config config; + private final OkHttpClient okHttpClient; + private final HttpUrl flareUrl; - private boolean logLevelOverridden; - private volatile long flareStartMillis; + private boolean logLevelOverridden; + private volatile long flareStartMillis; - private Scheduled scheduledCleanup; + private Scheduled scheduledCleanup; - TracerFlareService(Config config, OkHttpClient okHttpClient, HttpUrl agentUrl) { - this.config = config; - this.okHttpClient = okHttpClient; - this.flareUrl = agentUrl.newBuilder().addPathSegments(FLARE_ENDPOINT).build(); + TracerFlareService(Config config, OkHttpClient okHttpClient, HttpUrl agentUrl) { + this.config = config; + this.okHttpClient = okHttpClient; + this.flareUrl = agentUrl.newBuilder().addPathSegments(FLARE_ENDPOINT).build(); - applyTriageReportTrigger(config.getTriageReportTrigger()); - } + applyTriageReportTrigger(config.getTriageReportTrigger()); + } + + private void applyTriageReportTrigger(String triageTrigger) { + if (null != triageTrigger && !triageTrigger.isEmpty()) { + long delay = TimeUtils.parseSimpleDelay(triageTrigger); + if (delay < 0) { + log.info("Unrecognized triage trigger {}", triageTrigger); + } else { + scheduleTriageReport(delay); + } + } + } + + private void scheduleTriageReport(long delayInSeconds) { + Path triagePath = Paths.get(config.getTriageReportDir()); + // prepare at most 10 minutes before collection of the report, to match remote flare behaviour + scheduler.schedule(() -> prepareForFlare("triage"), delayInSeconds - 600, TimeUnit.SECONDS); + scheduler.schedule( + () -> { + try { + if (!Files.isDirectory(triagePath)) { + Files.createDirectories(triagePath); + } + long flareEndMillis = System.currentTimeMillis(); + Path reportPath = triagePath.resolve(getFlareName(flareEndMillis)); + log.info("Writing triage report to {}", reportPath); + Files.write(reportPath, buildFlareZip(flareStartMillis, flareEndMillis, true)); + } catch (Throwable e) { + log.info("Problem writing triage report", e); + } finally { + cleanupAfterFlare(); + } + }, + delayInSeconds, + TimeUnit.SECONDS); + } + + public synchronized void prepareForFlare(String logLevel) { + // allow turning on debug even part way through preparation + + if (!log.isDebugEnabled() && "debug".equalsIgnoreCase(logLevel)) { + GlobalLogLevelSwitcher.get().switchLevel(LogLevel.DEBUG); + logLevelOverridden = true; + } + Scheduled oldSchedule = scheduledCleanup; + if (null != oldSchedule) { + // already preparing, just cancel old schedule in favour of new one + oldSchedule.cancel(); + } else { + flareStartMillis = System.currentTimeMillis(); + log.debug("Preparing for tracer flare, logLevel={}", logLevel); + TracerFlare.prepareForFlare(); + } + // always schedule fresh clean-up 20 minutes from last prepare request + scheduledCleanup = scheduler.schedule(new CleanupTask(), 20, TimeUnit.MINUTES); + } - private void applyTriageReportTrigger(String triageTrigger) { - if (null != triageTrigger && !triageTrigger.isEmpty()) { - long delay = TimeUtils.parseSimpleDelay(triageTrigger); - if (delay < 0) { - log.info("Unrecognized triage trigger {}", triageTrigger); - } else { - scheduleTriageReport(delay); - } + public void cleanupAfterFlare() { + doCleanup(null); } - } - - private void scheduleTriageReport(long delayInSeconds) { - Path triagePath = Paths.get(config.getTriageReportDir()); - // prepare at most 10 minutes before collection of the report, to match remote flare behaviour - scheduler.schedule(() -> prepareForFlare("triage"), delayInSeconds - 600, TimeUnit.SECONDS); - scheduler.schedule( - () -> { - try { - if (!Files.isDirectory(triagePath)) { - Files.createDirectories(triagePath); + + synchronized void doCleanup(CleanupTask fromTask) { + Scheduled oldSchedule = scheduledCleanup; + if (null != oldSchedule) { + if (null == fromTask) { + // explicit clean-up request, cancel current schedule + oldSchedule.cancel(); + } else if (oldSchedule.get() != fromTask) { + // ignore clean-up requests from tasks which aren't currently scheduled + return; } - long flareEndMillis = System.currentTimeMillis(); - Path reportPath = triagePath.resolve(getFlareName(flareEndMillis)); - log.info("Writing triage report to {}", reportPath); - Files.write(reportPath, buildFlareZip(flareStartMillis, flareEndMillis, true)); - } catch (Throwable e) { - log.info("Problem writing triage report", e); - } finally { - cleanupAfterFlare(); - } - }, - delayInSeconds, - TimeUnit.SECONDS); - } - - public synchronized void prepareForFlare(String logLevel) { - // allow turning on debug even part way through preparation - - if (!log.isDebugEnabled() && "debug".equalsIgnoreCase(logLevel)) { - GlobalLogLevelSwitcher.get().switchLevel(LogLevel.DEBUG); - logLevelOverridden = true; + flareStartMillis = 0; + scheduledCleanup = null; + log.debug("Cleaning up after tracer flare"); + TracerFlare.cleanupAfterFlare(); + if (logLevelOverridden) { + GlobalLogLevelSwitcher.get().restore(); + logLevelOverridden = false; + } + } } - Scheduled oldSchedule = scheduledCleanup; - if (null != oldSchedule) { - // already preparing, just cancel old schedule in favour of new one - oldSchedule.cancel(); - } else { - flareStartMillis = System.currentTimeMillis(); - log.debug("Preparing for tracer flare, logLevel={}", logLevel); - TracerFlare.prepareForFlare(); + + public void sendFlare(String caseId, String email, String hostname) { + boolean dumpThreads = config.isTriageEnabled() || log.isDebugEnabled(); + scheduler.execute(() -> doSend(caseId, email, hostname, dumpThreads)); } - // always schedule fresh clean-up 20 minutes from last prepare request - scheduledCleanup = scheduler.schedule(new CleanupTask(), 20, TimeUnit.MINUTES); - } - - public void cleanupAfterFlare() { - doCleanup(null); - } - - synchronized void doCleanup(CleanupTask fromTask) { - Scheduled oldSchedule = scheduledCleanup; - if (null != oldSchedule) { - if (null == fromTask) { - // explicit clean-up request, cancel current schedule - oldSchedule.cancel(); - } else if (oldSchedule.get() != fromTask) { - // ignore clean-up requests from tasks which aren't currently scheduled - return; - } - flareStartMillis = 0; - scheduledCleanup = null; - log.debug("Cleaning up after tracer flare"); - TracerFlare.cleanupAfterFlare(); - if (logLevelOverridden) { - GlobalLogLevelSwitcher.get().restore(); - logLevelOverridden = false; - } + + void doSend(String caseId, String email, String hostname, boolean dumpThreads) { + log.debug("Sending tracer flare"); + try { + long flareEndMillis = System.currentTimeMillis(); + + RequestBody report = + RequestBody.create(OCTET_STREAM, buildFlareZip(flareStartMillis, flareEndMillis, dumpThreads)); + + RequestBody form = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("source", "tracer_java") + .addFormDataPart("case_id", caseId) + .addFormDataPart("email", email) + .addFormDataPart("hostname", hostname) + .addFormDataPart("flare_file", getFlareName(flareEndMillis), report) + .build(); + + Request flareRequest = OkHttpUtils.prepareRequest(flareUrl, Collections.emptyMap()) + .post(form) + .build(); + + try (Response response = okHttpClient.newCall(flareRequest).execute()) { + if (response.code() == 404) { + log.debug("Tracer flare endpoint is disabled, ignoring request"); + } else if (!response.isSuccessful()) { + log.warn("Tracer flare failed with: {} {}", response.code(), response.message()); + } else { + log.debug("Tracer flare sent successfully"); + } + } + + } catch (IOException e) { + log.warn("Tracer flare failed with exception: {}", e.toString()); + } } - } - - public void sendFlare(String caseId, String email, String hostname) { - boolean dumpThreads = config.isTriageEnabled() || log.isDebugEnabled(); - scheduler.execute(() -> doSend(caseId, email, hostname, dumpThreads)); - } - - void doSend(String caseId, String email, String hostname, boolean dumpThreads) { - log.debug("Sending tracer flare"); - try { - long flareEndMillis = System.currentTimeMillis(); - - RequestBody report = - RequestBody.create( - OCTET_STREAM, buildFlareZip(flareStartMillis, flareEndMillis, dumpThreads)); - - RequestBody form = - new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("source", "tracer_java") - .addFormDataPart("case_id", caseId) - .addFormDataPart("email", email) - .addFormDataPart("hostname", hostname) - .addFormDataPart("flare_file", getFlareName(flareEndMillis), report) - .build(); - - Request flareRequest = - OkHttpUtils.prepareRequest(flareUrl, Collections.emptyMap()).post(form).build(); - - try (Response response = okHttpClient.newCall(flareRequest).execute()) { - if (response.code() == 404) { - log.debug("Tracer flare endpoint is disabled, ignoring request"); - } else if (!response.isSuccessful()) { - log.warn("Tracer flare failed with: {} {}", response.code(), response.message()); - } else { - log.debug("Tracer flare sent successfully"); + + private String getFlareName(long endMillis) { + return REPORT_PREFIX + config.getRuntimeId() + "-" + endMillis + ".zip"; + } + + private byte[] buildFlareZip(long startMillis, long endMillis, boolean dumpThreads) throws IOException { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(bytes)) { + + addPrelude(zip, startMillis, endMillis); + addConfig(zip); + addRuntime(zip); + TracerFlare.addReportsToFlare(zip); + if (dumpThreads) { + addThreadDump(zip); + } + zip.finish(); + + return bytes.toByteArray(); } - } + } - } catch (IOException e) { - log.warn("Tracer flare failed with exception: {}", e.toString()); + private void addPrelude(ZipOutputStream zip, long startMillis, long endMillis) throws IOException { + TracerFlare.addText(zip, "flare_info.txt", flareInfo(startMillis, endMillis)); + TracerFlare.addText(zip, "tracer_version.txt", VERSION); } - } - - private String getFlareName(long endMillis) { - return REPORT_PREFIX + config.getRuntimeId() + "-" + endMillis + ".zip"; - } - - private byte[] buildFlareZip(long startMillis, long endMillis, boolean dumpThreads) - throws IOException { - try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - ZipOutputStream zip = new ZipOutputStream(bytes)) { - - addPrelude(zip, startMillis, endMillis); - addConfig(zip); - addRuntime(zip); - TracerFlare.addReportsToFlare(zip); - if (dumpThreads) { - addThreadDump(zip); - } - zip.finish(); - - return bytes.toByteArray(); + + private void addConfig(ZipOutputStream zip) throws IOException { + TracerFlare.addText(zip, "initial_config.txt", config.toString()); } - } - - private void addPrelude(ZipOutputStream zip, long startMillis, long endMillis) - throws IOException { - TracerFlare.addText(zip, "flare_info.txt", flareInfo(startMillis, endMillis)); - TracerFlare.addText(zip, "tracer_version.txt", VERSION); - } - - private void addConfig(ZipOutputStream zip) throws IOException { - TracerFlare.addText(zip, "initial_config.txt", config.toString()); - } - - private void addRuntime(ZipOutputStream zip) throws IOException { - try { - RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean(); - TracerFlare.addText(zip, "jvm_args.txt", String.join(" ", runtimeMXBean.getInputArguments())); - TracerFlare.addText(zip, "classpath.txt", runtimeMXBean.getClassPath()); - TracerFlare.addText(zip, "library_path.txt", runtimeMXBean.getLibraryPath()); - if (runtimeMXBean.isBootClassPathSupported()) { - TracerFlare.addText(zip, "boot_classpath.txt", runtimeMXBean.getBootClassPath()); - } - } catch (RuntimeException e) { - TracerFlare.addText(zip, "classpath.txt", System.getProperty("java.class.path")); + + private void addRuntime(ZipOutputStream zip) throws IOException { + try { + RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean(); + TracerFlare.addText(zip, "jvm_args.txt", String.join(" ", runtimeMXBean.getInputArguments())); + TracerFlare.addText(zip, "classpath.txt", runtimeMXBean.getClassPath()); + TracerFlare.addText(zip, "library_path.txt", runtimeMXBean.getLibraryPath()); + if (runtimeMXBean.isBootClassPathSupported()) { + TracerFlare.addText(zip, "boot_classpath.txt", runtimeMXBean.getBootClassPath()); + } + } catch (RuntimeException e) { + TracerFlare.addText(zip, "classpath.txt", System.getProperty("java.class.path")); + } + } + + private String flareInfo(long startMillis, long endMillis) { + StringBuilder buf = new StringBuilder(); + if (startMillis > 0) { + buf.append("Requested: ").append(toDateTime(startMillis)).append('\n'); + } + return buf.append("Completed: ").append(toDateTime(endMillis)).toString(); } - } - private String flareInfo(long startMillis, long endMillis) { - StringBuilder buf = new StringBuilder(); - if (startMillis > 0) { - buf.append("Requested: ").append(toDateTime(startMillis)).append('\n'); + private static ZonedDateTime toDateTime(long millis) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneOffset.UTC); } - return buf.append("Completed: ").append(toDateTime(endMillis)).toString(); - } - - private static ZonedDateTime toDateTime(long millis) { - return ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneOffset.UTC); - } - - private void addThreadDump(ZipOutputStream zip) throws IOException { - StringBuilder buf = new StringBuilder(); - try { - ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); - for (ThreadInfo threadInfo : - threadMXBean.dumpAllThreads( - threadMXBean.isObjectMonitorUsageSupported(), - threadMXBean.isSynchronizerUsageSupported())) { - buf.append(threadInfo); - } - } catch (RuntimeException e) { - buf.append("Problem collecting thread dump: ").append(e); + + private void addThreadDump(ZipOutputStream zip) throws IOException { + StringBuilder buf = new StringBuilder(); + try { + ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); + for (ThreadInfo threadInfo : threadMXBean.dumpAllThreads( + threadMXBean.isObjectMonitorUsageSupported(), threadMXBean.isSynchronizerUsageSupported())) { + buf.append(threadInfo); + } + } catch (RuntimeException e) { + buf.append("Problem collecting thread dump: ").append(e); + } + TracerFlare.addText(zip, "threads.txt", buf.toString()); } - TracerFlare.addText(zip, "threads.txt", buf.toString()); - } - final class CleanupTask implements Runnable { - @Override - public void run() { - doCleanup(this); + final class CleanupTask implements Runnable { + @Override + public void run() { + doCleanup(this); + } } - } } diff --git a/utils/logging-utils/src/main/java/datadog/logging/IOLogger.java b/utils/logging-utils/src/main/java/datadog/logging/IOLogger.java index af21f4a0c57..7c22ee6705d 100644 --- a/utils/logging-utils/src/main/java/datadog/logging/IOLogger.java +++ b/utils/logging-utils/src/main/java/datadog/logging/IOLogger.java @@ -8,131 +8,125 @@ /** Logger specialized on logging IO-related activity */ public class IOLogger { - private boolean logNextSuccess = false; - private final Logger log; - private final RatelimitedLogger ratelimitedLogger; - - public IOLogger(final Logger log) { - this(log, new RatelimitedLogger(log, 5, TimeUnit.MINUTES)); - } - - @VisibleForTesting - IOLogger(final Logger log, final RatelimitedLogger ratelimitedLogger) { - this.log = log; - this.ratelimitedLogger = ratelimitedLogger; - } - - /** - * @return true if actually logged the message, false otherwise - */ - public boolean success(final String format, final Object... arguments) { - if (log.isDebugEnabled()) { - log.debug(EXCLUDE_TELEMETRY, format, arguments); - return true; - } + private boolean logNextSuccess = false; + private final Logger log; + private final RatelimitedLogger ratelimitedLogger; - if (this.logNextSuccess) { - this.logNextSuccess = false; - if (log.isInfoEnabled()) { - log.info(EXCLUDE_TELEMETRY, format, arguments); - return true; - } + public IOLogger(final Logger log) { + this(log, new RatelimitedLogger(log, 5, TimeUnit.MINUTES)); } - return false; - } - - /** - * @return true if actually logged the message, false otherwise - */ - public boolean error(final String message) { - return error(message, null, null); - } - - /** - * @return true if actually logged the message, false otherwise - */ - public boolean error(final String message, Exception exception) { - return error(message, null, exception); - } - - /** - * @return true if actually logged the message, false otherwise - */ - public boolean error(final String message, Response response) { - return error(message, response, null); - } - - /** - * @return true if actually logged the message, false otherwise - */ - public boolean error(final String message, Response response, Exception exception) { - if (log.isDebugEnabled()) { - if (response != null) { - log.debug( - EXCLUDE_TELEMETRY, - "{} Status: {}, Response: {}, Body: {}", - message, - response.getStatusCode(), - response.getMessage(), - response.getBody()); - } else if (exception != null) { - log.debug(EXCLUDE_TELEMETRY, message, exception); - } else { - log.debug(EXCLUDE_TELEMETRY, message); - } - return true; - } - boolean hasLogged; - if (response != null) { - hasLogged = - ratelimitedLogger.warn( - EXCLUDE_TELEMETRY, - "{} Status: {} {}", - message, - response.getStatusCode(), - response.getMessage()); - } else if (exception != null) { - // NOTE: We do not pass the full exception to warn on purpose. We don't want to - // print a full stacktrace unless we're in debug mode - hasLogged = - ratelimitedLogger.warn( - EXCLUDE_TELEMETRY, - "{} {}: {}", - message, - exception.getClass().getName(), - exception.getMessage()); - } else { - hasLogged = ratelimitedLogger.warn(message); - } - if (hasLogged) { - this.logNextSuccess = true; + @VisibleForTesting + IOLogger(final Logger log, final RatelimitedLogger ratelimitedLogger) { + this.log = log; + this.ratelimitedLogger = ratelimitedLogger; } - return hasLogged; - } + /** + * @return true if actually logged the message, false otherwise + */ + public boolean success(final String format, final Object... arguments) { + if (log.isDebugEnabled()) { + log.debug(EXCLUDE_TELEMETRY, format, arguments); + return true; + } + + if (this.logNextSuccess) { + this.logNextSuccess = false; + if (log.isInfoEnabled()) { + log.info(EXCLUDE_TELEMETRY, format, arguments); + return true; + } + } + + return false; + } - public static final class Response { - private final int statusCode; - private final String message; - private final String body; + /** + * @return true if actually logged the message, false otherwise + */ + public boolean error(final String message) { + return error(message, null, null); + } - public Response(int statusCode, String message, String body) { - this.statusCode = statusCode; - this.message = message; - this.body = body; + /** + * @return true if actually logged the message, false otherwise + */ + public boolean error(final String message, Exception exception) { + return error(message, null, exception); } - public int getStatusCode() { - return statusCode; + /** + * @return true if actually logged the message, false otherwise + */ + public boolean error(final String message, Response response) { + return error(message, response, null); } - public String getMessage() { - return message; + /** + * @return true if actually logged the message, false otherwise + */ + public boolean error(final String message, Response response, Exception exception) { + if (log.isDebugEnabled()) { + if (response != null) { + log.debug( + EXCLUDE_TELEMETRY, + "{} Status: {}, Response: {}, Body: {}", + message, + response.getStatusCode(), + response.getMessage(), + response.getBody()); + } else if (exception != null) { + log.debug(EXCLUDE_TELEMETRY, message, exception); + } else { + log.debug(EXCLUDE_TELEMETRY, message); + } + return true; + } + boolean hasLogged; + if (response != null) { + hasLogged = ratelimitedLogger.warn( + EXCLUDE_TELEMETRY, "{} Status: {} {}", message, response.getStatusCode(), response.getMessage()); + } else if (exception != null) { + // NOTE: We do not pass the full exception to warn on purpose. We don't want to + // print a full stacktrace unless we're in debug mode + hasLogged = ratelimitedLogger.warn( + EXCLUDE_TELEMETRY, + "{} {}: {}", + message, + exception.getClass().getName(), + exception.getMessage()); + } else { + hasLogged = ratelimitedLogger.warn(message); + } + if (hasLogged) { + this.logNextSuccess = true; + } + + return hasLogged; } - public String getBody() { - return body; + public static final class Response { + private final int statusCode; + private final String message; + private final String body; + + public Response(int statusCode, String message, String body) { + this.statusCode = statusCode; + this.message = message; + this.body = body; + } + + public int getStatusCode() { + return statusCode; + } + + public String getMessage() { + return message; + } + + public String getBody() { + return body; + } } - } } diff --git a/utils/logging-utils/src/main/java/datadog/logging/RatelimitedLogger.java b/utils/logging-utils/src/main/java/datadog/logging/RatelimitedLogger.java index b88505c1e59..81789b60d93 100644 --- a/utils/logging-utils/src/main/java/datadog/logging/RatelimitedLogger.java +++ b/utils/logging-utils/src/main/java/datadog/logging/RatelimitedLogger.java @@ -15,62 +15,57 @@ */ public class RatelimitedLogger { - private final Logger log; - private final long delayNanos; - private final String noLogMessage; - private final TimeSource timeSource; + private final Logger log; + private final long delayNanos; + private final String noLogMessage; + private final TimeSource timeSource; - private final AtomicLong nextLogNanos; + private final AtomicLong nextLogNanos; - public RatelimitedLogger(final Logger log, final int delay, final TimeUnit timeUnit) { - this(log, delay, timeUnit, SystemTimeSource.INSTANCE); - } - - @VisibleForTesting - RatelimitedLogger( - final Logger log, final int delay, final TimeUnit timeUnit, final TimeSource timeSource) { - this.log = log; - this.delayNanos = timeUnit.toNanos(delay); - this.noLogMessage = createNoLogMessage(" (Will not log warnings for ", ")", delay, timeUnit); - this.timeSource = timeSource; - nextLogNanos = new AtomicLong(timeSource.getNanoTicks()); - } + public RatelimitedLogger(final Logger log, final int delay, final TimeUnit timeUnit) { + this(log, delay, timeUnit, SystemTimeSource.INSTANCE); + } - /** - * @return true if actually logged the message, false otherwise - */ - public boolean warn(final String format, final Object... arguments) { - return warn(null, format, arguments); - } + @VisibleForTesting + RatelimitedLogger(final Logger log, final int delay, final TimeUnit timeUnit, final TimeSource timeSource) { + this.log = log; + this.delayNanos = timeUnit.toNanos(delay); + this.noLogMessage = createNoLogMessage(" (Will not log warnings for ", ")", delay, timeUnit); + this.timeSource = timeSource; + nextLogNanos = new AtomicLong(timeSource.getNanoTicks()); + } - public boolean warn(Marker marker, final String format, final Object... arguments) { - if (log.isDebugEnabled()) { - log.warn(marker, format, arguments); - return true; + /** + * @return true if actually logged the message, false otherwise + */ + public boolean warn(final String format, final Object... arguments) { + return warn(null, format, arguments); } - if (log.isWarnEnabled()) { - final long next = nextLogNanos.get(); - final long now = timeSource.getNanoTicks(); - if (now - next >= 0 && nextLogNanos.compareAndSet(next, now + delayNanos)) { - log.warn(marker, format + noLogMessage, arguments); - return true; - } + + public boolean warn(Marker marker, final String format, final Object... arguments) { + if (log.isDebugEnabled()) { + log.warn(marker, format, arguments); + return true; + } + if (log.isWarnEnabled()) { + final long next = nextLogNanos.get(); + final long now = timeSource.getNanoTicks(); + if (now - next >= 0 && nextLogNanos.compareAndSet(next, now + delayNanos)) { + log.warn(marker, format + noLogMessage, arguments); + return true; + } + } + return false; } - return false; - } - private static String createNoLogMessage( - String prefix, String postfix, int delay, TimeUnit timeUnit) { - StringBuilder noLogStringBuilder = new StringBuilder(prefix); - noLogStringBuilder.append(delay); - noLogStringBuilder.append(' '); - String unit = timeUnit.name().toLowerCase(Locale.ROOT); - unit = - delay == 1 - ? unit.substring(0, unit.length() - 1) - : unit; // should we drop the plural s or not? - noLogStringBuilder.append(unit); - noLogStringBuilder.append(postfix); - return noLogStringBuilder.toString(); - } + private static String createNoLogMessage(String prefix, String postfix, int delay, TimeUnit timeUnit) { + StringBuilder noLogStringBuilder = new StringBuilder(prefix); + noLogStringBuilder.append(delay); + noLogStringBuilder.append(' '); + String unit = timeUnit.name().toLowerCase(Locale.ROOT); + unit = delay == 1 ? unit.substring(0, unit.length() - 1) : unit; // should we drop the plural s or not? + noLogStringBuilder.append(unit); + noLogStringBuilder.append(postfix); + return noLogStringBuilder.toString(); + } } diff --git a/utils/logging-utils/src/test/java/datadog/logging/IOLoggerTest.java b/utils/logging-utils/src/test/java/datadog/logging/IOLoggerTest.java index f8824d713fe..93f9775d199 100644 --- a/utils/logging-utils/src/test/java/datadog/logging/IOLoggerTest.java +++ b/utils/logging-utils/src/test/java/datadog/logging/IOLoggerTest.java @@ -16,135 +16,134 @@ import org.slf4j.Marker; class IOLoggerTest { - private final IOLogger.Response response = - new IOLogger.Response(404, "Not Found", "The thing you were looking for does not exist"); - private final RuntimeException exception = new RuntimeException("Something went wrong!"); + private final IOLogger.Response response = + new IOLogger.Response(404, "Not Found", "The thing you were looking for does not exist"); + private final RuntimeException exception = new RuntimeException("Something went wrong!"); - private Logger log; - private RatelimitedLogger rateLimitedLogger; - private IOLogger ioLogger; + private Logger log; + private RatelimitedLogger rateLimitedLogger; + private IOLogger ioLogger; - @BeforeEach - void setUp() { - this.log = mock(Logger.class); - this.rateLimitedLogger = mock(RatelimitedLogger.class); - this.ioLogger = new IOLogger(this.log, this.rateLimitedLogger); - } + @BeforeEach + void setUp() { + this.log = mock(Logger.class); + this.rateLimitedLogger = mock(RatelimitedLogger.class); + this.ioLogger = new IOLogger(this.log, this.rateLimitedLogger); + } - @Test - void successDebugLevel() { - when(this.log.isDebugEnabled()).thenReturn(true); + @Test + void successDebugLevel() { + when(this.log.isDebugEnabled()).thenReturn(true); - this.ioLogger.success("test {}", "message"); + this.ioLogger.success("test {}", "message"); - verify(this.log).debug(eq(EXCLUDE_TELEMETRY), eq("test {}"), (Object[]) any()); - } + verify(this.log).debug(eq(EXCLUDE_TELEMETRY), eq("test {}"), (Object[]) any()); + } - @Test - void successInfoLevel() { - when(this.log.isDebugEnabled()).thenReturn(false); + @Test + void successInfoLevel() { + when(this.log.isDebugEnabled()).thenReturn(false); - this.ioLogger.success("test {}", "message"); + this.ioLogger.success("test {}", "message"); - verify(this.log, never()).debug(nullable(Marker.class), anyString(), (Object[]) any()); - verify(this.log, never()).info(nullable(Marker.class), anyString(), (Object[]) any()); - } + verify(this.log, never()).debug(nullable(Marker.class), anyString(), (Object[]) any()); + verify(this.log, never()).info(nullable(Marker.class), anyString(), (Object[]) any()); + } - @Test - void errorDebugLevelMessage() { - when(this.log.isDebugEnabled()).thenReturn(true); + @Test + void errorDebugLevelMessage() { + when(this.log.isDebugEnabled()).thenReturn(true); - this.ioLogger.error("test message"); + this.ioLogger.error("test message"); - verify(this.log).debug(EXCLUDE_TELEMETRY, "test message"); - } + verify(this.log).debug(EXCLUDE_TELEMETRY, "test message"); + } - @Test - void errorDebugLevelResponse() { - when(this.log.isDebugEnabled()).thenReturn(true); + @Test + void errorDebugLevelResponse() { + when(this.log.isDebugEnabled()).thenReturn(true); - this.ioLogger.error("test message", this.response); + this.ioLogger.error("test message", this.response); - verify(this.log) - .debug( - eq(EXCLUDE_TELEMETRY), - anyString(), - eq("test message"), - eq(404), - eq("Not Found"), - eq("The thing you were looking for does not exist")); - } + verify(this.log) + .debug( + eq(EXCLUDE_TELEMETRY), + anyString(), + eq("test message"), + eq(404), + eq("Not Found"), + eq("The thing you were looking for does not exist")); + } - @Test - void errorDebugLevelException() { - when(this.log.isDebugEnabled()).thenReturn(true); + @Test + void errorDebugLevelException() { + when(this.log.isDebugEnabled()).thenReturn(true); - this.ioLogger.error("test message", this.exception); + this.ioLogger.error("test message", this.exception); - verify(this.log).debug(EXCLUDE_TELEMETRY, "test message", this.exception); - } + verify(this.log).debug(EXCLUDE_TELEMETRY, "test message", this.exception); + } - @Test - void errorInfoLevelMessage() { - when(this.log.isDebugEnabled()).thenReturn(false); + @Test + void errorInfoLevelMessage() { + when(this.log.isDebugEnabled()).thenReturn(false); - this.ioLogger.error("test message"); + this.ioLogger.error("test message"); - verify(this.rateLimitedLogger).warn("test message"); - } + verify(this.rateLimitedLogger).warn("test message"); + } - @Test - void errorInfoLevelResponse() { - when(this.log.isDebugEnabled()).thenReturn(false); + @Test + void errorInfoLevelResponse() { + when(this.log.isDebugEnabled()).thenReturn(false); - this.ioLogger.error("test message", this.response); + this.ioLogger.error("test message", this.response); - verify(this.rateLimitedLogger) - .warn(eq(EXCLUDE_TELEMETRY), anyString(), eq("test message"), eq(404), eq("Not Found")); - } + verify(this.rateLimitedLogger) + .warn(eq(EXCLUDE_TELEMETRY), anyString(), eq("test message"), eq(404), eq("Not Found")); + } - @Test - void errorInfoLevelException() { - when(this.log.isDebugEnabled()).thenReturn(false); + @Test + void errorInfoLevelException() { + when(this.log.isDebugEnabled()).thenReturn(false); - this.ioLogger.error("test message", this.exception); + this.ioLogger.error("test message", this.exception); - verify(this.rateLimitedLogger) - .warn( - eq(EXCLUDE_TELEMETRY), - anyString(), - eq("test message"), - eq("java.lang.RuntimeException"), - eq("Something went wrong!")); - } + verify(this.rateLimitedLogger) + .warn( + eq(EXCLUDE_TELEMETRY), + anyString(), + eq("test message"), + eq("java.lang.RuntimeException"), + eq("Something went wrong!")); + } - @Test - void loggedErrorThenSuccessInfoLevel() { - when(this.log.isDebugEnabled()).thenReturn(false); - when(this.log.isInfoEnabled()).thenReturn(true); - when(this.rateLimitedLogger.warn("test message")).thenReturn(true); + @Test + void loggedErrorThenSuccessInfoLevel() { + when(this.log.isDebugEnabled()).thenReturn(false); + when(this.log.isInfoEnabled()).thenReturn(true); + when(this.rateLimitedLogger.warn("test message")).thenReturn(true); - this.ioLogger.error("test message"); - this.ioLogger.success("very successful"); - this.ioLogger.success("very successful again"); + this.ioLogger.error("test message"); + this.ioLogger.success("very successful"); + this.ioLogger.success("very successful again"); - verify(this.rateLimitedLogger).warn("test message"); - verify(this.log).info(eq(EXCLUDE_TELEMETRY), eq("very successful"), (Object[]) any()); - verify(this.log, never()) - .info(eq(EXCLUDE_TELEMETRY), eq("very successful again"), (Object[]) any()); - } + verify(this.rateLimitedLogger).warn("test message"); + verify(this.log).info(eq(EXCLUDE_TELEMETRY), eq("very successful"), (Object[]) any()); + verify(this.log, never()).info(eq(EXCLUDE_TELEMETRY), eq("very successful again"), (Object[]) any()); + } - @Test - void unloggedErrorThenSuccessInfoLevel() { - when(this.log.isDebugEnabled()).thenReturn(false); - when(this.log.isInfoEnabled()).thenReturn(true); - when(this.rateLimitedLogger.warn("test message")).thenReturn(false); + @Test + void unloggedErrorThenSuccessInfoLevel() { + when(this.log.isDebugEnabled()).thenReturn(false); + when(this.log.isInfoEnabled()).thenReturn(true); + when(this.rateLimitedLogger.warn("test message")).thenReturn(false); - this.ioLogger.error("test message"); - this.ioLogger.success("very successful"); - this.ioLogger.success("very successful again"); + this.ioLogger.error("test message"); + this.ioLogger.success("very successful"); + this.ioLogger.success("very successful again"); - verify(this.rateLimitedLogger).warn("test message"); - verify(this.log, never()).info(nullable(Marker.class), anyString(), (Object[]) any()); - } + verify(this.rateLimitedLogger).warn("test message"); + verify(this.log, never()).info(nullable(Marker.class), anyString(), (Object[]) any()); + } } diff --git a/utils/logging-utils/src/test/java/datadog/logging/RateLimitedLoggerTest.java b/utils/logging-utils/src/test/java/datadog/logging/RateLimitedLoggerTest.java index 11519ad2d9f..395940ba745 100644 --- a/utils/logging-utils/src/test/java/datadog/logging/RateLimitedLoggerTest.java +++ b/utils/logging-utils/src/test/java/datadog/logging/RateLimitedLoggerTest.java @@ -22,168 +22,147 @@ import org.slf4j.Marker; class RateLimitedLoggerTest { - private static final RuntimeException EXCEPTION = new RuntimeException("bad thing"); - - private Logger log; - private ControllableTimeSource timeSource; - - @BeforeEach - void setUp() { - this.log = mock(Logger.class); - this.timeSource = new ControllableTimeSource(); - } - - @Test - void debugLevel() { - when(this.log.isDebugEnabled()).thenReturn(true); - RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 5, MINUTES, this.timeSource); - - rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - - verify(this.log, times(2)).warn(nullable(Marker.class), eq("test {} {}"), (Object[]) any()); - } - - @Test - void defaultWarningOnce() { - when(this.log.isWarnEnabled()).thenReturn(true); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger defaultRateLimitedLog = new RatelimitedLogger(this.log, 5, MINUTES); - - boolean firstLog = defaultRateLimitedLog.warn("test {} {}", "message", EXCEPTION); - boolean secondLog = defaultRateLimitedLog.warn("test {} {}", "message", EXCEPTION); - - verify(this.log) - .warn( - nullable(Marker.class), - eq("test {} {} (Will not log warnings for 5 minutes)"), - (Object[]) any()); - assertTrue(firstLog); - assertFalse(secondLog); - } - - @Test - void warningOnce() { - when(this.log.isWarnEnabled()).thenReturn(true); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 1, MINUTES, this.timeSource); - - boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertTrue(firstLog); - - boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertFalse(secondLog); - - verify(this.log) - .warn( - nullable(Marker.class), - eq("test {} {} (Will not log warnings for 1 minute)"), - (Object[]) any()); - } - - @Test - void warningDisabled() { - when(this.log.isWarnEnabled()).thenReturn(false); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 1, MINUTES, this.timeSource); - - assertFalse(rateLimitedLog.warn("test {} {}", "message", EXCEPTION)); - verify(this.log, never()).warn(nullable(Marker.class), any(String.class), (Object[]) any()); - } - - @Test - void warningOnceNegativeTime() { - this.timeSource.set(Long.MIN_VALUE); - when(this.log.isWarnEnabled()).thenReturn(true); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger rateLimitedLog = - new RatelimitedLogger(this.log, 5, NANOSECONDS, this.timeSource); - - boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertTrue(firstLog); - - this.timeSource.advance(5 - 1); - boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertFalse(secondLog); - - verify(this.log) - .warn( - nullable(Marker.class), - eq("test {} {} (Will not log warnings for 5 nanoseconds)"), - (Object[]) any()); - } - - @Test - void warningOnceZeroTime() { - this.timeSource.set(0); - when(this.log.isWarnEnabled()).thenReturn(true); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger rateLimitedLog = - new RatelimitedLogger(this.log, 5, NANOSECONDS, this.timeSource); - - boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertTrue(firstLog); - - this.timeSource.advance(1); - boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertFalse(secondLog); - - verify(this.log) - .warn( - nullable(Marker.class), - eq("test {} {} (Will not log warnings for 5 nanoseconds)"), - (Object[]) any()); - } - - @Test - void warningTwice() { - when(this.log.isWarnEnabled()).thenReturn(true); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger rateLimitedLog = - new RatelimitedLogger(this.log, 7, NANOSECONDS, this.timeSource); - - boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertTrue(firstLog); - - this.timeSource.advance(7); - boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); - assertTrue(secondLog); - - verify(this.log, times(2)) - .warn( - nullable(Marker.class), - eq("test {} {} (Will not log warnings for 7 nanoseconds)"), - (Object[]) any()); - } - - @Test - void noArgs() { - when(this.log.isWarnEnabled()).thenReturn(true); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger rateLimitedLog = new RatelimitedLogger(log, 1, MILLISECONDS, this.timeSource); - - rateLimitedLog.warn("test"); - - verify(this.log) - .warn( - nullable(Marker.class), - eq("test (Will not log warnings for 1 millisecond)"), - (Object[]) any()); - } - - @Test - void withMarker() { - when(this.log.isWarnEnabled()).thenReturn(true); - when(this.log.isDebugEnabled()).thenReturn(false); - RatelimitedLogger rateLimitedLog = - new RatelimitedLogger(this.log, 1, MILLISECONDS, this.timeSource); - - rateLimitedLog.warn(LogCollector.SEND_TELEMETRY, "test"); - - verify(this.log) - .warn( - eq(LogCollector.SEND_TELEMETRY), - eq("test (Will not log warnings for 1 millisecond)"), - (Object[]) any()); - } + private static final RuntimeException EXCEPTION = new RuntimeException("bad thing"); + + private Logger log; + private ControllableTimeSource timeSource; + + @BeforeEach + void setUp() { + this.log = mock(Logger.class); + this.timeSource = new ControllableTimeSource(); + } + + @Test + void debugLevel() { + when(this.log.isDebugEnabled()).thenReturn(true); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 5, MINUTES, this.timeSource); + + rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + + verify(this.log, times(2)).warn(nullable(Marker.class), eq("test {} {}"), (Object[]) any()); + } + + @Test + void defaultWarningOnce() { + when(this.log.isWarnEnabled()).thenReturn(true); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger defaultRateLimitedLog = new RatelimitedLogger(this.log, 5, MINUTES); + + boolean firstLog = defaultRateLimitedLog.warn("test {} {}", "message", EXCEPTION); + boolean secondLog = defaultRateLimitedLog.warn("test {} {}", "message", EXCEPTION); + + verify(this.log) + .warn(nullable(Marker.class), eq("test {} {} (Will not log warnings for 5 minutes)"), (Object[]) any()); + assertTrue(firstLog); + assertFalse(secondLog); + } + + @Test + void warningOnce() { + when(this.log.isWarnEnabled()).thenReturn(true); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 1, MINUTES, this.timeSource); + + boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertTrue(firstLog); + + boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertFalse(secondLog); + + verify(this.log) + .warn(nullable(Marker.class), eq("test {} {} (Will not log warnings for 1 minute)"), (Object[]) any()); + } + + @Test + void warningDisabled() { + when(this.log.isWarnEnabled()).thenReturn(false); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 1, MINUTES, this.timeSource); + + assertFalse(rateLimitedLog.warn("test {} {}", "message", EXCEPTION)); + verify(this.log, never()).warn(nullable(Marker.class), any(String.class), (Object[]) any()); + } + + @Test + void warningOnceNegativeTime() { + this.timeSource.set(Long.MIN_VALUE); + when(this.log.isWarnEnabled()).thenReturn(true); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 5, NANOSECONDS, this.timeSource); + + boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertTrue(firstLog); + + this.timeSource.advance(5 - 1); + boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertFalse(secondLog); + + verify(this.log) + .warn(nullable(Marker.class), eq("test {} {} (Will not log warnings for 5 nanoseconds)"), (Object[]) + any()); + } + + @Test + void warningOnceZeroTime() { + this.timeSource.set(0); + when(this.log.isWarnEnabled()).thenReturn(true); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 5, NANOSECONDS, this.timeSource); + + boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertTrue(firstLog); + + this.timeSource.advance(1); + boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertFalse(secondLog); + + verify(this.log) + .warn(nullable(Marker.class), eq("test {} {} (Will not log warnings for 5 nanoseconds)"), (Object[]) + any()); + } + + @Test + void warningTwice() { + when(this.log.isWarnEnabled()).thenReturn(true); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 7, NANOSECONDS, this.timeSource); + + boolean firstLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertTrue(firstLog); + + this.timeSource.advance(7); + boolean secondLog = rateLimitedLog.warn("test {} {}", "message", EXCEPTION); + assertTrue(secondLog); + + verify(this.log, times(2)) + .warn(nullable(Marker.class), eq("test {} {} (Will not log warnings for 7 nanoseconds)"), (Object[]) + any()); + } + + @Test + void noArgs() { + when(this.log.isWarnEnabled()).thenReturn(true); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(log, 1, MILLISECONDS, this.timeSource); + + rateLimitedLog.warn("test"); + + verify(this.log) + .warn(nullable(Marker.class), eq("test (Will not log warnings for 1 millisecond)"), (Object[]) any()); + } + + @Test + void withMarker() { + when(this.log.isWarnEnabled()).thenReturn(true); + when(this.log.isDebugEnabled()).thenReturn(false); + RatelimitedLogger rateLimitedLog = new RatelimitedLogger(this.log, 1, MILLISECONDS, this.timeSource); + + rateLimitedLog.warn(LogCollector.SEND_TELEMETRY, "test"); + + verify(this.log) + .warn(eq(LogCollector.SEND_TELEMETRY), eq("test (Will not log warnings for 1 millisecond)"), (Object[]) + any()); + } } diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerArrayQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerArrayQueue.java index b5a8c43c6fc..810e59167c4 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerArrayQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerArrayQueue.java @@ -1,10 +1,9 @@ package datadog.common.queue; -public final class MpscBlockingConsumerArrayQueue - extends org.jctools.queues.MpscBlockingConsumerArrayQueue - implements MessagePassingBlockingQueue { +public final class MpscBlockingConsumerArrayQueue extends org.jctools.queues.MpscBlockingConsumerArrayQueue + implements MessagePassingBlockingQueue { - public MpscBlockingConsumerArrayQueue(int capacity) { - super(capacity); - } + public MpscBlockingConsumerArrayQueue(int capacity) { + super(capacity); + } } diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerVarHandleArrayQueue.java b/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerVarHandleArrayQueue.java index 9ceb56a2a2f..c65ac788c06 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerVarHandleArrayQueue.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/MpscBlockingConsumerVarHandleArrayQueue.java @@ -1,10 +1,10 @@ package datadog.common.queue; public final class MpscBlockingConsumerVarHandleArrayQueue - extends org.jctools.queues.varhandle.MpscBlockingConsumerVarHandleArrayQueue - implements MessagePassingBlockingQueue { + extends org.jctools.queues.varhandle.MpscBlockingConsumerVarHandleArrayQueue + implements MessagePassingBlockingQueue { - public MpscBlockingConsumerVarHandleArrayQueue(int capacity) { - super(capacity); - } + public MpscBlockingConsumerVarHandleArrayQueue(int capacity) { + super(capacity); + } } diff --git a/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java b/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java index 9c3de5fac8a..cddddbd3f2a 100644 --- a/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java +++ b/utils/queue-utils/src/main/java/datadog/common/queue/Queues.java @@ -25,68 +25,67 @@ */ public final class Queues { - private static final boolean CAN_USE_VARHANDLES = JavaVirtualMachine.isJavaVersionAtLeast(25); + private static final boolean CAN_USE_VARHANDLES = JavaVirtualMachine.isJavaVersionAtLeast(25); - private Queues() {} + private Queues() {} - /** - * Creates a Multiple Producer, Single Consumer (MPSC) array-backed queue. - * - * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power - * of two. - * @return a new {@link MessagePassingQueue} instance suitable for MPSC usage - */ - public static MessagePassingQueue mpscArrayQueue(int requestedCapacity) { - if (CAN_USE_VARHANDLES) { - return new MpscVarHandleArrayQueue<>(requestedCapacity); + /** + * Creates a Multiple Producer, Single Consumer (MPSC) array-backed queue. + * + * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power + * of two. + * @return a new {@link MessagePassingQueue} instance suitable for MPSC usage + */ + public static MessagePassingQueue mpscArrayQueue(int requestedCapacity) { + if (CAN_USE_VARHANDLES) { + return new MpscVarHandleArrayQueue<>(requestedCapacity); + } + return new MpscArrayQueue<>(requestedCapacity); } - return new MpscArrayQueue<>(requestedCapacity); - } - /** - * Creates a Single Producer, Multiple Consumer (SPMC) array-backed queue. - * - *

\ * @param requestedCapacity the requested capacity of the queue. Will be rounded to the - * next power of two. - * - * @return a new {@link MessagePassingQueue} instance suitable for SPMC usage - */ - public static MessagePassingQueue spmcArrayQueue(int requestedCapacity) { - if (CAN_USE_VARHANDLES) { - return new SpmcVarHandleArrayQueue<>(requestedCapacity); - } else { - return new SpmcArrayQueue<>(requestedCapacity); + /** + * Creates a Single Producer, Multiple Consumer (SPMC) array-backed queue. + * + *

\ * @param requestedCapacity the requested capacity of the queue. Will be rounded to the + * next power of two. + * + * @return a new {@link MessagePassingQueue} instance suitable for SPMC usage + */ + public static MessagePassingQueue spmcArrayQueue(int requestedCapacity) { + if (CAN_USE_VARHANDLES) { + return new SpmcVarHandleArrayQueue<>(requestedCapacity); + } else { + return new SpmcArrayQueue<>(requestedCapacity); + } } - } - /** - * Creates a Multiple Producer, Single Consumer (MPSC) array-backed queue that allows blocking - * behavior for the consumer. - * - * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power - * of two. - * @return a new {@link MessagePassingBlockingQueue} instance suitable for MPSC usage with - * blocking consumption - */ - public static MessagePassingBlockingQueue mpscBlockingConsumerArrayQueue( - int requestedCapacity) { - if (CAN_USE_VARHANDLES) { - return new MpscBlockingConsumerVarHandleArrayQueue<>(requestedCapacity); + /** + * Creates a Multiple Producer, Single Consumer (MPSC) array-backed queue that allows blocking + * behavior for the consumer. + * + * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power + * of two. + * @return a new {@link MessagePassingBlockingQueue} instance suitable for MPSC usage with + * blocking consumption + */ + public static MessagePassingBlockingQueue mpscBlockingConsumerArrayQueue(int requestedCapacity) { + if (CAN_USE_VARHANDLES) { + return new MpscBlockingConsumerVarHandleArrayQueue<>(requestedCapacity); + } + return new MpscBlockingConsumerArrayQueue<>(requestedCapacity); } - return new MpscBlockingConsumerArrayQueue<>(requestedCapacity); - } - /** - * Creates a Single Producer, Single Consumer (SPSC) array-backed queue. - * - * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power - * of two. - * @return a new {@link MessagePassingQueue} instance suitable for SPSC usage - */ - public static MessagePassingQueue spscArrayQueue(int requestedCapacity) { - if (CAN_USE_VARHANDLES) { - return new SpscVarHandleArrayQueue<>(requestedCapacity); + /** + * Creates a Single Producer, Single Consumer (SPSC) array-backed queue. + * + * @param requestedCapacity the requested capacity of the queue. Will be rounded to the next power + * of two. + * @return a new {@link MessagePassingQueue} instance suitable for SPSC usage + */ + public static MessagePassingQueue spscArrayQueue(int requestedCapacity) { + if (CAN_USE_VARHANDLES) { + return new SpscVarHandleArrayQueue<>(requestedCapacity); + } + return new SpscArrayQueue<>(requestedCapacity); } - return new SpscArrayQueue<>(requestedCapacity); - } } diff --git a/utils/socket-utils/src/jmh/java/datadog/common/socket/TunnelingJdkSocketBenchmark.java b/utils/socket-utils/src/jmh/java/datadog/common/socket/TunnelingJdkSocketBenchmark.java index c977ed3db8d..1d900a89f96 100644 --- a/utils/socket-utils/src/jmh/java/datadog/common/socket/TunnelingJdkSocketBenchmark.java +++ b/utils/socket-utils/src/jmh/java/datadog/common/socket/TunnelingJdkSocketBenchmark.java @@ -42,143 +42,141 @@ @Measurement(iterations = 5, time = 1) @Fork(2) public class TunnelingJdkSocketBenchmark { - private static final InetSocketAddress ENDPOINT = new InetSocketAddress("localhost", 0); + private static final InetSocketAddress ENDPOINT = new InetSocketAddress("localhost", 0); - @Benchmark - public void connectAndClose(Listener listener, Blackhole blackhole) throws IOException { - try (TunnelingJdkSocket socket = new TunnelingJdkSocket(listener.path)) { - socket.connect(ENDPOINT); - try (SocketChannel peer = listener.server.accept()) { - blackhole.consume(socket.getInputStream()); - blackhole.consume(socket.getOutputStream()); - } + @Benchmark + public void connectAndClose(Listener listener, Blackhole blackhole) throws IOException { + try (TunnelingJdkSocket socket = new TunnelingJdkSocket(listener.path)) { + socket.connect(ENDPOINT); + try (SocketChannel peer = listener.server.accept()) { + blackhole.consume(socket.getInputStream()); + blackhole.consume(socket.getOutputStream()); + } + } } - } - @Benchmark - public byte roundTrip(Connection connection) throws IOException { - connection.output.write(connection.request); - int received = 0; - while (received < connection.response.length) { - int count = - connection.input.read( - connection.response, received, connection.response.length - received); - if (count <= 0) { - throw new IOException("Peer closed or timed out", connection.peerFailure); - } - received += count; + @Benchmark + public byte roundTrip(Connection connection) throws IOException { + connection.output.write(connection.request); + int received = 0; + while (received < connection.response.length) { + int count = connection.input.read(connection.response, received, connection.response.length - received); + if (count <= 0) { + throw new IOException("Peer closed or timed out", connection.peerFailure); + } + received += count; + } + return connection.response[received - 1]; } - return connection.response[received - 1]; - } - @State(Scope.Thread) - public static class Listener { - private Path path; - private ServerSocketChannel server; + @State(Scope.Thread) + public static class Listener { + private Path path; + private ServerSocketChannel server; - @Setup - public void setup() throws IOException { - // Gradle's JMH temp directory can exceed the Unix-domain socket path limit. - path = Files.createTempFile(Paths.get("/tmp"), "uds-jmh-", ".sock"); - Files.delete(path); - server = ServerSocketChannel.open(StandardProtocolFamily.UNIX); - try { - server.bind(UnixDomainSocketAddress.of(path)); - } catch (IOException | RuntimeException e) { - close(); - throw e; - } - } + @Setup + public void setup() throws IOException { + // Gradle's JMH temp directory can exceed the Unix-domain socket path limit. + path = Files.createTempFile(Paths.get("/tmp"), "uds-jmh-", ".sock"); + Files.delete(path); + server = ServerSocketChannel.open(StandardProtocolFamily.UNIX); + try { + server.bind(UnixDomainSocketAddress.of(path)); + } catch (IOException | RuntimeException e) { + close(); + throw e; + } + } - @TearDown - public void close() throws IOException { - try { - server.close(); - } finally { - Files.deleteIfExists(path); - } + @TearDown + public void close() throws IOException { + try { + server.close(); + } finally { + Files.deleteIfExists(path); + } + } } - } - @State(Scope.Thread) - public static class Connection { - @Param({"256", "8192", "65536"}) - public int bytes; + @State(Scope.Thread) + public static class Connection { + @Param({"256", "8192", "65536"}) + public int bytes; - private Socket socket; - private SocketChannel peer; - private InputStream input; - private OutputStream output; - private byte[] request; - private byte[] response; - private Thread responder; - private volatile IOException peerFailure; + private Socket socket; + private SocketChannel peer; + private InputStream input; + private OutputStream output; + private byte[] request; + private byte[] response; + private Thread responder; + private volatile IOException peerFailure; - @Setup - public void setup(Listener listener) throws IOException { - request = new byte[bytes]; - Arrays.fill(request, (byte) 1); - response = new byte[bytes]; - socket = new TunnelingJdkSocket(listener.path); - try { - socket.connect(ENDPOINT); - peer = listener.server.accept(); - socket.setSoTimeout(5000); - input = socket.getInputStream(); - output = socket.getOutputStream(); - } catch (IOException | RuntimeException e) { - socket.close(); - if (peer != null) { - peer.close(); + @Setup + public void setup(Listener listener) throws IOException { + request = new byte[bytes]; + Arrays.fill(request, (byte) 1); + response = new byte[bytes]; + socket = new TunnelingJdkSocket(listener.path); + try { + socket.connect(ENDPOINT); + peer = listener.server.accept(); + socket.setSoTimeout(5000); + input = socket.getInputStream(); + output = socket.getOutputStream(); + } catch (IOException | RuntimeException e) { + socket.close(); + if (peer != null) { + peer.close(); + } + throw e; + } + responder = new Thread(this::echo, "uds-jmh-peer"); + responder.setDaemon(true); + responder.start(); } - throw e; - } - responder = new Thread(this::echo, "uds-jmh-peer"); - responder.setDaemon(true); - responder.start(); - } - private void echo() { - ByteBuffer buffer = ByteBuffer.allocate(bytes); - try { - while (true) { - buffer.clear(); - while (buffer.hasRemaining()) { - if (peer.read(buffer) == -1) { - return; + private void echo() { + ByteBuffer buffer = ByteBuffer.allocate(bytes); + try { + while (true) { + buffer.clear(); + while (buffer.hasRemaining()) { + if (peer.read(buffer) == -1) { + return; + } + } + buffer.flip(); + while (buffer.hasRemaining()) { + peer.write(buffer); + } + } + } catch (IOException e) { + if (peer.isOpen()) { + peerFailure = e; + try { + socket.close(); + } catch (IOException closeFailure) { + e.addSuppressed(closeFailure); + } + } } - } - buffer.flip(); - while (buffer.hasRemaining()) { - peer.write(buffer); - } } - } catch (IOException e) { - if (peer.isOpen()) { - peerFailure = e; - try { - socket.close(); - } catch (IOException closeFailure) { - e.addSuppressed(closeFailure); - } - } - } - } - @TearDown - public void close() throws IOException, InterruptedException { - try { - peer.close(); - } finally { - socket.close(); - } - responder.join(TimeUnit.SECONDS.toMillis(5)); - if (responder.isAlive()) { - throw new IllegalStateException("Peer did not terminate"); - } - if (peerFailure != null) { - throw peerFailure; - } + @TearDown + public void close() throws IOException, InterruptedException { + try { + peer.close(); + } finally { + socket.close(); + } + responder.join(TimeUnit.SECONDS.toMillis(5)); + if (responder.isAlive()) { + throw new IllegalStateException("Peer did not terminate"); + } + if (peerFailure != null) { + throw peerFailure; + } + } } - } } diff --git a/utils/socket-utils/src/main/java/com/kenai/jffi/PatchInit.java b/utils/socket-utils/src/main/java/com/kenai/jffi/PatchInit.java index 049d3ca481c..3e47410f8b4 100644 --- a/utils/socket-utils/src/main/java/com/kenai/jffi/PatchInit.java +++ b/utils/socket-utils/src/main/java/com/kenai/jffi/PatchInit.java @@ -6,27 +6,27 @@ /** Replacement Init class that loads StubLoader from the same (isolating) class-loader. */ final class PatchInit { - private static volatile boolean loaded; + private static volatile boolean loaded; - private PatchInit() {} + private PatchInit() {} - static void load() { - if (loaded) { - return; + static void load() { + if (loaded) { + return; + } + if (JavaVirtualMachine.isJavaVersionAtLeast(25)) { + SystemProperties.set("jffi.unsafe.disabled", "true"); + } + try { + if (StubLoader.isLoaded()) { + loaded = true; + } else { + throw StubLoader.getFailureCause(); + } + } catch (UnsatisfiedLinkError e) { + throw e; + } catch (Throwable e) { + throw (UnsatisfiedLinkError) new UnsatisfiedLinkError(e.getLocalizedMessage()).initCause(e); + } } - if (JavaVirtualMachine.isJavaVersionAtLeast(25)) { - SystemProperties.set("jffi.unsafe.disabled", "true"); - } - try { - if (StubLoader.isLoaded()) { - loaded = true; - } else { - throw StubLoader.getFailureCause(); - } - } catch (UnsatisfiedLinkError e) { - throw e; - } catch (Throwable e) { - throw (UnsatisfiedLinkError) new UnsatisfiedLinkError(e.getLocalizedMessage()).initCause(e); - } - } } diff --git a/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocket.java b/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocket.java index 34b254d13bb..52be2d80f87 100644 --- a/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocket.java +++ b/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocket.java @@ -18,148 +18,148 @@ * the context of a named pipe and therefore implemented as no-ops */ public class NamedPipeSocket extends Socket { - private final InputStream inputStream; - private final OutputStream outputStream; - private final RandomAccessFile randomAccessFile; - private final FileChannel fileChannel; - - public NamedPipeSocket(File pipe) throws FileNotFoundException { - randomAccessFile = new RandomAccessFile(pipe, "rw"); - fileChannel = randomAccessFile.getChannel(); - inputStream = Channels.newInputStream(fileChannel); - outputStream = Channels.newOutputStream(fileChannel); - } - - @Override - public InputStream getInputStream() throws IOException { - if (randomAccessFile.getChannel().isOpen()) { - return inputStream; - } else { - throw new IOException("Pipe closed"); - } - } - - @Override - public OutputStream getOutputStream() throws IOException { - if (randomAccessFile.getChannel().isOpen()) { - return outputStream; - } else { - throw new IOException("Pipe closed"); - } - } - - @Override - public void close() throws IOException { - randomAccessFile.close(); - } - - @Override - public boolean isClosed() { - return !fileChannel.isOpen(); - } - - @Override - public boolean isBound() { - return !isClosed(); - } - - @Override - public boolean isConnected() { - return !isClosed(); - } - - @Override - public boolean isInputShutdown() { - return isClosed(); - } - - @Override - public boolean isOutputShutdown() { - return isClosed(); - } - - @Override - public void shutdownInput() throws IOException { - close(); - } - - @Override - public void shutdownOutput() throws IOException { - close(); - } - - @Override - public void bind(SocketAddress local) { - // do nothing - } - - @Override - public void connect(SocketAddress addr) { - // do nothing - } - - @Override - public void connect(SocketAddress addr, int timeout) { - // do nothing - } - - @Override - public SocketChannel getChannel() { - return null; - } - - @Override - public InetAddress getInetAddress() { - return null; - } - - @Override - public SocketAddress getLocalSocketAddress() { - return null; - } - - @Override - public SocketAddress getRemoteSocketAddress() { - return null; - } - - @Override - public boolean getKeepAlive() { - return true; - } - - @Override - public int getReceiveBufferSize() { - return 0; - } - - @Override - public int getSendBufferSize() { - return 0; - } - - @Override - public int getSoTimeout() { - return 0; - } - - @Override - public void setKeepAlive(boolean on) { - // do nothing - } - - @Override - public void setReceiveBufferSize(int size) { - // do nothing - } - - @Override - public void setSendBufferSize(int size) { - // do nothing - } - - @Override - public void setSoTimeout(int timeout) { - // do nothing - } + private final InputStream inputStream; + private final OutputStream outputStream; + private final RandomAccessFile randomAccessFile; + private final FileChannel fileChannel; + + public NamedPipeSocket(File pipe) throws FileNotFoundException { + randomAccessFile = new RandomAccessFile(pipe, "rw"); + fileChannel = randomAccessFile.getChannel(); + inputStream = Channels.newInputStream(fileChannel); + outputStream = Channels.newOutputStream(fileChannel); + } + + @Override + public InputStream getInputStream() throws IOException { + if (randomAccessFile.getChannel().isOpen()) { + return inputStream; + } else { + throw new IOException("Pipe closed"); + } + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (randomAccessFile.getChannel().isOpen()) { + return outputStream; + } else { + throw new IOException("Pipe closed"); + } + } + + @Override + public void close() throws IOException { + randomAccessFile.close(); + } + + @Override + public boolean isClosed() { + return !fileChannel.isOpen(); + } + + @Override + public boolean isBound() { + return !isClosed(); + } + + @Override + public boolean isConnected() { + return !isClosed(); + } + + @Override + public boolean isInputShutdown() { + return isClosed(); + } + + @Override + public boolean isOutputShutdown() { + return isClosed(); + } + + @Override + public void shutdownInput() throws IOException { + close(); + } + + @Override + public void shutdownOutput() throws IOException { + close(); + } + + @Override + public void bind(SocketAddress local) { + // do nothing + } + + @Override + public void connect(SocketAddress addr) { + // do nothing + } + + @Override + public void connect(SocketAddress addr, int timeout) { + // do nothing + } + + @Override + public SocketChannel getChannel() { + return null; + } + + @Override + public InetAddress getInetAddress() { + return null; + } + + @Override + public SocketAddress getLocalSocketAddress() { + return null; + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return null; + } + + @Override + public boolean getKeepAlive() { + return true; + } + + @Override + public int getReceiveBufferSize() { + return 0; + } + + @Override + public int getSendBufferSize() { + return 0; + } + + @Override + public int getSoTimeout() { + return 0; + } + + @Override + public void setKeepAlive(boolean on) { + // do nothing + } + + @Override + public void setReceiveBufferSize(int size) { + // do nothing + } + + @Override + public void setSendBufferSize(int size) { + // do nothing + } + + @Override + public void setSoTimeout(int timeout) { + // do nothing + } } diff --git a/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocketFactory.java b/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocketFactory.java index 02fe777d130..4b1e5fd1186 100644 --- a/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocketFactory.java +++ b/utils/socket-utils/src/main/java/datadog/common/socket/NamedPipeSocketFactory.java @@ -9,50 +9,48 @@ import javax.net.SocketFactory; public class NamedPipeSocketFactory extends SocketFactory { - private static final String NAMED_PIPE_PREFIX = "\\\\.\\pipe\\"; - private static final Map fileToSocket = new HashMap<>(); - private final File pipe; - - public NamedPipeSocketFactory(String pipeName) { - String pipeNameWithPrefix = - pipeName.startsWith(NAMED_PIPE_PREFIX) ? pipeName : NAMED_PIPE_PREFIX + pipeName; - this.pipe = new File(pipeNameWithPrefix); - } - - @Override - public Socket createSocket() throws IOException { - // Getting a new socket is rare enough that simple synchronization is sufficient - synchronized (fileToSocket) { - NamedPipeSocket socket = fileToSocket.get(pipe); - - if (socket == null || socket.isClosed()) { - socket = new NamedPipeSocket(pipe); - fileToSocket.put(pipe, socket); - } - - return socket; + private static final String NAMED_PIPE_PREFIX = "\\\\.\\pipe\\"; + private static final Map fileToSocket = new HashMap<>(); + private final File pipe; + + public NamedPipeSocketFactory(String pipeName) { + String pipeNameWithPrefix = pipeName.startsWith(NAMED_PIPE_PREFIX) ? pipeName : NAMED_PIPE_PREFIX + pipeName; + this.pipe = new File(pipeNameWithPrefix); + } + + @Override + public Socket createSocket() throws IOException { + // Getting a new socket is rare enough that simple synchronization is sufficient + synchronized (fileToSocket) { + NamedPipeSocket socket = fileToSocket.get(pipe); + + if (socket == null || socket.isClosed()) { + socket = new NamedPipeSocket(pipe); + fileToSocket.put(pipe, socket); + } + + return socket; + } + } + + @Override + public Socket createSocket(String host, int port) throws IOException { + return createSocket(); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { + return createSocket(); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return createSocket(); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) + throws IOException { + return createSocket(); } - } - - @Override - public Socket createSocket(String host, int port) throws IOException { - return createSocket(); - } - - @Override - public Socket createSocket(String host, int port, InetAddress localHost, int localPort) - throws IOException { - return createSocket(); - } - - @Override - public Socket createSocket(InetAddress host, int port) throws IOException { - return createSocket(); - } - - @Override - public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) - throws IOException { - return createSocket(); - } } diff --git a/utils/socket-utils/src/main/java/datadog/common/socket/TunnelingUnixSocket.java b/utils/socket-utils/src/main/java/datadog/common/socket/TunnelingUnixSocket.java index 1c37e93dddd..60a503de70a 100644 --- a/utils/socket-utils/src/main/java/datadog/common/socket/TunnelingUnixSocket.java +++ b/utils/socket-utils/src/main/java/datadog/common/socket/TunnelingUnixSocket.java @@ -18,34 +18,33 @@ * examples. */ final class TunnelingUnixSocket extends UnixSocket { - private final File path; - private InetSocketAddress inetSocketAddress; + private final File path; + private InetSocketAddress inetSocketAddress; - TunnelingUnixSocket(final File path, final UnixSocketChannel channel) { - super(channel); - this.path = path; - } + TunnelingUnixSocket(final File path, final UnixSocketChannel channel) { + super(channel); + this.path = path; + } - TunnelingUnixSocket( - final File path, final UnixSocketChannel channel, final InetSocketAddress address) { - this(path, channel); - inetSocketAddress = address; - } + TunnelingUnixSocket(final File path, final UnixSocketChannel channel, final InetSocketAddress address) { + this(path, channel); + inetSocketAddress = address; + } - @Override - public void connect(final SocketAddress endpoint) throws IOException { - inetSocketAddress = (InetSocketAddress) endpoint; - super.connect(new UnixSocketAddress(path), 0); - } + @Override + public void connect(final SocketAddress endpoint) throws IOException { + inetSocketAddress = (InetSocketAddress) endpoint; + super.connect(new UnixSocketAddress(path), 0); + } - @Override - public void connect(final SocketAddress endpoint, final int timeout) throws IOException { - inetSocketAddress = (InetSocketAddress) endpoint; - super.connect(new UnixSocketAddress(path), timeout); - } + @Override + public void connect(final SocketAddress endpoint, final int timeout) throws IOException { + inetSocketAddress = (InetSocketAddress) endpoint; + super.connect(new UnixSocketAddress(path), timeout); + } - @Override - public InetAddress getInetAddress() { - return inetSocketAddress.getAddress(); - } + @Override + public InetAddress getInetAddress() { + return inetSocketAddress.getAddress(); + } } diff --git a/utils/socket-utils/src/main/java/datadog/common/socket/UnixDomainSocketFactory.java b/utils/socket-utils/src/main/java/datadog/common/socket/UnixDomainSocketFactory.java index 09d03d1325d..5c25807a11d 100644 --- a/utils/socket-utils/src/main/java/datadog/common/socket/UnixDomainSocketFactory.java +++ b/utils/socket-utils/src/main/java/datadog/common/socket/UnixDomainSocketFactory.java @@ -22,73 +22,71 @@ * examples. */ public final class UnixDomainSocketFactory extends SocketFactory { - private static final Logger log = LoggerFactory.getLogger(UnixDomainSocketFactory.class); + private static final Logger log = LoggerFactory.getLogger(UnixDomainSocketFactory.class); - private static final boolean JDK_SUPPORTS_UDS = JavaVirtualMachine.isJavaVersionAtLeast(16); + private static final boolean JDK_SUPPORTS_UDS = JavaVirtualMachine.isJavaVersionAtLeast(16); - private final RatelimitedLogger rlLog = new RatelimitedLogger(log, 5, MINUTES); + private final RatelimitedLogger rlLog = new RatelimitedLogger(log, 5, MINUTES); - private final File path; - private final boolean useJdkUdsSocket; - private final boolean agentConfiguredUsingDefault; + private final File path; + private final boolean useJdkUdsSocket; + private final boolean agentConfiguredUsingDefault; - public UnixDomainSocketFactory( - final File path, boolean isJdkSocketEnabled, boolean agentConfiguredUsingDefault) { - this.path = path; - this.useJdkUdsSocket = isJdkSocketEnabled && JavaVirtualMachine.isJavaVersionAtLeast(16); - this.agentConfiguredUsingDefault = agentConfiguredUsingDefault; - } + public UnixDomainSocketFactory(final File path, boolean isJdkSocketEnabled, boolean agentConfiguredUsingDefault) { + this.path = path; + this.useJdkUdsSocket = isJdkSocketEnabled && JavaVirtualMachine.isJavaVersionAtLeast(16); + this.agentConfiguredUsingDefault = agentConfiguredUsingDefault; + } - @Override - public Socket createSocket() throws IOException { - try { - if (this.useJdkUdsSocket) { + @Override + public Socket createSocket() throws IOException { try { - return new TunnelingJdkSocket(this.path.toPath()); - } catch (IOException | UnsupportedOperationException ignore) { - // fall back to jnr-unixsocket library - } - } - return new TunnelingUnixSocket(this.path, UnixSocketChannel.open()); - } catch (Throwable e) { - if (this.agentConfiguredUsingDefault) { - // fall back to port if we previously auto-discovered this socket file - if (log.isDebugEnabled()) { - rlLog.warn("Problem opening {}, using port instead", path, e); - } else { - rlLog.warn("Problem opening {}, using port instead: " + e, path); + if (this.useJdkUdsSocket) { + try { + return new TunnelingJdkSocket(this.path.toPath()); + } catch (IOException | UnsupportedOperationException ignore) { + // fall back to jnr-unixsocket library + } + } + return new TunnelingUnixSocket(this.path, UnixSocketChannel.open()); + } catch (Throwable e) { + if (this.agentConfiguredUsingDefault) { + // fall back to port if we previously auto-discovered this socket file + if (log.isDebugEnabled()) { + rlLog.warn("Problem opening {}, using port instead", path, e); + } else { + rlLog.warn("Problem opening {}, using port instead: " + e, path); + } + return getDefault().createSocket(); + } + throw e; } - return getDefault().createSocket(); - } - throw e; } - } - @Override - public Socket createSocket(final String host, final int port) throws IOException { - final Socket result = createSocket(); - result.connect(new InetSocketAddress(host, port)); - return result; - } + @Override + public Socket createSocket(final String host, final int port) throws IOException { + final Socket result = createSocket(); + result.connect(new InetSocketAddress(host, port)); + return result; + } - @Override - public Socket createSocket( - final String host, final int port, final InetAddress localHost, final int localPort) - throws IOException { - return createSocket(host, port); - } + @Override + public Socket createSocket(final String host, final int port, final InetAddress localHost, final int localPort) + throws IOException { + return createSocket(host, port); + } - @Override - public Socket createSocket(final InetAddress host, final int port) throws IOException { - final Socket result = createSocket(); - result.connect(new InetSocketAddress(host, port)); - return result; - } + @Override + public Socket createSocket(final InetAddress host, final int port) throws IOException { + final Socket result = createSocket(); + result.connect(new InetSocketAddress(host, port)); + return result; + } - @Override - public Socket createSocket( - final InetAddress host, final int port, final InetAddress localAddress, final int localPort) - throws IOException { - return createSocket(host, port); - } + @Override + public Socket createSocket( + final InetAddress host, final int port, final InetAddress localAddress, final int localPort) + throws IOException { + return createSocket(host, port); + } } diff --git a/utils/socket-utils/src/main/java17/datadog/common/socket/TunnelingJdkSocket.java b/utils/socket-utils/src/main/java17/datadog/common/socket/TunnelingJdkSocket.java index a0487a4e944..77723161cef 100644 --- a/utils/socket-utils/src/main/java17/datadog/common/socket/TunnelingJdkSocket.java +++ b/utils/socket-utils/src/main/java17/datadog/common/socket/TunnelingJdkSocket.java @@ -30,372 +30,374 @@ * 16. */ final class TunnelingJdkSocket extends Socket { - private final UnixDomainSocketAddress unixSocketAddress; - private final SocketChannel unixSocketChannel; - - private volatile InetSocketAddress inetSocketAddress; - @VisibleForTesting Selector selector; - - private volatile int timeout; - private volatile boolean shutIn; - private volatile boolean shutOut; - private volatile boolean closed; - - static final int DEFAULT_BUFFER_SIZE = 8192; - // Indicate that the buffer size is not set by initializing to -1 - private int sendBufferSize = -1; - private int receiveBufferSize = -1; - - TunnelingJdkSocket(final Path path) throws IOException, UnsupportedOperationException { - this.unixSocketAddress = UnixDomainSocketAddress.of(path); - this.unixSocketChannel = SocketChannel.open(StandardProtocolFamily.UNIX); - } - - @Override - public boolean isConnected() { - return inetSocketAddress != null; - } - - @Override - public boolean isInputShutdown() { - return shutIn; - } - - @Override - public boolean isOutputShutdown() { - return shutOut; - } - - @Override - public boolean isClosed() { - return closed; - } - - @Override - public void setSoTimeout(int timeout) throws SocketException { - if (isClosed()) { - throw new SocketException("Socket is closed"); - } - if (timeout < 0) { - throw new IllegalArgumentException("Socket timeout can't be negative"); - } - this.timeout = timeout; - } + private final UnixDomainSocketAddress unixSocketAddress; + private final SocketChannel unixSocketChannel; - @Override - public int getSoTimeout() throws SocketException { - if (isClosed()) { - throw new SocketException("Socket is closed"); - } - return timeout; - } - - @Override - public void connect(final SocketAddress endpoint) throws IOException { - connect(endpoint, 0); - } - - // `timeout` is intentionally ignored here, like in the jnr-unixsocket implementation. - // See: - // https://github.com/jnr/jnr-unixsocket/blob/master/src/main/java/jnr/unixsocket/UnixSocket.java#L89-L97 - @Override - public void connect(final SocketAddress endpoint, final int timeout) throws IOException { - if (endpoint == null) { - throw new IllegalArgumentException("Endpoint cannot be null"); - } - if (timeout < 0) { - throw new IllegalArgumentException("Timeout cannot be negative"); - } - if (isClosed()) { - throw new SocketException("Socket is closed"); - } - if (isConnected()) { - throw new SocketException("Socket is already connected"); - } - InetSocketAddress inetSocketAddress = (InetSocketAddress) endpoint; - try { - unixSocketChannel.connect(unixSocketAddress); - this.inetSocketAddress = inetSocketAddress; - } catch (IOException e) { - close(); - throw e; - } - } + private volatile InetSocketAddress inetSocketAddress; - @Override - public SocketChannel getChannel() { - return unixSocketChannel; - } + @VisibleForTesting + Selector selector; - @Override - public void setSendBufferSize(int size) throws SocketException { - if (size <= 0) { - throw new IllegalArgumentException("Invalid send buffer size"); - } - if (isClosed()) { - throw new SocketException("Socket is closed"); + private volatile int timeout; + private volatile boolean shutIn; + private volatile boolean shutOut; + private volatile boolean closed; + + static final int DEFAULT_BUFFER_SIZE = 8192; + // Indicate that the buffer size is not set by initializing to -1 + private int sendBufferSize = -1; + private int receiveBufferSize = -1; + + TunnelingJdkSocket(final Path path) throws IOException, UnsupportedOperationException { + this.unixSocketAddress = UnixDomainSocketAddress.of(path); + this.unixSocketChannel = SocketChannel.open(StandardProtocolFamily.UNIX); } - sendBufferSize = size; - try { - unixSocketChannel.setOption(StandardSocketOptions.SO_SNDBUF, size); - } catch (IOException e) { - SocketException se = new SocketException("Failed to set send buffer size socket option"); - se.initCause(e); - throw se; + + @Override + public boolean isConnected() { + return inetSocketAddress != null; } - } - @Override - public int getSendBufferSize() throws SocketException { - if (isClosed()) { - throw new SocketException("Socket is closed"); + @Override + public boolean isInputShutdown() { + return shutIn; } - if (sendBufferSize == -1) { - return DEFAULT_BUFFER_SIZE; + + @Override + public boolean isOutputShutdown() { + return shutOut; } - return sendBufferSize; - } - @Override - public void setReceiveBufferSize(int size) throws SocketException { - if (size <= 0) { - throw new IllegalArgumentException("Invalid receive buffer size"); + @Override + public boolean isClosed() { + return closed; } - if (isClosed()) { - throw new SocketException("Socket is closed"); + + @Override + public void setSoTimeout(int timeout) throws SocketException { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (timeout < 0) { + throw new IllegalArgumentException("Socket timeout can't be negative"); + } + this.timeout = timeout; } - receiveBufferSize = size; - try { - unixSocketChannel.setOption(StandardSocketOptions.SO_RCVBUF, size); - } catch (IOException e) { - SocketException se = new SocketException("Failed to set receive buffer size socket option"); - se.initCause(e); - throw se; + + @Override + public int getSoTimeout() throws SocketException { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + return timeout; } - } - @Override - public int getReceiveBufferSize() throws SocketException { - if (isClosed()) { - throw new SocketException("Socket is closed"); + @Override + public void connect(final SocketAddress endpoint) throws IOException { + connect(endpoint, 0); } - if (receiveBufferSize == -1) { - return DEFAULT_BUFFER_SIZE; + + // `timeout` is intentionally ignored here, like in the jnr-unixsocket implementation. + // See: + // https://github.com/jnr/jnr-unixsocket/blob/master/src/main/java/jnr/unixsocket/UnixSocket.java#L89-L97 + @Override + public void connect(final SocketAddress endpoint, final int timeout) throws IOException { + if (endpoint == null) { + throw new IllegalArgumentException("Endpoint cannot be null"); + } + if (timeout < 0) { + throw new IllegalArgumentException("Timeout cannot be negative"); + } + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (isConnected()) { + throw new SocketException("Socket is already connected"); + } + InetSocketAddress inetSocketAddress = (InetSocketAddress) endpoint; + try { + unixSocketChannel.connect(unixSocketAddress); + this.inetSocketAddress = inetSocketAddress; + } catch (IOException e) { + close(); + throw e; + } } - return receiveBufferSize; - } - public int getStreamBufferSize() throws SocketException { - if (isClosed()) { - throw new SocketException("Socket is closed"); + @Override + public SocketChannel getChannel() { + return unixSocketChannel; } - if (sendBufferSize == -1 && receiveBufferSize == -1) { - return DEFAULT_BUFFER_SIZE; + + @Override + public void setSendBufferSize(int size) throws SocketException { + if (size <= 0) { + throw new IllegalArgumentException("Invalid send buffer size"); + } + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + sendBufferSize = size; + try { + unixSocketChannel.setOption(StandardSocketOptions.SO_SNDBUF, size); + } catch (IOException e) { + SocketException se = new SocketException("Failed to set send buffer size socket option"); + se.initCause(e); + throw se; + } } - return Math.max(sendBufferSize, receiveBufferSize); - } - - @Override - public InputStream getInputStream() throws IOException { - // Configuring the channel can wait for a blocked write. Let close() interrupt that write. - if (!isClosed() && isConnected() && !isInputShutdown() && unixSocketChannel.isBlocking()) { - unixSocketChannel.configureBlocking(false); + + @Override + public int getSendBufferSize() throws SocketException { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (sendBufferSize == -1) { + return DEFAULT_BUFFER_SIZE; + } + return sendBufferSize; } - // Serialize validation and selector publication with close() so close cannot miss a selector - // that is still being initialized. - synchronized (this) { - if (isClosed()) { - throw new SocketException("Socket is closed"); - } - if (!isConnected()) { - throw new SocketException("Socket is not connected"); - } - if (isInputShutdown()) { - throw new SocketException("Socket input is shutdown"); - } - - if (selector == null) { - Selector newSelector = Selector.open(); + + @Override + public void setReceiveBufferSize(int size) throws SocketException { + if (size <= 0) { + throw new IllegalArgumentException("Invalid receive buffer size"); + } + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + receiveBufferSize = size; try { - unixSocketChannel.register(newSelector, SelectionKey.OP_READ); - selector = newSelector; - } catch (IOException | RuntimeException e) { - try { - newSelector.close(); - } catch (IOException closeException) { - e.addSuppressed(closeException); - } - throw e; + unixSocketChannel.setOption(StandardSocketOptions.SO_RCVBUF, size); + } catch (IOException e) { + SocketException se = new SocketException("Failed to set receive buffer size socket option"); + se.initCause(e); + throw se; } - } + } - final Selector readSelector = selector; - return new InputStream() { - private final ByteBuffer buffer = ByteBuffer.allocate(getStreamBufferSize()); + @Override + public int getReceiveBufferSize() throws SocketException { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (receiveBufferSize == -1) { + return DEFAULT_BUFFER_SIZE; + } + return receiveBufferSize; + } - @Override - public int read() throws IOException { - byte[] nextByte = new byte[1]; - return (read(nextByte, 0, 1) == -1) ? -1 : (nextByte[0] & 0xFF); + public int getStreamBufferSize() throws SocketException { + if (isClosed()) { + throw new SocketException("Socket is closed"); } + if (sendBufferSize == -1 && receiveBufferSize == -1) { + return DEFAULT_BUFFER_SIZE; + } + return Math.max(sendBufferSize, receiveBufferSize); + } - @Override - public int read(byte[] b, int off, int len) throws IOException { - if (isInputShutdown()) { - return -1; - } - buffer.clear(); - - try { - int readyChannels = readSelector.select(timeout); - if (readyChannels == 0) { - if (isClosed() || !readSelector.isOpen()) { + @Override + public InputStream getInputStream() throws IOException { + // Configuring the channel can wait for a blocked write. Let close() interrupt that write. + if (!isClosed() && isConnected() && !isInputShutdown() && unixSocketChannel.isBlocking()) { + unixSocketChannel.configureBlocking(false); + } + // Serialize validation and selector publication with close() so close cannot miss a selector + // that is still being initialized. + synchronized (this) { + if (isClosed()) { throw new SocketException("Socket is closed"); - } - return 0; + } + if (!isConnected()) { + throw new SocketException("Socket is not connected"); + } + if (isInputShutdown()) { + throw new SocketException("Socket input is shutdown"); } - Set selectedKeys = readSelector.selectedKeys(); - // Multiple input streams share this selector, so serialize iteration and removal from - // its non-thread-safe selected-key set. - synchronized (selectedKeys) { - Iterator keyIterator = selectedKeys.iterator(); - while (keyIterator.hasNext()) { - SelectionKey key = keyIterator.next(); - keyIterator.remove(); - if (key.isReadable()) { - int r = unixSocketChannel.read(buffer); - if (r == -1) { - return -1; - } - buffer.flip(); - len = Math.min(r, len); - buffer.get(b, off, len); - return len; + if (selector == null) { + Selector newSelector = Selector.open(); + try { + unixSocketChannel.register(newSelector, SelectionKey.OP_READ); + selector = newSelector; + } catch (IOException | RuntimeException e) { + try { + newSelector.close(); + } catch (IOException closeException) { + e.addSuppressed(closeException); + } + throw e; } - } } - return 0; - } catch (ClosedSelectorException | CancelledKeyException e) { - SocketException socketException = new SocketException("Socket is closed"); - socketException.initCause(e); - throw socketException; - } - } - @Override - public void close() throws IOException { - TunnelingJdkSocket.this.close(); - } - }; - } - } + final Selector readSelector = selector; + return new InputStream() { + private final ByteBuffer buffer = ByteBuffer.allocate(getStreamBufferSize()); - @Override - public OutputStream getOutputStream() throws IOException { - if (isClosed()) { - throw new SocketException("Socket is closed"); - } - if (!isConnected()) { - throw new SocketException("Socket is not connected"); - } - if (isOutputShutdown()) { - throw new SocketException("Socket output is shutdown"); - } + @Override + public int read() throws IOException { + byte[] nextByte = new byte[1]; + return (read(nextByte, 0, 1) == -1) ? -1 : (nextByte[0] & 0xFF); + } - return new OutputStream() { - @Override - public void write(int b) throws IOException { - byte[] array = ByteBuffer.allocate(4).putInt(b).array(); - write(array, 0, 4); - } + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (isInputShutdown()) { + return -1; + } + buffer.clear(); + + try { + int readyChannels = readSelector.select(timeout); + if (readyChannels == 0) { + if (isClosed() || !readSelector.isOpen()) { + throw new SocketException("Socket is closed"); + } + return 0; + } + + Set selectedKeys = readSelector.selectedKeys(); + // Multiple input streams share this selector, so serialize iteration and removal from + // its non-thread-safe selected-key set. + synchronized (selectedKeys) { + Iterator keyIterator = selectedKeys.iterator(); + while (keyIterator.hasNext()) { + SelectionKey key = keyIterator.next(); + keyIterator.remove(); + if (key.isReadable()) { + int r = unixSocketChannel.read(buffer); + if (r == -1) { + return -1; + } + buffer.flip(); + len = Math.min(r, len); + buffer.get(b, off, len); + return len; + } + } + } + return 0; + } catch (ClosedSelectorException | CancelledKeyException e) { + SocketException socketException = new SocketException("Socket is closed"); + socketException.initCause(e); + throw socketException; + } + } - @Override - public void write(byte[] b, int off, int len) throws IOException { - if (isOutputShutdown()) { - throw new IOException("Stream closed"); - } - ByteBuffer buffer = ByteBuffer.wrap(b, off, len); - while (buffer.hasRemaining()) { - unixSocketChannel.write(buffer); + @Override + public void close() throws IOException { + TunnelingJdkSocket.this.close(); + } + }; } - } - - @Override - public void close() throws IOException { - TunnelingJdkSocket.this.close(); - } - }; - } - - @Override - public void shutdownInput() throws IOException { - // Keep validation, channel shutdown, and state publication atomic with close(). - synchronized (this) { - if (isClosed()) { - throw new SocketException("Socket is closed"); - } - if (!isConnected()) { - throw new SocketException("Socket is not connected"); - } - if (isInputShutdown()) { - throw new SocketException("Socket input is already shutdown"); - } - unixSocketChannel.shutdownInput(); - shutIn = true; } - } - - @Override - public void shutdownOutput() throws IOException { - // Keep validation, channel shutdown, and state publication atomic with close(). - synchronized (this) { - if (isClosed()) { - throw new SocketException("Socket is closed"); - } - if (!isConnected()) { - throw new SocketException("Socket is not connected"); - } - if (isOutputShutdown()) { - throw new SocketException("Socket output is already shutdown"); - } - unixSocketChannel.shutdownOutput(); - shutOut = true; + + @Override + public OutputStream getOutputStream() throws IOException { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (!isConnected()) { + throw new SocketException("Socket is not connected"); + } + if (isOutputShutdown()) { + throw new SocketException("Socket output is shutdown"); + } + + return new OutputStream() { + @Override + public void write(int b) throws IOException { + byte[] array = ByteBuffer.allocate(4).putInt(b).array(); + write(array, 0, 4); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (isOutputShutdown()) { + throw new IOException("Stream closed"); + } + ByteBuffer buffer = ByteBuffer.wrap(b, off, len); + while (buffer.hasRemaining()) { + unixSocketChannel.write(buffer); + } + } + + @Override + public void close() throws IOException { + TunnelingJdkSocket.this.close(); + } + }; } - } - @Override - public InetAddress getInetAddress() { - if (!isConnected()) { - return null; + @Override + public void shutdownInput() throws IOException { + // Keep validation, channel shutdown, and state publication atomic with close(). + synchronized (this) { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (!isConnected()) { + throw new SocketException("Socket is not connected"); + } + if (isInputShutdown()) { + throw new SocketException("Socket input is already shutdown"); + } + unixSocketChannel.shutdownInput(); + shutIn = true; + } } - return inetSocketAddress.getAddress(); - } - - @Override - public void close() { - Selector currentSelector; - // Publish the terminal state and snapshot the selector atomically with selector creation and - // half-close operations. The resources are closed after releasing this monitor. - synchronized (this) { - if (isClosed()) { - return; - } - shutIn = true; - shutOut = true; - closed = true; - currentSelector = selector; + + @Override + public void shutdownOutput() throws IOException { + // Keep validation, channel shutdown, and state publication atomic with close(). + synchronized (this) { + if (isClosed()) { + throw new SocketException("Socket is closed"); + } + if (!isConnected()) { + throw new SocketException("Socket is not connected"); + } + if (isOutputShutdown()) { + throw new SocketException("Socket output is already shutdown"); + } + unixSocketChannel.shutdownOutput(); + shutOut = true; + } } - // Ignore possible exceptions so that we continue closing the socket - try { - if (currentSelector != null) { - currentSelector.close(); - } - } catch (IOException ignored) { + + @Override + public InetAddress getInetAddress() { + if (!isConnected()) { + return null; + } + return inetSocketAddress.getAddress(); } - try { - unixSocketChannel.close(); - } catch (IOException ignored) { + + @Override + public void close() { + Selector currentSelector; + // Publish the terminal state and snapshot the selector atomically with selector creation and + // half-close operations. The resources are closed after releasing this monitor. + synchronized (this) { + if (isClosed()) { + return; + } + shutIn = true; + shutOut = true; + closed = true; + currentSelector = selector; + } + // Ignore possible exceptions so that we continue closing the socket + try { + if (currentSelector != null) { + currentSelector.close(); + } + } catch (IOException ignored) { + } + try { + unixSocketChannel.close(); + } catch (IOException ignored) { + } } - } } diff --git a/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java b/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java index ef6741e35bb..9628ca47121 100644 --- a/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java +++ b/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java @@ -48,671 +48,656 @@ @EnabledForJreRange(min = JAVA_16) public class TunnelingJdkSocketTest { - private TestUnixSocketServer server; - - @BeforeAll - static void assumeUnixDomainSocketsAreSupported() { - Assumptions.assumeTrue(udsSupported()); - } - - @AfterEach - void closeServer() throws Exception { - if (server != null) { - server.close(); - server = null; + private TestUnixSocketServer server; + + @BeforeAll + static void assumeUnixDomainSocketsAreSupported() { + Assumptions.assumeTrue(udsSupported()); + } + + @AfterEach + void closeServer() throws Exception { + if (server != null) { + server.close(); + server = null; + } } - } - - @Test - public void testSocketConnectAndClose() throws Exception { - Path socketPath = getSocketPath(); - UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(socketPath); - startServer(socketAddress); - TunnelingJdkSocket clientSocket = new TunnelingJdkSocket(socketPath); - - assertNotNull(clientSocket.getChannel()); - assertTrue(clientSocket.getChannel().isOpen()); - assertFalse(clientSocket.isConnected()); - assertFalse(clientSocket.isClosed()); - - clientSocket.connect(new InetSocketAddress("localhost", 0)); - InputStream inputStream = clientSocket.getInputStream(); - OutputStream outputStream = clientSocket.getOutputStream(); - - assertTrue(clientSocket.isConnected()); - assertFalse(clientSocket.isClosed()); - assertFalse(clientSocket.isInputShutdown()); - assertFalse(clientSocket.isOutputShutdown()); - assertThrows( - SocketException.class, () -> clientSocket.connect(new InetSocketAddress("localhost", 0))); - - clientSocket.close(); - - assertTrue(clientSocket.isConnected()); - assertTrue(clientSocket.isClosed()); - assertFalse(clientSocket.getChannel().isOpen()); - assertTrue(clientSocket.isInputShutdown()); - assertTrue(clientSocket.isOutputShutdown()); - assertEquals(-1, inputStream.read()); - assertThrows(IOException.class, () -> outputStream.write(1)); - assertThrows(SocketException.class, clientSocket::getInputStream); - assertThrows(SocketException.class, clientSocket::getOutputStream); - clientSocket.close(); - } - - @Test - public void testConnectFailureClosesSocket() throws Exception { - Path missingSocketPath = getSocketPath(); - TunnelingJdkSocket clientSocket = new TunnelingJdkSocket(missingSocketPath); - - assertThrows( - IOException.class, () -> clientSocket.connect(new InetSocketAddress("localhost", 0))); - - assertFalse(clientSocket.isConnected()); - assertTrue(clientSocket.isClosed()); - assertTrue(clientSocket.isInputShutdown()); - assertTrue(clientSocket.isOutputShutdown()); - assertFalse(clientSocket.getChannel().isOpen()); - assertThrows( - SocketException.class, () -> clientSocket.connect(new InetSocketAddress("localhost", 0))); - } - - @Test - public void testInputStreamClose() throws Exception { - TunnelingJdkSocket clientSocket = createClient(); - InputStream inputStream = clientSocket.getInputStream(); - OutputStream outputStream = clientSocket.getOutputStream(); - - assertFalse(clientSocket.isClosed()); - assertFalse(clientSocket.isInputShutdown()); - assertFalse(clientSocket.isOutputShutdown()); - - inputStream.close(); - - assertTrue(clientSocket.isClosed()); - assertTrue(clientSocket.isInputShutdown()); - assertTrue(clientSocket.isOutputShutdown()); - assertEquals(-1, inputStream.read()); - assertThrows(IOException.class, () -> outputStream.write(1)); - assertThrows(SocketException.class, clientSocket::getInputStream); - assertThrows(SocketException.class, clientSocket::getOutputStream); - } - - @Test - public void testOutputStreamClose() throws Exception { - TunnelingJdkSocket clientSocket = createClient(); - InputStream inputStream = clientSocket.getInputStream(); - OutputStream outputStream = clientSocket.getOutputStream(); - - assertFalse(clientSocket.isClosed()); - assertFalse(clientSocket.isInputShutdown()); - assertFalse(clientSocket.isOutputShutdown()); - - outputStream.close(); - - assertTrue(clientSocket.isClosed()); - assertTrue(clientSocket.isInputShutdown()); - assertTrue(clientSocket.isOutputShutdown()); - assertEquals(-1, inputStream.read()); - assertThrows(IOException.class, () -> outputStream.write(1)); - assertThrows(SocketException.class, clientSocket::getInputStream); - assertThrows(SocketException.class, clientSocket::getOutputStream); - } - - @Test - public void testTimeout() throws Exception { - TunnelingJdkSocket clientSocket = createClient(); - InputStream inputStream = clientSocket.getInputStream(); - - int testTimeout = 1000; - clientSocket.setSoTimeout(testTimeout); - assertEquals(testTimeout, clientSocket.getSoTimeout()); - - long startTime = System.currentTimeMillis(); - int readResult = inputStream.read(); - long endTime = System.currentTimeMillis(); - long readDuration = endTime - startTime; - int timeVariance = 100; - assertTrue(readDuration >= testTimeout && readDuration <= testTimeout + timeVariance); - assertEquals(0, readResult); - - int newTimeout = testTimeout / 2; - clientSocket.setSoTimeout(newTimeout); - assertEquals(newTimeout, clientSocket.getSoTimeout()); - assertTimeoutPreemptively(Duration.ofMillis(testTimeout), () -> inputStream.read()); - - // The socket should block indefinitely when timeout is set to 0, per - // https://docs.oracle.com/en/java/javase/16/docs/api//java.base/java/net/Socket.html#setSoTimeout(int). - int infiniteTimeout = 0; - clientSocket.setSoTimeout(infiniteTimeout); - assertEquals(infiniteTimeout, clientSocket.getSoTimeout()); - try { - assertTimeoutPreemptively(Duration.ofMillis(testTimeout), () -> inputStream.read()); - fail("Read should block indefinitely with infinite timeout"); - } catch (AssertionError e) { - // Expected + + @Test + public void testSocketConnectAndClose() throws Exception { + Path socketPath = getSocketPath(); + UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(socketPath); + startServer(socketAddress); + TunnelingJdkSocket clientSocket = new TunnelingJdkSocket(socketPath); + + assertNotNull(clientSocket.getChannel()); + assertTrue(clientSocket.getChannel().isOpen()); + assertFalse(clientSocket.isConnected()); + assertFalse(clientSocket.isClosed()); + + clientSocket.connect(new InetSocketAddress("localhost", 0)); + InputStream inputStream = clientSocket.getInputStream(); + OutputStream outputStream = clientSocket.getOutputStream(); + + assertTrue(clientSocket.isConnected()); + assertFalse(clientSocket.isClosed()); + assertFalse(clientSocket.isInputShutdown()); + assertFalse(clientSocket.isOutputShutdown()); + assertThrows(SocketException.class, () -> clientSocket.connect(new InetSocketAddress("localhost", 0))); + + clientSocket.close(); + + assertTrue(clientSocket.isConnected()); + assertTrue(clientSocket.isClosed()); + assertFalse(clientSocket.getChannel().isOpen()); + assertTrue(clientSocket.isInputShutdown()); + assertTrue(clientSocket.isOutputShutdown()); + assertEquals(-1, inputStream.read()); + assertThrows(IOException.class, () -> outputStream.write(1)); + assertThrows(SocketException.class, clientSocket::getInputStream); + assertThrows(SocketException.class, clientSocket::getOutputStream); + clientSocket.close(); } - int invalidTimeout = -1; - assertThrows(IllegalArgumentException.class, () -> clientSocket.setSoTimeout(invalidTimeout)); - - clientSocket.close(); - assertThrows(SocketException.class, () -> clientSocket.setSoTimeout(testTimeout)); - assertThrows(SocketException.class, clientSocket::getSoTimeout); - } - - @Test - public void testBufferSizes() throws Exception { - TunnelingJdkSocket clientSocket = createClient(); - - assertEquals(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE, clientSocket.getSendBufferSize()); - assertEquals(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE, clientSocket.getReceiveBufferSize()); - assertEquals(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE, clientSocket.getStreamBufferSize()); - - int newBufferSize = TunnelingJdkSocket.DEFAULT_BUFFER_SIZE / 2; - clientSocket.setSendBufferSize(newBufferSize); - clientSocket.setReceiveBufferSize(newBufferSize / 2); - assertEquals(newBufferSize, clientSocket.getSendBufferSize()); - assertEquals(newBufferSize / 2, clientSocket.getReceiveBufferSize()); - assertEquals(newBufferSize, clientSocket.getStreamBufferSize()); - - int invalidBufferSize = -1; - assertThrows( - IllegalArgumentException.class, () -> clientSocket.setSendBufferSize(invalidBufferSize)); - assertThrows( - IllegalArgumentException.class, () -> clientSocket.setReceiveBufferSize(invalidBufferSize)); - - clientSocket.close(); - assertThrows( - SocketException.class, - () -> clientSocket.setSendBufferSize(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE)); - assertThrows( - SocketException.class, - () -> clientSocket.setReceiveBufferSize(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE)); - assertThrows(SocketException.class, clientSocket::getSendBufferSize); - assertThrows(SocketException.class, clientSocket::getReceiveBufferSize); - assertThrows(SocketException.class, clientSocket::getStreamBufferSize); - } - - @Test - public void testFileDescriptorLeak() throws Exception { - long initialCount = getFileDescriptorCount(); - - TunnelingJdkSocket clientSocket = createClient(); - - for (int i = 0; i < 100; i++) { - @SuppressWarnings("unused") - InputStream inputStream = clientSocket.getInputStream(); - long currentCount = getFileDescriptorCount(); - assertTrue(currentCount <= initialCount + 7); + @Test + public void testConnectFailureClosesSocket() throws Exception { + Path missingSocketPath = getSocketPath(); + TunnelingJdkSocket clientSocket = new TunnelingJdkSocket(missingSocketPath); + + assertThrows(IOException.class, () -> clientSocket.connect(new InetSocketAddress("localhost", 0))); + + assertFalse(clientSocket.isConnected()); + assertTrue(clientSocket.isClosed()); + assertTrue(clientSocket.isInputShutdown()); + assertTrue(clientSocket.isOutputShutdown()); + assertFalse(clientSocket.getChannel().isOpen()); + assertThrows(SocketException.class, () -> clientSocket.connect(new InetSocketAddress("localhost", 0))); } - clientSocket.close(); - closeServer(); + @Test + public void testInputStreamClose() throws Exception { + TunnelingJdkSocket clientSocket = createClient(); + InputStream inputStream = clientSocket.getInputStream(); + OutputStream outputStream = clientSocket.getOutputStream(); + + assertFalse(clientSocket.isClosed()); + assertFalse(clientSocket.isInputShutdown()); + assertFalse(clientSocket.isOutputShutdown()); + + inputStream.close(); + + assertTrue(clientSocket.isClosed()); + assertTrue(clientSocket.isInputShutdown()); + assertTrue(clientSocket.isOutputShutdown()); + assertEquals(-1, inputStream.read()); + assertThrows(IOException.class, () -> outputStream.write(1)); + assertThrows(SocketException.class, clientSocket::getInputStream); + assertThrows(SocketException.class, clientSocket::getOutputStream); + } - long finalCount = getFileDescriptorCount(); - assertTrue(finalCount <= initialCount + 3); - } + @Test + public void testOutputStreamClose() throws Exception { + TunnelingJdkSocket clientSocket = createClient(); + InputStream inputStream = clientSocket.getInputStream(); + OutputStream outputStream = clientSocket.getOutputStream(); + + assertFalse(clientSocket.isClosed()); + assertFalse(clientSocket.isInputShutdown()); + assertFalse(clientSocket.isOutputShutdown()); + + outputStream.close(); + + assertTrue(clientSocket.isClosed()); + assertTrue(clientSocket.isInputShutdown()); + assertTrue(clientSocket.isOutputShutdown()); + assertEquals(-1, inputStream.read()); + assertThrows(IOException.class, () -> outputStream.write(1)); + assertThrows(SocketException.class, clientSocket::getInputStream); + assertThrows(SocketException.class, clientSocket::getOutputStream); + } - @Test - public void testClosedSelectorIsReportedAsSocketException() throws Exception { - try (TunnelingJdkSocket clientSocket = createClient()) { - InputStream inputStream = clientSocket.getInputStream(); - clientSocket.selector.close(); + @Test + public void testTimeout() throws Exception { + TunnelingJdkSocket clientSocket = createClient(); + InputStream inputStream = clientSocket.getInputStream(); + + int testTimeout = 1000; + clientSocket.setSoTimeout(testTimeout); + assertEquals(testTimeout, clientSocket.getSoTimeout()); + + long startTime = System.currentTimeMillis(); + int readResult = inputStream.read(); + long endTime = System.currentTimeMillis(); + long readDuration = endTime - startTime; + int timeVariance = 100; + assertTrue(readDuration >= testTimeout && readDuration <= testTimeout + timeVariance); + assertEquals(0, readResult); + + int newTimeout = testTimeout / 2; + clientSocket.setSoTimeout(newTimeout); + assertEquals(newTimeout, clientSocket.getSoTimeout()); + assertTimeoutPreemptively(Duration.ofMillis(testTimeout), () -> inputStream.read()); + + // The socket should block indefinitely when timeout is set to 0, per + // https://docs.oracle.com/en/java/javase/16/docs/api//java.base/java/net/Socket.html#setSoTimeout(int). + int infiniteTimeout = 0; + clientSocket.setSoTimeout(infiniteTimeout); + assertEquals(infiniteTimeout, clientSocket.getSoTimeout()); + try { + assertTimeoutPreemptively(Duration.ofMillis(testTimeout), () -> inputStream.read()); + fail("Read should block indefinitely with infinite timeout"); + } catch (AssertionError e) { + // Expected + } - SocketException exception = assertThrows(SocketException.class, inputStream::read); + int invalidTimeout = -1; + assertThrows(IllegalArgumentException.class, () -> clientSocket.setSoTimeout(invalidTimeout)); - assertInstanceOf(ClosedSelectorException.class, exception.getCause()); + clientSocket.close(); + assertThrows(SocketException.class, () -> clientSocket.setSoTimeout(testTimeout)); + assertThrows(SocketException.class, clientSocket::getSoTimeout); } - } - - @Test - public void testSelectorClosedBetweenSelectAndSelectedKeysIsReportedAsSocketException() - throws Exception { - try (TunnelingJdkSocket clientSocket = createClient()) { - clientSocket.selector = new ClosedAfterSelectSelector(); - assertTrue( - clientSocket.getChannel().isBlocking(), - "The channel should be blocking before getInputStream()"); - InputStream inputStream = clientSocket.getInputStream(); - assertFalse( - clientSocket.getChannel().isBlocking(), - "getInputStream() should switch the channel to nonblocking mode"); - - SocketException exception = assertThrows(SocketException.class, inputStream::read); - - assertInstanceOf(ClosedSelectorException.class, exception.getCause()); + + @Test + public void testBufferSizes() throws Exception { + TunnelingJdkSocket clientSocket = createClient(); + + assertEquals(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE, clientSocket.getSendBufferSize()); + assertEquals(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE, clientSocket.getReceiveBufferSize()); + assertEquals(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE, clientSocket.getStreamBufferSize()); + + int newBufferSize = TunnelingJdkSocket.DEFAULT_BUFFER_SIZE / 2; + clientSocket.setSendBufferSize(newBufferSize); + clientSocket.setReceiveBufferSize(newBufferSize / 2); + assertEquals(newBufferSize, clientSocket.getSendBufferSize()); + assertEquals(newBufferSize / 2, clientSocket.getReceiveBufferSize()); + assertEquals(newBufferSize, clientSocket.getStreamBufferSize()); + + int invalidBufferSize = -1; + assertThrows(IllegalArgumentException.class, () -> clientSocket.setSendBufferSize(invalidBufferSize)); + assertThrows(IllegalArgumentException.class, () -> clientSocket.setReceiveBufferSize(invalidBufferSize)); + + clientSocket.close(); + assertThrows( + SocketException.class, () -> clientSocket.setSendBufferSize(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE)); + assertThrows( + SocketException.class, () -> clientSocket.setReceiveBufferSize(TunnelingJdkSocket.DEFAULT_BUFFER_SIZE)); + assertThrows(SocketException.class, clientSocket::getSendBufferSize); + assertThrows(SocketException.class, clientSocket::getReceiveBufferSize); + assertThrows(SocketException.class, clientSocket::getStreamBufferSize); } - } - @Test - public void testCancelledKeyIsReportedAsSocketException() throws Exception { - try (TunnelingJdkSocket clientSocket = createClient()) { - clientSocket.selector = new CancelledKeyAfterSelectionSelector(); - InputStream inputStream = clientSocket.getInputStream(); + @Test + public void testFileDescriptorLeak() throws Exception { + long initialCount = getFileDescriptorCount(); - SocketException exception = assertThrows(SocketException.class, inputStream::read); + TunnelingJdkSocket clientSocket = createClient(); - assertInstanceOf(CancelledKeyException.class, exception.getCause()); + for (int i = 0; i < 100; i++) { + @SuppressWarnings("unused") + InputStream inputStream = clientSocket.getInputStream(); + long currentCount = getFileDescriptorCount(); + assertTrue(currentCount <= initialCount + 7); + } + + clientSocket.close(); + closeServer(); + + long finalCount = getFileDescriptorCount(); + assertTrue(finalCount <= initialCount + 3); } - } - - @Test - public void testAsynchronousCloseInterruptsBlockedReadWithIOException() throws Exception { - TunnelingJdkSocket clientSocket = createClient(); - Thread reader = null; - try { - BlockingCloseSelector blockingCloseSelector = new BlockingCloseSelector(); - clientSocket.selector = blockingCloseSelector; - InputStream inputStream = clientSocket.getInputStream(); - AtomicReference readFailure = new AtomicReference<>(); - - reader = - new Thread( - () -> { - try { - inputStream.read(); - } catch (Throwable t) { - readFailure.set(t); - } - }, - "tunneling-jdk-socket-reader"); - reader.setDaemon(true); - reader.start(); - - assertTrue( - blockingCloseSelector.awaitSelectStarted(5, TimeUnit.SECONDS), - "The reader did not block in Selector.select"); - clientSocket.close(); - reader.join(TimeUnit.SECONDS.toMillis(5)); - - assertFalse(reader.isAlive(), "The blocked read did not terminate after close"); - Throwable failure = readFailure.get(); - assertNotNull(failure, "The blocked read should fail when the socket is closed"); - assertInstanceOf( - IOException.class, - failure, - () -> "Expected an IOException, but got " + failure.getClass().getName()); - } finally { - clientSocket.close(); - if (reader != null && reader.isAlive()) { - reader.interrupt(); - reader.join(TimeUnit.SECONDS.toMillis(5)); - } + + @Test + public void testClosedSelectorIsReportedAsSocketException() throws Exception { + try (TunnelingJdkSocket clientSocket = createClient()) { + InputStream inputStream = clientSocket.getInputStream(); + clientSocket.selector.close(); + + SocketException exception = assertThrows(SocketException.class, inputStream::read); + + assertInstanceOf(ClosedSelectorException.class, exception.getCause()); + } + } + + @Test + public void testSelectorClosedBetweenSelectAndSelectedKeysIsReportedAsSocketException() throws Exception { + try (TunnelingJdkSocket clientSocket = createClient()) { + clientSocket.selector = new ClosedAfterSelectSelector(); + assertTrue( + clientSocket.getChannel().isBlocking(), "The channel should be blocking before getInputStream()"); + InputStream inputStream = clientSocket.getInputStream(); + assertFalse( + clientSocket.getChannel().isBlocking(), + "getInputStream() should switch the channel to nonblocking mode"); + + SocketException exception = assertThrows(SocketException.class, inputStream::read); + + assertInstanceOf(ClosedSelectorException.class, exception.getCause()); + } + } + + @Test + public void testCancelledKeyIsReportedAsSocketException() throws Exception { + try (TunnelingJdkSocket clientSocket = createClient()) { + clientSocket.selector = new CancelledKeyAfterSelectionSelector(); + InputStream inputStream = clientSocket.getInputStream(); + + SocketException exception = assertThrows(SocketException.class, inputStream::read); + + assertInstanceOf(CancelledKeyException.class, exception.getCause()); + } } - } - - @Test - public void testCloseInterruptsWriteWhileInputStreamIsBeingCreated() throws Exception { - TunnelingJdkSocket clientSocket = createClient(); - ExecutorService executor = Executors.newFixedThreadPool(3); - try { - clientSocket.setSendBufferSize(4096); - OutputStream output = clientSocket.getOutputStream(); - CountDownLatch writing = new CountDownLatch(1); - Future writer = - executor.submit( - () -> { + + @Test + public void testAsynchronousCloseInterruptsBlockedReadWithIOException() throws Exception { + TunnelingJdkSocket clientSocket = createClient(); + Thread reader = null; + try { + BlockingCloseSelector blockingCloseSelector = new BlockingCloseSelector(); + clientSocket.selector = blockingCloseSelector; + InputStream inputStream = clientSocket.getInputStream(); + AtomicReference readFailure = new AtomicReference<>(); + + reader = new Thread( + () -> { + try { + inputStream.read(); + } catch (Throwable t) { + readFailure.set(t); + } + }, + "tunneling-jdk-socket-reader"); + reader.setDaemon(true); + reader.start(); + + assertTrue( + blockingCloseSelector.awaitSelectStarted(5, TimeUnit.SECONDS), + "The reader did not block in Selector.select"); + clientSocket.close(); + reader.join(TimeUnit.SECONDS.toMillis(5)); + + assertFalse(reader.isAlive(), "The blocked read did not terminate after close"); + Throwable failure = readFailure.get(); + assertNotNull(failure, "The blocked read should fail when the socket is closed"); + assertInstanceOf( + IOException.class, + failure, + () -> "Expected an IOException, but got " + + failure.getClass().getName()); + } finally { + clientSocket.close(); + if (reader != null && reader.isAlive()) { + reader.interrupt(); + reader.join(TimeUnit.SECONDS.toMillis(5)); + } + } + } + + @Test + public void testCloseInterruptsWriteWhileInputStreamIsBeingCreated() throws Exception { + TunnelingJdkSocket clientSocket = createClient(); + ExecutorService executor = Executors.newFixedThreadPool(3); + try { + clientSocket.setSendBufferSize(4096); + OutputStream output = clientSocket.getOutputStream(); + CountDownLatch writing = new CountDownLatch(1); + Future writer = executor.submit(() -> { writing.countDown(); // The server accepts but does not read, so this exceeds the socket's send buffer. output.write(new byte[1024 * 1024]); return null; - }); - assertTrue(writing.await(5, TimeUnit.SECONDS)); - assertThrows(TimeoutException.class, () -> writer.get(100, TimeUnit.MILLISECONDS)); - - AtomicReference inputThread = new AtomicReference<>(); - Future input = - executor.submit( - () -> { + }); + assertTrue(writing.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> writer.get(100, TimeUnit.MILLISECONDS)); + + AtomicReference inputThread = new AtomicReference<>(); + Future input = executor.submit(() -> { inputThread.set(Thread.currentThread()); return clientSocket.getInputStream(); - }); - // Wait until input setup is waiting for the channel's blocked writer before closing. - assertTimeoutPreemptively( - Duration.ofSeconds(5), - () -> { - while (inputThread.get() == null - || inputThread.get().getState() != Thread.State.WAITING) { - assertFalse(input.isDone(), "Input setup should wait for the blocked writer"); - Thread.sleep(1); - } - }); - - executor.submit(clientSocket::close).get(5, TimeUnit.SECONDS); - - assertTrue(clientSocket.isClosed()); - assertFalse(clientSocket.getChannel().isOpen()); - assertInstanceOf( - IOException.class, - assertThrows(ExecutionException.class, () -> writer.get(5, TimeUnit.SECONDS)).getCause()); - assertInstanceOf( - IOException.class, - assertThrows(ExecutionException.class, () -> input.get(5, TimeUnit.SECONDS)).getCause()); - } finally { - // Also release the writer if the regression prevents socket.close() from taking its monitor. - clientSocket.getChannel().close(); - executor.shutdownNow(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - clientSocket.close(); - } - } - - private long getFileDescriptorCount() { - try { - Process process = Runtime.getRuntime().exec("lsof -p " + ProcessHandle.current().pid()); - int count = 0; - try (java.io.BufferedReader reader = - new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream()))) { - while (reader.readLine() != null) { - count++; + }); + // Wait until input setup is waiting for the channel's blocked writer before closing. + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { + while (inputThread.get() == null || inputThread.get().getState() != Thread.State.WAITING) { + assertFalse(input.isDone(), "Input setup should wait for the blocked writer"); + Thread.sleep(1); + } + }); + + executor.submit(clientSocket::close).get(5, TimeUnit.SECONDS); + + assertTrue(clientSocket.isClosed()); + assertFalse(clientSocket.getChannel().isOpen()); + assertInstanceOf( + IOException.class, + assertThrows(ExecutionException.class, () -> writer.get(5, TimeUnit.SECONDS)) + .getCause()); + assertInstanceOf( + IOException.class, + assertThrows(ExecutionException.class, () -> input.get(5, TimeUnit.SECONDS)) + .getCause()); + } finally { + // Also release the writer if the regression prevents socket.close() from taking its monitor. + clientSocket.getChannel().close(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + clientSocket.close(); } - } - return count; - } catch (IOException e) { - throw new RuntimeException("Failed to get file descriptor count", e); } - } - - private void startServer(UnixDomainSocketAddress socketAddress) throws IOException { - server = new TestUnixSocketServer(socketAddress); - } - - private Path getSocketPath() throws IOException { - Path socketPath = Files.createTempFile("testSocket", null); - Files.delete(socketPath); - socketPath.toFile().deleteOnExit(); - return socketPath; - } - - private TunnelingJdkSocket createClient() throws IOException { - Path socketPath = getSocketPath(); - UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(socketPath); - startServer(socketAddress); - TunnelingJdkSocket clientSocket = new TunnelingJdkSocket(socketPath); - clientSocket.connect(new InetSocketAddress("localhost", 0)); - return clientSocket; - } - - private static final class TestUnixSocketServer implements AutoCloseable { - private final Path socketPath; - private final ServerSocketChannel serverChannel; - private final AtomicReference acceptedChannel = new AtomicReference<>(); - private final AtomicReference serverFailure = new AtomicReference<>(); - private final Thread serverThread; - - private TestUnixSocketServer(UnixDomainSocketAddress socketAddress) throws IOException { - socketPath = socketAddress.getPath(); - serverChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); - boolean bound = false; - try { - serverChannel.bind(socketAddress); - bound = true; - } finally { - if (!bound) { - serverChannel.close(); - } - } - serverThread = - new Thread( - () -> { - try { - acceptedChannel.set(serverChannel.accept()); - } catch (IOException e) { - if (serverChannel.isOpen()) { - serverFailure.set(e); - } + private long getFileDescriptorCount() { + try { + Process process = Runtime.getRuntime() + .exec("lsof -p " + ProcessHandle.current().pid()); + int count = 0; + try (java.io.BufferedReader reader = + new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream()))) { + while (reader.readLine() != null) { + count++; } - }, - "tunneling-jdk-socket-test-server"); - serverThread.setDaemon(true); - serverThread.start(); + } + return count; + } catch (IOException e) { + throw new RuntimeException("Failed to get file descriptor count", e); + } } - @Override - public void close() throws Exception { - serverChannel.close(); - serverThread.join(TimeUnit.SECONDS.toMillis(5)); - if (serverThread.isAlive()) { - serverThread.interrupt(); - throw new AssertionError("The Unix-domain test server did not terminate"); - } - - SocketChannel clientChannel = acceptedChannel.get(); - if (clientChannel != null) { - clientChannel.close(); - } - Files.deleteIfExists(socketPath); - - Throwable failure = serverFailure.get(); - if (failure != null) { - throw new AssertionError("The Unix-domain test server failed", failure); - } + private void startServer(UnixDomainSocketAddress socketAddress) throws IOException { + server = new TestUnixSocketServer(socketAddress); } - } - private abstract static class SelectorAdapter extends Selector { - private volatile boolean open = true; - - @Override - public final boolean isOpen() { - return open; + private Path getSocketPath() throws IOException { + Path socketPath = Files.createTempFile("testSocket", null); + Files.delete(socketPath); + socketPath.toFile().deleteOnExit(); + return socketPath; } - @Override - public final SelectorProvider provider() { - return SelectorProvider.provider(); + private TunnelingJdkSocket createClient() throws IOException { + Path socketPath = getSocketPath(); + UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(socketPath); + startServer(socketAddress); + TunnelingJdkSocket clientSocket = new TunnelingJdkSocket(socketPath); + clientSocket.connect(new InetSocketAddress("localhost", 0)); + return clientSocket; } - @Override - public final int selectNow() { - return doSelect(); - } + private static final class TestUnixSocketServer implements AutoCloseable { + private final Path socketPath; + private final ServerSocketChannel serverChannel; + private final AtomicReference acceptedChannel = new AtomicReference<>(); + private final AtomicReference serverFailure = new AtomicReference<>(); + private final Thread serverThread; + + private TestUnixSocketServer(UnixDomainSocketAddress socketAddress) throws IOException { + socketPath = socketAddress.getPath(); + serverChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); + boolean bound = false; + try { + serverChannel.bind(socketAddress); + bound = true; + } finally { + if (!bound) { + serverChannel.close(); + } + } - @Override - public final int select(long timeout) { - return doSelect(); - } + serverThread = new Thread( + () -> { + try { + acceptedChannel.set(serverChannel.accept()); + } catch (IOException e) { + if (serverChannel.isOpen()) { + serverFailure.set(e); + } + } + }, + "tunneling-jdk-socket-test-server"); + serverThread.setDaemon(true); + serverThread.start(); + } - @Override - public final int select() { - return doSelect(); - } + @Override + public void close() throws Exception { + serverChannel.close(); + serverThread.join(TimeUnit.SECONDS.toMillis(5)); + if (serverThread.isAlive()) { + serverThread.interrupt(); + throw new AssertionError("The Unix-domain test server did not terminate"); + } - @Override - public final Selector wakeup() { - return this; - } + SocketChannel clientChannel = acceptedChannel.get(); + if (clientChannel != null) { + clientChannel.close(); + } + Files.deleteIfExists(socketPath); - @Override - public final void close() { - if (open) { - open = false; - onClose(); - } + Throwable failure = serverFailure.get(); + if (failure != null) { + throw new AssertionError("The Unix-domain test server failed", failure); + } + } } - abstract int doSelect(); - - void onClose() {} - } - - /** - * Models another thread closing a selector while a socket read is blocked: - * - *

    - *
  1. Signals when {@code select()} is entered. - *
  2. Blocks until another thread closes the selector. - *
  3. Throws {@link ClosedSelectorException} after closure. - *
  4. Lets the test verify that the reader receives an {@link IOException} and terminates. - *
- */ - private static final class BlockingCloseSelector extends SelectorAdapter { - private final CountDownLatch selectStarted = new CountDownLatch(1); - private final CountDownLatch closed = new CountDownLatch(1); - - @Override - public Set keys() { - return Collections.emptySet(); - } + private abstract static class SelectorAdapter extends Selector { + private volatile boolean open = true; - @Override - public Set selectedKeys() { - return Collections.emptySet(); - } + @Override + public final boolean isOpen() { + return open; + } - @Override - int doSelect() { - selectStarted.countDown(); - try { - if (!closed.await(5, TimeUnit.SECONDS)) { - throw new AssertionError("Selector was not closed"); + @Override + public final SelectorProvider provider() { + return SelectorProvider.provider(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted while waiting for the selector to close", e); - } - throw new ClosedSelectorException(); - } - @Override - void onClose() { - closed.countDown(); - } + @Override + public final int selectNow() { + return doSelect(); + } - boolean awaitSelectStarted(long timeout, TimeUnit unit) throws InterruptedException { - return selectStarted.await(timeout, unit); - } - } - - /** - * Models a selector closing between selection and selected-key processing: - * - *
    - *
  1. Closes itself during {@code select()} and reports one ready channel. - *
  2. Throws {@link ClosedSelectorException} when the selected keys are requested. - *
  3. Lets the test verify that the exception is reported as a {@link SocketException}. - *
- */ - private static final class ClosedAfterSelectSelector extends SelectorAdapter { - @Override - public Set keys() { - return Collections.emptySet(); - } + @Override + public final int select(long timeout) { + return doSelect(); + } - @Override - public Set selectedKeys() { - if (!isOpen()) { - throw new ClosedSelectorException(); - } - return Collections.emptySet(); - } + @Override + public final int select() { + return doSelect(); + } - @Override - int doSelect() { - close(); - return 1; - } - } - - /** - * Models a key being canceled after selection, before selected-key processing: - * - *
    - *
  1. Reports one ready channel from {@code select()}. - *
  2. Cancels the selected key before returning it. - *
  3. Throws {@link CancelledKeyException} when the read checks whether the key is readable. - *
  4. Lets the test verify that the exception is reported as a {@link SocketException}. - *
- */ - private static final class CancelledKeyAfterSelectionSelector extends SelectorAdapter { - private final CancelledSelectionKey key = new CancelledSelectionKey(this); - private final Set selectedKeys = new HashSet<>(Collections.singleton(key)); - - @Override - public Set keys() { - return selectedKeys; + @Override + public final Selector wakeup() { + return this; + } + + @Override + public final void close() { + if (open) { + open = false; + onClose(); + } + } + + abstract int doSelect(); + + void onClose() {} } - @Override - public Set selectedKeys() { - key.cancel(); - return selectedKeys; + /** + * Models another thread closing a selector while a socket read is blocked: + * + *
    + *
  1. Signals when {@code select()} is entered. + *
  2. Blocks until another thread closes the selector. + *
  3. Throws {@link ClosedSelectorException} after closure. + *
  4. Lets the test verify that the reader receives an {@link IOException} and terminates. + *
+ */ + private static final class BlockingCloseSelector extends SelectorAdapter { + private final CountDownLatch selectStarted = new CountDownLatch(1); + private final CountDownLatch closed = new CountDownLatch(1); + + @Override + public Set keys() { + return Collections.emptySet(); + } + + @Override + public Set selectedKeys() { + return Collections.emptySet(); + } + + @Override + int doSelect() { + selectStarted.countDown(); + try { + if (!closed.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Selector was not closed"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for the selector to close", e); + } + throw new ClosedSelectorException(); + } + + @Override + void onClose() { + closed.countDown(); + } + + boolean awaitSelectStarted(long timeout, TimeUnit unit) throws InterruptedException { + return selectStarted.await(timeout, unit); + } } - @Override - int doSelect() { - return 1; + /** + * Models a selector closing between selection and selected-key processing: + * + *
    + *
  1. Closes itself during {@code select()} and reports one ready channel. + *
  2. Throws {@link ClosedSelectorException} when the selected keys are requested. + *
  3. Lets the test verify that the exception is reported as a {@link SocketException}. + *
+ */ + private static final class ClosedAfterSelectSelector extends SelectorAdapter { + @Override + public Set keys() { + return Collections.emptySet(); + } + + @Override + public Set selectedKeys() { + if (!isOpen()) { + throw new ClosedSelectorException(); + } + return Collections.emptySet(); + } + + @Override + int doSelect() { + close(); + return 1; + } } - private static final class CancelledSelectionKey extends SelectionKey { - private final Selector selector; - private boolean valid = true; - - private CancelledSelectionKey(Selector selector) { - this.selector = selector; - } - - @Override - public SelectableChannel channel() { - return null; - } - - @Override - public Selector selector() { - return selector; - } - - @Override - public boolean isValid() { - return valid; - } - - @Override - public void cancel() { - valid = false; - } - - @Override - public int interestOps() { - return OP_READ; - } - - @Override - public SelectionKey interestOps(int ops) { - return this; - } - - @Override - public int readyOps() { - if (!valid) { - throw new CancelledKeyException(); + /** + * Models a key being canceled after selection, before selected-key processing: + * + *
    + *
  1. Reports one ready channel from {@code select()}. + *
  2. Cancels the selected key before returning it. + *
  3. Throws {@link CancelledKeyException} when the read checks whether the key is readable. + *
  4. Lets the test verify that the exception is reported as a {@link SocketException}. + *
+ */ + private static final class CancelledKeyAfterSelectionSelector extends SelectorAdapter { + private final CancelledSelectionKey key = new CancelledSelectionKey(this); + private final Set selectedKeys = new HashSet<>(Collections.singleton(key)); + + @Override + public Set keys() { + return selectedKeys; + } + + @Override + public Set selectedKeys() { + key.cancel(); + return selectedKeys; + } + + @Override + int doSelect() { + return 1; + } + + private static final class CancelledSelectionKey extends SelectionKey { + private final Selector selector; + private boolean valid = true; + + private CancelledSelectionKey(Selector selector) { + this.selector = selector; + } + + @Override + public SelectableChannel channel() { + return null; + } + + @Override + public Selector selector() { + return selector; + } + + @Override + public boolean isValid() { + return valid; + } + + @Override + public void cancel() { + valid = false; + } + + @Override + public int interestOps() { + return OP_READ; + } + + @Override + public SelectionKey interestOps(int ops) { + return this; + } + + @Override + public int readyOps() { + if (!valid) { + throw new CancelledKeyException(); + } + return OP_READ; + } } - return OP_READ; - } } - } - - private static boolean udsSupported() { - Path socketPath = null; - try { - socketPath = Files.createTempFile("testSocketSupport", null); - Files.delete(socketPath); - try (ServerSocketChannel serverChannel = - ServerSocketChannel.open(StandardProtocolFamily.UNIX)) { - serverChannel.bind(UnixDomainSocketAddress.of(socketPath)); - } - return true; - } catch (IOException | UnsupportedOperationException e) { - return false; - } finally { - if (socketPath != null) { + + private static boolean udsSupported() { + Path socketPath = null; try { - Files.deleteIfExists(socketPath); - } catch (IOException ignored) { + socketPath = Files.createTempFile("testSocketSupport", null); + Files.delete(socketPath); + try (ServerSocketChannel serverChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) { + serverChannel.bind(UnixDomainSocketAddress.of(socketPath)); + } + return true; + } catch (IOException | UnsupportedOperationException e) { + return false; + } finally { + if (socketPath != null) { + try { + Files.deleteIfExists(socketPath); + } catch (IOException ignored) { + } + } } - } } - } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedMessage.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedMessage.java index 68f7dfc897d..e8df6bbcc57 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedMessage.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedMessage.java @@ -3,5 +3,5 @@ import java.util.List; public interface DecodedMessage { - List getTraces(); + List getTraces(); } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java index 355863e3924..aff91f0ea72 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpan.java @@ -4,41 +4,41 @@ import java.util.Map; public interface DecodedSpan { - String getService(); + String getService(); - String getName(); + String getName(); - String getResource(); + String getResource(); - /** - * Returns the span 64-bit trace identifier, dropping high-order bits if present. - * - * @return The span 64-bit trace identifier. - */ - long getTraceId(); + /** + * Returns the span 64-bit trace identifier, dropping high-order bits if present. + * + * @return The span 64-bit trace identifier. + */ + long getTraceId(); - long getSpanId(); + long getSpanId(); - long getParentId(); + long getParentId(); - long getStart(); + long getStart(); - long getDuration(); + long getDuration(); - int getError(); + int getError(); - Map getMeta(); + Map getMeta(); - Map getMetaStruct(); + Map getMetaStruct(); - Map getMetrics(); + Map getMetrics(); - String getType(); + String getType(); - /** - * Returns the links. - * - * @return The span links, empty when the span carries none. - */ - List getLinks(); + /** + * Returns the links. + * + * @return The span links, empty when the span carries none. + */ + List getLinks(); } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLink.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLink.java index 4f016f84f8f..b0ae728854b 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLink.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLink.java @@ -11,43 +11,43 @@ * absent trace state to {@code ""}, and absent attributes to an empty map. */ public interface DecodedSpanLink { - /** - * Returns the identifier of the trace this link refers to, on the same 64 bits as {@link - * DecodedSpan#getTraceId()} so the two compare directly. - * - *

The wire carries 128 bits, but the decoder narrows a trace identifier to its low-order half - * everywhere — see {@link DecodedSpan#getTraceId()} — so a link is narrowed the same way rather - * than modelling a width no span can be compared against. - * - * @return The identifier of the trace this link refers to. - */ - long getTraceId(); + /** + * Returns the identifier of the trace this link refers to, on the same 64 bits as {@link + * DecodedSpan#getTraceId()} so the two compare directly. + * + *

The wire carries 128 bits, but the decoder narrows a trace identifier to its low-order half + * everywhere — see {@link DecodedSpan#getTraceId()} — so a link is narrowed the same way rather + * than modelling a width no span can be compared against. + * + * @return The identifier of the trace this link refers to. + */ + long getTraceId(); - /** - * Returns the identifier of the span this link refers to. - * - * @return The unsigned 64-bit span identifier this link refers to. - */ - long getSpanId(); + /** + * Returns the identifier of the span this link refers to. + * + * @return The unsigned 64-bit span identifier this link refers to. + */ + long getSpanId(); - /** - * Returns the W3C trace flags of the linked span. - * - * @return The trace flags, {@code 0} if the tracer sent none. - */ - byte getTraceFlags(); + /** + * Returns the W3C trace flags of the linked span. + * + * @return The trace flags, {@code 0} if the tracer sent none. + */ + byte getTraceFlags(); - /** - * Returns the W3C trace state of the linked span. - * - * @return The trace state, empty if the tracer sent none. - */ - String getTraceState(); + /** + * Returns the W3C trace state of the linked span. + * + * @return The trace state, empty if the tracer sent none. + */ + String getTraceState(); - /** - * Returns the attributes attached to this link. - * - * @return The link attributes, empty if the tracer sent none. - */ - Map getAttributes(); + /** + * Returns the attributes attached to this link. + * + * @return The link attributes, empty if the tracer sent none. + */ + Map getAttributes(); } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLinks.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLinks.java index 657582d8e74..6bcaa42600c 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLinks.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedSpanLinks.java @@ -24,172 +24,161 @@ * {@code v1} span decoder through {@link #link}. */ public final class DecodedSpanLinks { - private static final String SPAN_LINKS_TAG = "_dd.span_links"; - private static final int TRACE_ID_HEX_LENGTH = 16; - private static final Type LIST_OF_LINKS = - Types.newParameterizedType(List.class, SpanLinkJson.class); - private static final JsonAdapter> ADAPTER = - new Moshi.Builder().build().adapter(LIST_OF_LINKS); - - private DecodedSpanLinks() {} - - /** - * Decodes the value of meta tags. - * - * @param meta The span meta collection. - * @return The decoded links, in the order the tracer serialized them; empty if the tag is absent - * or holds no link. - * @throws IllegalStateException If the tag value is not a well-formed span-link array. - */ - public static List fromMeta(Map meta) { - String tagValue = meta.get(SPAN_LINKS_TAG); - if (tagValue == null || tagValue.isEmpty()) { - return emptyList(); - } - List rawLinks; - try { - rawLinks = ADAPTER.fromJson(tagValue); - } catch (IOException | JsonDataException e) { - throw new IllegalStateException("Failed to parse span links: " + tagValue, e); - } - if (rawLinks == null || rawLinks.isEmpty()) { - return emptyList(); - } - List links = new ArrayList<>(rawLinks.size()); - for (SpanLinkJson rawLink : rawLinks) { - if (rawLink == null) { - throw new IllegalStateException("Malformed span links with a null link: " + tagValue); - } - links.add(toLink(rawLink, tagValue)); - } - return unmodifiableList(links); - } - - /** - * Builds a link from already decoded values, for the structured {@code v1.0} payload field. - * - * @param traceId The linked trace identifier. - * @param spanId The linked span identifier. - * @param traceFlags The W3C trace flags. - * @param traceState The W3C trace state, {@code null} for none. - * @param attributes The link attributes, {@code null} for none. - * @return The link. - */ - public static DecodedSpanLink link( - long traceId, - long spanId, - byte traceFlags, - String traceState, - Map attributes) { - return new SpanLink(traceId, spanId, traceFlags, traceState, attributes); - } - - private static DecodedSpanLink toLink(SpanLinkJson rawLink, String tagValue) { - if (rawLink.traceId == null || rawLink.spanId == null) { - throw new IllegalStateException( - "Span link missing a required field (trace_id, span_id): " + tagValue); - } - try { - return new SpanLink( - Long.parseUnsignedLong(lowOrderTraceId(rawLink.traceId), 16), - Long.parseUnsignedLong(rawLink.spanId, 16), - rawLink.flags == null ? 0 : (byte) rawLink.flags.intValue(), - rawLink.traceState, - rawLink.attributes); - } catch (NumberFormatException e) { - throw new IllegalStateException("Span link with a malformed identifier: " + tagValue, e); - } - } - - /** - * Keeps the low-order 64 bits of a serialized trace identifier. The tracer always emits the full - * 128-bit width, of which the decoder models the low-order half. - */ - private static String lowOrderTraceId(String traceId) { - return traceId.length() > TRACE_ID_HEX_LENGTH - ? traceId.substring(traceId.length() - TRACE_ID_HEX_LENGTH) - : traceId; - } - - /** The serialized shape of a single link, as the tracer's {@code DDSpanLink} writes it. */ - private static final class SpanLinkJson { - @Json(name = "trace_id") - String traceId; - - @Json(name = "span_id") - String spanId; - - // Read as an Integer, then narrowed, mirroring how the tracer writes `traceFlags() & 0xFF`. - Integer flags; - - @Json(name = "tracestate") - String traceState; - - Map attributes; - } - - private static final class SpanLink implements DecodedSpanLink { - private final long traceId; - private final long spanId; - private final byte traceFlags; - private final String traceState; - private final Map attributes; - - private SpanLink( - long traceId, - long spanId, - byte traceFlags, - String traceState, - Map attributes) { - this.traceId = traceId; - this.spanId = spanId; - this.traceFlags = traceFlags; - this.traceState = traceState == null ? "" : traceState; - this.attributes = - attributes == null || attributes.isEmpty() - ? emptyMap() - : unmodifiableMap(new HashMap<>(attributes)); + private static final String SPAN_LINKS_TAG = "_dd.span_links"; + private static final int TRACE_ID_HEX_LENGTH = 16; + private static final Type LIST_OF_LINKS = Types.newParameterizedType(List.class, SpanLinkJson.class); + private static final JsonAdapter> ADAPTER = + new Moshi.Builder().build().adapter(LIST_OF_LINKS); + + private DecodedSpanLinks() {} + + /** + * Decodes the value of meta tags. + * + * @param meta The span meta collection. + * @return The decoded links, in the order the tracer serialized them; empty if the tag is absent + * or holds no link. + * @throws IllegalStateException If the tag value is not a well-formed span-link array. + */ + public static List fromMeta(Map meta) { + String tagValue = meta.get(SPAN_LINKS_TAG); + if (tagValue == null || tagValue.isEmpty()) { + return emptyList(); + } + List rawLinks; + try { + rawLinks = ADAPTER.fromJson(tagValue); + } catch (IOException | JsonDataException e) { + throw new IllegalStateException("Failed to parse span links: " + tagValue, e); + } + if (rawLinks == null || rawLinks.isEmpty()) { + return emptyList(); + } + List links = new ArrayList<>(rawLinks.size()); + for (SpanLinkJson rawLink : rawLinks) { + if (rawLink == null) { + throw new IllegalStateException("Malformed span links with a null link: " + tagValue); + } + links.add(toLink(rawLink, tagValue)); + } + return unmodifiableList(links); } - @Override - public long getTraceId() { - return this.traceId; + /** + * Builds a link from already decoded values, for the structured {@code v1.0} payload field. + * + * @param traceId The linked trace identifier. + * @param spanId The linked span identifier. + * @param traceFlags The W3C trace flags. + * @param traceState The W3C trace state, {@code null} for none. + * @param attributes The link attributes, {@code null} for none. + * @return The link. + */ + public static DecodedSpanLink link( + long traceId, long spanId, byte traceFlags, String traceState, Map attributes) { + return new SpanLink(traceId, spanId, traceFlags, traceState, attributes); } - @Override - public long getSpanId() { - return this.spanId; + private static DecodedSpanLink toLink(SpanLinkJson rawLink, String tagValue) { + if (rawLink.traceId == null || rawLink.spanId == null) { + throw new IllegalStateException("Span link missing a required field (trace_id, span_id): " + tagValue); + } + try { + return new SpanLink( + Long.parseUnsignedLong(lowOrderTraceId(rawLink.traceId), 16), + Long.parseUnsignedLong(rawLink.spanId, 16), + rawLink.flags == null ? 0 : (byte) rawLink.flags.intValue(), + rawLink.traceState, + rawLink.attributes); + } catch (NumberFormatException e) { + throw new IllegalStateException("Span link with a malformed identifier: " + tagValue, e); + } } - @Override - public byte getTraceFlags() { - return this.traceFlags; + /** + * Keeps the low-order 64 bits of a serialized trace identifier. The tracer always emits the full + * 128-bit width, of which the decoder models the low-order half. + */ + private static String lowOrderTraceId(String traceId) { + return traceId.length() > TRACE_ID_HEX_LENGTH + ? traceId.substring(traceId.length() - TRACE_ID_HEX_LENGTH) + : traceId; } - @Override - public String getTraceState() { - return this.traceState; - } + /** The serialized shape of a single link, as the tracer's {@code DDSpanLink} writes it. */ + private static final class SpanLinkJson { + @Json(name = "trace_id") + String traceId; + + @Json(name = "span_id") + String spanId; + + // Read as an Integer, then narrowed, mirroring how the tracer writes `traceFlags() & 0xFF`. + Integer flags; + + @Json(name = "tracestate") + String traceState; - @Override - public Map getAttributes() { - return this.attributes; + Map attributes; } - @Override - public String toString() { - // Renders the identifiers the way the wire carries them, so failures read wire-faithfully. - return "SpanLink{traceId=" - + Long.toHexString(this.traceId) - + ", spanId=" - + Long.toHexString(this.spanId) - + ", traceFlags=" - + this.traceFlags - + ", traceState='" - + this.traceState - + "', attributes=" - + this.attributes - + '}'; + private static final class SpanLink implements DecodedSpanLink { + private final long traceId; + private final long spanId; + private final byte traceFlags; + private final String traceState; + private final Map attributes; + + private SpanLink( + long traceId, long spanId, byte traceFlags, String traceState, Map attributes) { + this.traceId = traceId; + this.spanId = spanId; + this.traceFlags = traceFlags; + this.traceState = traceState == null ? "" : traceState; + this.attributes = attributes == null || attributes.isEmpty() + ? emptyMap() + : unmodifiableMap(new HashMap<>(attributes)); + } + + @Override + public long getTraceId() { + return this.traceId; + } + + @Override + public long getSpanId() { + return this.spanId; + } + + @Override + public byte getTraceFlags() { + return this.traceFlags; + } + + @Override + public String getTraceState() { + return this.traceState; + } + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override + public String toString() { + // Renders the identifiers the way the wire carries them, so failures read wire-faithfully. + return "SpanLink{traceId=" + + Long.toHexString(this.traceId) + + ", spanId=" + + Long.toHexString(this.spanId) + + ", traceFlags=" + + this.traceFlags + + ", traceState='" + + this.traceState + + "', attributes=" + + this.attributes + + '}'; + } } - } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedTrace.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedTrace.java index 63df7a68ca1..90b2047b117 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedTrace.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/DecodedTrace.java @@ -3,10 +3,10 @@ import java.util.List; public interface DecodedTrace { - List getSpans(); + List getSpans(); - // Payload v1.0 has sampling priority on trace level. - default Integer getSamplingPriority() { - return null; - } + // Payload v1.0 has sampling priority on trace level. + default Integer getSamplingPriority() { + return null; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java index 313584518a3..8511d12576f 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java @@ -9,32 +9,30 @@ import java.util.List; public class Decoder { - public static DecodedMessage decodeV1(byte[] buffer) { - return MessageV1.unpack(buffer); - } + public static DecodedMessage decodeV1(byte[] buffer) { + return MessageV1.unpack(buffer); + } - /** Decodes the JSON trace format exposed by the dd-apm-test-agent. */ - public static DecodedMessage decodeJson(String json) { - return MessageJson.fromJson(json); - } + /** Decodes the JSON trace format exposed by the dd-apm-test-agent. */ + public static DecodedMessage decodeJson(String json) { + return MessageJson.fromJson(json); + } - public static DecodedMessage decodeV05(byte[] buffer) { - return MessageV05.unpack(buffer); - } + public static DecodedMessage decodeV05(byte[] buffer) { + return MessageV05.unpack(buffer); + } - public static DecodedMessage decodeV04(byte[] buffer) { - return MessageV04.unpack(buffer); - } + public static DecodedMessage decodeV04(byte[] buffer) { + return MessageV04.unpack(buffer); + } - public static List sortByStart(Collection spans) { - DecodedSpan[] spanArray = new DecodedSpan[spans.size()]; - spanArray = spans.toArray(spanArray); - Arrays.sort( - spanArray, - (o1, o2) -> { - long res = o1.getStart() - o2.getStart(); - return res == 0 ? 0 : res > 0 ? 1 : -1; + public static List sortByStart(Collection spans) { + DecodedSpan[] spanArray = new DecodedSpan[spans.size()]; + spanArray = spans.toArray(spanArray); + Arrays.sort(spanArray, (o1, o2) -> { + long res = o1.getStart() - o2.getStart(); + return res == 0 ? 0 : res > 0 ? 1 : -1; }); - return Arrays.asList(spanArray); - } + return Arrays.asList(spanArray); + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java index 42e8971cf06..9d7b02be7eb 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java @@ -28,168 +28,163 @@ * the list of traces. */ public final class MessageJson implements DecodedMessage { - private static final Type LIST_OF_TRACES = - Types.newParameterizedType( - List.class, Types.newParameterizedType(List.class, SpanJson.class)); - private static final JsonAdapter>> ADAPTER = - new Moshi.Builder() - .add(new MetricNumberAdapter()) - .add(new MetaStructObjectAdapter()) - .build() - .adapter(LIST_OF_TRACES); + private static final Type LIST_OF_TRACES = + Types.newParameterizedType(List.class, Types.newParameterizedType(List.class, SpanJson.class)); + private static final JsonAdapter>> ADAPTER = new Moshi.Builder() + .add(new MetricNumberAdapter()) + .add(new MetaStructObjectAdapter()) + .build() + .adapter(LIST_OF_TRACES); - private final List traces; + private final List traces; - private MessageJson(List traces) { - this.traces = unmodifiableList(traces); - } - - /** Decodes a JSON trace payload into a {@link MessageJson}. */ - public static MessageJson fromJson(String json) { - List> rawTraces; - try { - rawTraces = ADAPTER.fromJson(json); - } catch (IOException | JsonDataException e) { - throw new IllegalStateException("Failed to parse JSON traces: " + json, e); + private MessageJson(List traces) { + this.traces = unmodifiableList(traces); } - List traces = new ArrayList<>(); - if (rawTraces != null) { - for (List spans : rawTraces) { - if (spans == null) { - throw new IllegalStateException("Malformed JSON trace with a null trace: " + json); + + /** Decodes a JSON trace payload into a {@link MessageJson}. */ + public static MessageJson fromJson(String json) { + List> rawTraces; + try { + rawTraces = ADAPTER.fromJson(json); + } catch (IOException | JsonDataException e) { + throw new IllegalStateException("Failed to parse JSON traces: " + json, e); } - List decodedSpans = new ArrayList<>(spans.size()); - for (SpanJson span : spans) { - if (span == null) { - throw new IllegalStateException("Malformed JSON trace with a null span: " + json); - } - if (span.name == null - || span.traceId == null - || span.spanId == null - || span.start == null - || span.duration == null) { - throw new IllegalStateException( - "JSON span missing a required v0.4 field " - + "(name, trace_id, span_id, start, duration): " - + span); - } - span.resolveLinks(); - decodedSpans.add(span); + List traces = new ArrayList<>(); + if (rawTraces != null) { + for (List spans : rawTraces) { + if (spans == null) { + throw new IllegalStateException("Malformed JSON trace with a null trace: " + json); + } + List decodedSpans = new ArrayList<>(spans.size()); + for (SpanJson span : spans) { + if (span == null) { + throw new IllegalStateException("Malformed JSON trace with a null span: " + json); + } + if (span.name == null + || span.traceId == null + || span.spanId == null + || span.start == null + || span.duration == null) { + throw new IllegalStateException("JSON span missing a required v0.4 field " + + "(name, trace_id, span_id, start, duration): " + + span); + } + span.resolveLinks(); + decodedSpans.add(span); + } + traces.add(new TraceJson(decodedSpans)); + } } - traces.add(new TraceJson(decodedSpans)); - } + return new MessageJson(traces); } - return new MessageJson(traces); - } - /** - * Decodes the numeric values of the {@code metrics} map. Moshi coerces every JSON number to - * {@code Double}; this adapter instead reads the number from its literal form and preserves the - * integral vs. fractional distinction the msgpack decoders produce (see {@code - * SpanV04.unpackNumber}): integral values become {@code Integer} (or {@code Long} when they - * overflow {@code int}, or {@code BigInteger} beyond {@code long} range), fractional values - * become {@code Double}. Reading the literal rather than coercing through {@code double} keeps - * every integral value exact — including those above 2^53 and above {@code Long.MAX_VALUE}. - */ - static final class MetricNumberAdapter { - @FromJson - Number fromJson(JsonReader reader) throws IOException { - String literal = reader.nextString(); - boolean fractional = - literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0; - if (!fractional) { - try { - return Integer.valueOf(literal); - } catch (NumberFormatException overflowsInt) { - try { - return Long.valueOf(literal); - } catch (NumberFormatException overflowsLong) { - // Integral magnitude beyond long range: keep it exact as a BigInteger. - return new BigInteger(literal); - } + /** + * Decodes the numeric values of the {@code metrics} map. Moshi coerces every JSON number to + * {@code Double}; this adapter instead reads the number from its literal form and preserves the + * integral vs. fractional distinction the msgpack decoders produce (see {@code + * SpanV04.unpackNumber}): integral values become {@code Integer} (or {@code Long} when they + * overflow {@code int}, or {@code BigInteger} beyond {@code long} range), fractional values + * become {@code Double}. Reading the literal rather than coercing through {@code double} keeps + * every integral value exact — including those above 2^53 and above {@code Long.MAX_VALUE}. + */ + static final class MetricNumberAdapter { + @FromJson + Number fromJson(JsonReader reader) throws IOException { + String literal = reader.nextString(); + boolean fractional = literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0; + if (!fractional) { + try { + return Integer.valueOf(literal); + } catch (NumberFormatException overflowsInt) { + try { + return Long.valueOf(literal); + } catch (NumberFormatException overflowsLong) { + // Integral magnitude beyond long range: keep it exact as a BigInteger. + return new BigInteger(literal); + } + } + } + return Double.valueOf(literal); } - } - return Double.valueOf(literal); - } - @ToJson - void toJson(JsonWriter writer, Number value) throws IOException { - writer.value(value); + @ToJson + void toJson(JsonWriter writer, Number value) throws IOException { + writer.value(value); + } } - } - /** - * Decodes the arbitrarily-nested values of the {@code meta_struct} map. Moshi's generic {@code - * Object} adapter materializes every JSON number as {@code Double}, every object as a map, and - * every array as a list; this adapter instead mirrors the msgpack decoder (see {@code - * SpanV04.convertValueToObject}) so both backends expose the same types for a nested leaf: - * - *

    - *
  • integers become {@code Long} (reading the literal keeps values above 2^53 exact, and - * values beyond {@code long} range become {@code BigInteger} rather than a lossy {@code - * Double}), - *
  • floating-point numbers become {@code Float}, - *
  • objects become {@code Map} and arrays {@code List}, recursively, - *
  • strings, booleans, and JSON {@code null} are preserved as {@code String}/{@code - * Boolean}/{@code null}. - * - * - *

    Keeping the representations identical means a {@code metaStruct(...)} assertion behaves the - * same whether the span came from the in-process msgpack backend or the dd-apm-test-agent JSON - * backend. Binary leaves ({@code byte[]} on the msgpack side) have no JSON representation — the - * agent fails to serialize them (HTTP 400) so they never reach this decoder; in practice {@code - * meta_struct} carries only JSON-serializable data. - */ - static final class MetaStructObjectAdapter { - @FromJson - Object fromJson(JsonReader reader) throws IOException { - switch (reader.peek()) { - case BEGIN_OBJECT: - Map object = new LinkedHashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - object.put(reader.nextName(), fromJson(reader)); - } - reader.endObject(); - return object; - case BEGIN_ARRAY: - List array = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - array.add(fromJson(reader)); - } - reader.endArray(); - return array; - case STRING: - return reader.nextString(); - case NUMBER: - return parseNumber(reader.nextString()); - case BOOLEAN: - return reader.nextBoolean(); - case NULL: - return reader.nextNull(); - default: - throw new JsonDataException("Unexpected meta_struct token: " + reader.peek()); - } - } + /** + * Decodes the arbitrarily-nested values of the {@code meta_struct} map. Moshi's generic {@code + * Object} adapter materializes every JSON number as {@code Double}, every object as a map, and + * every array as a list; this adapter instead mirrors the msgpack decoder (see {@code + * SpanV04.convertValueToObject}) so both backends expose the same types for a nested leaf: + * + *
      + *
    • integers become {@code Long} (reading the literal keeps values above 2^53 exact, and + * values beyond {@code long} range become {@code BigInteger} rather than a lossy {@code + * Double}), + *
    • floating-point numbers become {@code Float}, + *
    • objects become {@code Map} and arrays {@code List}, recursively, + *
    • strings, booleans, and JSON {@code null} are preserved as {@code String}/{@code + * Boolean}/{@code null}. + * + * + *

      Keeping the representations identical means a {@code metaStruct(...)} assertion behaves the + * same whether the span came from the in-process msgpack backend or the dd-apm-test-agent JSON + * backend. Binary leaves ({@code byte[]} on the msgpack side) have no JSON representation — the + * agent fails to serialize them (HTTP 400) so they never reach this decoder; in practice {@code + * meta_struct} carries only JSON-serializable data. + */ + static final class MetaStructObjectAdapter { + @FromJson + Object fromJson(JsonReader reader) throws IOException { + switch (reader.peek()) { + case BEGIN_OBJECT: + Map object = new LinkedHashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + object.put(reader.nextName(), fromJson(reader)); + } + reader.endObject(); + return object; + case BEGIN_ARRAY: + List array = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + array.add(fromJson(reader)); + } + reader.endArray(); + return array; + case STRING: + return reader.nextString(); + case NUMBER: + return parseNumber(reader.nextString()); + case BOOLEAN: + return reader.nextBoolean(); + case NULL: + return reader.nextNull(); + default: + throw new JsonDataException("Unexpected meta_struct token: " + reader.peek()); + } + } - private static Number parseNumber(String literal) { - boolean fractional = - literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0; - if (fractional) { - return Float.valueOf(literal); - } - try { - return Long.valueOf(literal); - } catch (NumberFormatException overflowsLong) { - // Integral magnitude beyond long range: keep it exact as a BigInteger. - return new BigInteger(literal); - } + private static Number parseNumber(String literal) { + boolean fractional = literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0; + if (fractional) { + return Float.valueOf(literal); + } + try { + return Long.valueOf(literal); + } catch (NumberFormatException overflowsLong) { + // Integral magnitude beyond long range: keep it exact as a BigInteger. + return new BigInteger(literal); + } + } } - } - @Override - public List getTraces() { - return this.traces; - } + @Override + public List getTraces() { + return this.traces; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java index 8c59fd6835b..8b20ed0c4e4 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java @@ -19,162 +19,162 @@ * start/duration/error, meta, metrics, and meta_struct. */ public final class SpanJson implements DecodedSpan { - String service; - String name; - String resource; - String type; - - // IDs are unsigned 64-bit; read as decimal strings and parsed with Long.parseUnsignedLong, since - // Moshi's long adapter rejects values above Long.MAX_VALUE (the agent emits them as JSON numbers, - // which Moshi coerces to their string form). - @Json(name = "trace_id") - String traceId; - - @Json(name = "span_id") - String spanId; - - @Json(name = "parent_id") - String parentId; - - // start and duration are required v0.4 fields; boxed so a missing value decodes to null (and is - // rejected by MessageJson) instead of a silent 0. error is optional per the agent's Span schema - // (absent => 0, i.e. no error), so it stays a primitive. - Long start; - Long duration; - int error; - Map meta; - - @Json(name = "meta_struct") - Map metaStruct; - - Map metrics; - - // Ony present in V1 payload. Decoded from meta by #resolveLinks otherwise - @Json(name = "span_links") - List spanLinks; - - // Decoded from span links meta tag or #spanLinks - // transient so Moshi does not try to decode it - transient List links; - - @Override - public String getService() { - return this.service; - } - - @Override - public String getName() { - return this.name; - } - - @Override - public String getResource() { - return this.resource; - } - - @Override - public long getTraceId() { - return this.traceId == null ? 0L : Long.parseUnsignedLong(this.traceId); - } - - @Override - public long getSpanId() { - return this.spanId == null ? 0L : Long.parseUnsignedLong(this.spanId); - } - - @Override - public long getParentId() { - return this.parentId == null ? 0L : Long.parseUnsignedLong(this.parentId); - } - - @Override - public long getStart() { - return this.start == null ? 0L : this.start; - } - - @Override - public long getDuration() { - return this.duration == null ? 0L : this.duration; - } - - @Override - public int getError() { - return this.error; - } - - @Override - public Map getMeta() { - return this.meta == null ? emptyMap() : unmodifiableMap(this.meta); - } - - @Override - public Map getMetaStruct() { - return this.metaStruct == null ? emptyMap() : unmodifiableMap(this.metaStruct); - } - - @Override - public Map getMetrics() { - return this.metrics == null ? emptyMap() : unmodifiableMap(this.metrics); - } - - @Override - public String getType() { - return this.type; - } - - @Override - public List getLinks() { - return this.links; - } - - /** - * Decodes the span links, from the {@code span_links} field when the agent filled it (the tracer - * submitted a {@code v1.0} payload) and from the meta tag otherwise. - */ - void resolveLinks() { - if (this.spanLinks == null) { - this.links = DecodedSpanLinks.fromMeta(getMeta()); - return; - } - List decoded = new ArrayList<>(this.spanLinks.size()); - for (SpanLinkJson spanLink : this.spanLinks) { - if (spanLink == null) { - throw new IllegalStateException("Malformed span_links with a null link: " + this); - } - decoded.add(spanLink.toDecodedSpanLink()); - } - this.links = unmodifiableList(decoded); - } - - @Override - public String toString() { - return "SpanJson{" - + "service='" - + this.service - + "', name='" - + this.name - + "', resource='" - + this.resource - + "', type='" - + this.type - + "', traceId=" - + this.traceId - + ", spanId=" - + this.spanId - + ", parentId=" - + this.parentId - + ", start=" - + this.start - + ", duration=" - + this.duration - + ", error=" - + this.error - + ", meta=" - + this.meta - + ", metaStruct=" - + this.metaStruct - + ", metrics=" - + this.metrics - + '}'; - } + String service; + String name; + String resource; + String type; + + // IDs are unsigned 64-bit; read as decimal strings and parsed with Long.parseUnsignedLong, since + // Moshi's long adapter rejects values above Long.MAX_VALUE (the agent emits them as JSON numbers, + // which Moshi coerces to their string form). + @Json(name = "trace_id") + String traceId; + + @Json(name = "span_id") + String spanId; + + @Json(name = "parent_id") + String parentId; + + // start and duration are required v0.4 fields; boxed so a missing value decodes to null (and is + // rejected by MessageJson) instead of a silent 0. error is optional per the agent's Span schema + // (absent => 0, i.e. no error), so it stays a primitive. + Long start; + Long duration; + int error; + Map meta; + + @Json(name = "meta_struct") + Map metaStruct; + + Map metrics; + + // Ony present in V1 payload. Decoded from meta by #resolveLinks otherwise + @Json(name = "span_links") + List spanLinks; + + // Decoded from span links meta tag or #spanLinks + // transient so Moshi does not try to decode it + transient List links; + + @Override + public String getService() { + return this.service; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getResource() { + return this.resource; + } + + @Override + public long getTraceId() { + return this.traceId == null ? 0L : Long.parseUnsignedLong(this.traceId); + } + + @Override + public long getSpanId() { + return this.spanId == null ? 0L : Long.parseUnsignedLong(this.spanId); + } + + @Override + public long getParentId() { + return this.parentId == null ? 0L : Long.parseUnsignedLong(this.parentId); + } + + @Override + public long getStart() { + return this.start == null ? 0L : this.start; + } + + @Override + public long getDuration() { + return this.duration == null ? 0L : this.duration; + } + + @Override + public int getError() { + return this.error; + } + + @Override + public Map getMeta() { + return this.meta == null ? emptyMap() : unmodifiableMap(this.meta); + } + + @Override + public Map getMetaStruct() { + return this.metaStruct == null ? emptyMap() : unmodifiableMap(this.metaStruct); + } + + @Override + public Map getMetrics() { + return this.metrics == null ? emptyMap() : unmodifiableMap(this.metrics); + } + + @Override + public String getType() { + return this.type; + } + + @Override + public List getLinks() { + return this.links; + } + + /** + * Decodes the span links, from the {@code span_links} field when the agent filled it (the tracer + * submitted a {@code v1.0} payload) and from the meta tag otherwise. + */ + void resolveLinks() { + if (this.spanLinks == null) { + this.links = DecodedSpanLinks.fromMeta(getMeta()); + return; + } + List decoded = new ArrayList<>(this.spanLinks.size()); + for (SpanLinkJson spanLink : this.spanLinks) { + if (spanLink == null) { + throw new IllegalStateException("Malformed span_links with a null link: " + this); + } + decoded.add(spanLink.toDecodedSpanLink()); + } + this.links = unmodifiableList(decoded); + } + + @Override + public String toString() { + return "SpanJson{" + + "service='" + + this.service + + "', name='" + + this.name + + "', resource='" + + this.resource + + "', type='" + + this.type + + "', traceId=" + + this.traceId + + ", spanId=" + + this.spanId + + ", parentId=" + + this.parentId + + ", start=" + + this.start + + ", duration=" + + this.duration + + ", error=" + + this.error + + ", meta=" + + this.meta + + ", metaStruct=" + + this.metaStruct + + ", metrics=" + + this.metrics + + '}'; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanLinkJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanLinkJson.java index 0c3b72ce60d..073110c6604 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanLinkJson.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanLinkJson.java @@ -15,58 +15,57 @@ * identifier is split into its low and high halves. */ final class SpanLinkJson { - // Identifiers are unsigned 64-bit, read as decimal strings and parsed with - // Long.parseUnsignedLong for the same reason as SpanJson: Moshi's long adapter rejects values - // above Long.MAX_VALUE. - @Json(name = "trace_id") - String traceId; + // Identifiers are unsigned 64-bit, read as decimal strings and parsed with + // Long.parseUnsignedLong for the same reason as SpanJson: Moshi's long adapter rejects values + // above Long.MAX_VALUE. + @Json(name = "trace_id") + String traceId; - // The high-order half of a 128-bit trace identifier. Not modeled by DecodedSpanLink, which - // narrows a trace identifier to its low-order half like the rest of the decoder. - @Json(name = "trace_id_high") - String traceIdHigh; + // The high-order half of a 128-bit trace identifier. Not modeled by DecodedSpanLink, which + // narrows a trace identifier to its low-order half like the rest of the decoder. + @Json(name = "trace_id_high") + String traceIdHigh; - @Json(name = "span_id") - String spanId; + @Json(name = "span_id") + String spanId; - Integer flags; + Integer flags; - String tracestate; + String tracestate; - Map attributes; + Map attributes; - DecodedSpanLink toDecodedSpanLink() { - if (this.traceId == null || this.spanId == null) { - throw new IllegalStateException( - "JSON span link missing a required field (trace_id, span_id): " + this); + DecodedSpanLink toDecodedSpanLink() { + if (this.traceId == null || this.spanId == null) { + throw new IllegalStateException("JSON span link missing a required field (trace_id, span_id): " + this); + } + try { + return DecodedSpanLinks.link( + Long.parseUnsignedLong(this.traceId), + Long.parseUnsignedLong(this.spanId), + this.flags == null ? 0 : (byte) this.flags.intValue(), + this.tracestate, + this.attributes); + } catch (NumberFormatException e) { + throw new IllegalStateException("JSON span link with a malformed identifier: " + this, e); + } } - try { - return DecodedSpanLinks.link( - Long.parseUnsignedLong(this.traceId), - Long.parseUnsignedLong(this.spanId), - this.flags == null ? 0 : (byte) this.flags.intValue(), - this.tracestate, - this.attributes); - } catch (NumberFormatException e) { - throw new IllegalStateException("JSON span link with a malformed identifier: " + this, e); - } - } - @Override - public String toString() { - return "SpanLinkJson{" - + "traceId=" - + this.traceId - + ", traceIdHigh=" - + this.traceIdHigh - + ", spanId=" - + this.spanId - + ", flags=" - + this.flags - + ", tracestate='" - + this.tracestate - + "', attributes=" - + this.attributes - + '}'; - } + @Override + public String toString() { + return "SpanLinkJson{" + + "traceId=" + + this.traceId + + ", traceIdHigh=" + + this.traceIdHigh + + ", spanId=" + + this.spanId + + ", flags=" + + this.flags + + ", tracestate='" + + this.tracestate + + "', attributes=" + + this.attributes + + '}'; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java index feaa991632d..3a01377be0a 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java @@ -8,19 +8,19 @@ /** TraceJson is a single trace (an ordered list of {@link SpanJson}) from the JSON trace format. */ public final class TraceJson implements DecodedTrace { - private final List spans; + private final List spans; - TraceJson(List spans) { - this.spans = unmodifiableList(spans); - } + TraceJson(List spans) { + this.spans = unmodifiableList(spans); + } - @Override - public List getSpans() { - return this.spans; - } + @Override + public List getSpans() { + return this.spans; + } - @Override - public String toString() { - return this.spans.toString(); - } + @Override + public String toString() { + return this.spans.toString(); + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/MessageV04.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/MessageV04.java index ffc7e6d8df4..127486dfd95 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/MessageV04.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/MessageV04.java @@ -10,38 +10,38 @@ import org.msgpack.core.MessageUnpacker; public class MessageV04 implements DecodedMessage { - public static MessageV04 unpack(ByteBuffer buffer) { - return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); - } + public static MessageV04 unpack(ByteBuffer buffer) { + return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); + } - public static MessageV04 unpack(byte[] buffer) { - return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); - } + public static MessageV04 unpack(byte[] buffer) { + return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); + } - static MessageV04 unpack(MessageUnpacker unpacker) { - try { - DecodedTrace[] traces = TraceV04.unpackTraces(unpacker); - return new MessageV04(traces); - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } + static MessageV04 unpack(MessageUnpacker unpacker) { + try { + DecodedTrace[] traces = TraceV04.unpackTraces(unpacker); + return new MessageV04(traces); + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - } - private final DecodedTrace[] traces; + private final DecodedTrace[] traces; - private MessageV04(DecodedTrace[] traces) { - this.traces = traces; - } + private MessageV04(DecodedTrace[] traces) { + this.traces = traces; + } - @Override - public List getTraces() { - if (traces.length == 0) { - return Collections.emptyList(); + @Override + public List getTraces() { + if (traces.length == 0) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(Arrays.asList(traces)); } - return Collections.unmodifiableList(Arrays.asList(traces)); - } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java index cbaeb2af1ed..186ffd8d1a7 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java @@ -18,360 +18,349 @@ import org.msgpack.value.ValueType; public class SpanV04 implements DecodedSpan { - static DecodedSpan[] unpackSpans(MessageUnpacker unpacker) { - try { - int size = unpacker.unpackArrayHeader(); - if (size < 0) { - throw new IllegalArgumentException("Negative span array size " + size); - } - DecodedSpan[] spans = new DecodedSpan[size]; - for (int i = 0; i < size; i++) { - spans[i] = unpack(unpacker); - } - return spans; - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } - } - } - - static SpanV04 unpack(MessageUnpacker unpacker) { - try { - int size = unpacker.unpackMapHeader(); - if (size != 12 && size != 13) { - throw new IllegalArgumentException( - "Wrong span element map size " - + size - + ". Expected 12 (plain) or 13 (with meta_struct)."); - } - - String service = unpackString("service", unpacker); - String name = unpackString("name", unpacker); - String resource = unpackString("resource", unpacker); - long traceId = unpackLong("trace_id", unpacker); - long spanId = unpackLong("span_id", unpacker); - long parentId = unpackLong("parent_id", unpacker); - long start = unpackLong("start", unpacker); - long duration = unpackLong("duration", unpacker); - String type = unpackString("type", unpacker); - int error = unpackInt("error", unpacker); - - unpackKey("metrics", unpacker); - Map metrics = unpackMetrics(unpacker, spanId); - - unpackKey("meta", unpacker); - Map meta = unpackMeta(unpacker, spanId); - - Map metaStruct = null; - if (size > 12) { - unpackKey("meta_struct", unpacker); - metaStruct = unpackMetaStruct(unpacker, spanId); - } - - List links = DecodedSpanLinks.fromMeta(meta); - - return new SpanV04( - service, - name, - resource, - traceId, - spanId, - parentId, - start, - duration, - error, - type, - metrics, - meta, - metaStruct, - links); - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } - } - } - - private static Map unpackMetrics(MessageUnpacker unpacker, long spanId) - throws IOException { - int metricsSize = unpacker.unpackMapHeader(); - if (metricsSize < 0) { - throw new IllegalArgumentException( - "Negative meta map size " + metricsSize + " for span " + spanId); + static DecodedSpan[] unpackSpans(MessageUnpacker unpacker) { + try { + int size = unpacker.unpackArrayHeader(); + if (size < 0) { + throw new IllegalArgumentException("Negative span array size " + size); + } + DecodedSpan[] spans = new DecodedSpan[size]; + for (int i = 0; i < size; i++) { + spans[i] = unpack(unpacker); + } + return spans; + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - Map metrics = new HashMap<>(metricsSize); - for (int i = 0; i < metricsSize; i++) { - metrics.put(unpacker.unpackString(), unpackNumber(unpacker)); + + static SpanV04 unpack(MessageUnpacker unpacker) { + try { + int size = unpacker.unpackMapHeader(); + if (size != 12 && size != 13) { + throw new IllegalArgumentException( + "Wrong span element map size " + size + ". Expected 12 (plain) or 13 (with meta_struct)."); + } + + String service = unpackString("service", unpacker); + String name = unpackString("name", unpacker); + String resource = unpackString("resource", unpacker); + long traceId = unpackLong("trace_id", unpacker); + long spanId = unpackLong("span_id", unpacker); + long parentId = unpackLong("parent_id", unpacker); + long start = unpackLong("start", unpacker); + long duration = unpackLong("duration", unpacker); + String type = unpackString("type", unpacker); + int error = unpackInt("error", unpacker); + + unpackKey("metrics", unpacker); + Map metrics = unpackMetrics(unpacker, spanId); + + unpackKey("meta", unpacker); + Map meta = unpackMeta(unpacker, spanId); + + Map metaStruct = null; + if (size > 12) { + unpackKey("meta_struct", unpacker); + metaStruct = unpackMetaStruct(unpacker, spanId); + } + + List links = DecodedSpanLinks.fromMeta(meta); + + return new SpanV04( + service, + name, + resource, + traceId, + spanId, + parentId, + start, + duration, + error, + type, + metrics, + meta, + metaStruct, + links); + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - return metrics; - } - - private static Map unpackMeta(MessageUnpacker unpacker, long spanId) - throws IOException { - int metaSize = unpacker.unpackMapHeader(); - if (metaSize < 0) { - throw new IllegalArgumentException( - "Negative meta map size " + metaSize + " for span " + spanId); + + private static Map unpackMetrics(MessageUnpacker unpacker, long spanId) throws IOException { + int metricsSize = unpacker.unpackMapHeader(); + if (metricsSize < 0) { + throw new IllegalArgumentException("Negative meta map size " + metricsSize + " for span " + spanId); + } + Map metrics = new HashMap<>(metricsSize); + for (int i = 0; i < metricsSize; i++) { + metrics.put(unpacker.unpackString(), unpackNumber(unpacker)); + } + return metrics; } - Map meta = new HashMap<>(metaSize); - for (int i = 0; i < metaSize; i++) { - meta.put(unpacker.unpackString(), unpacker.unpackString()); + + private static Map unpackMeta(MessageUnpacker unpacker, long spanId) throws IOException { + int metaSize = unpacker.unpackMapHeader(); + if (metaSize < 0) { + throw new IllegalArgumentException("Negative meta map size " + metaSize + " for span " + spanId); + } + Map meta = new HashMap<>(metaSize); + for (int i = 0; i < metaSize; i++) { + meta.put(unpacker.unpackString(), unpacker.unpackString()); + } + return meta; } - return meta; - } - - private static Map unpackMetaStruct(MessageUnpacker unpacker, long spanId) - throws IOException { - int metaSize = unpacker.unpackMapHeader(); - if (metaSize < 0) { - throw new IllegalArgumentException( - "Negative meta map size " + metaSize + " for span " + spanId); + + private static Map unpackMetaStruct(MessageUnpacker unpacker, long spanId) throws IOException { + int metaSize = unpacker.unpackMapHeader(); + if (metaSize < 0) { + throw new IllegalArgumentException("Negative meta map size " + metaSize + " for span " + spanId); + } + Map result = new HashMap<>(metaSize); + for (int i = 0; i < metaSize; i++) { + final String key = unpacker.unpackString(); + final int payloadSize = unpacker.unpackBinaryHeader(); + final byte[] payload = unpacker.readPayload(payloadSize); + result.put(key, decodeObject(payload)); + } + return result; } - Map result = new HashMap<>(metaSize); - for (int i = 0; i < metaSize; i++) { - final String key = unpacker.unpackString(); - final int payloadSize = unpacker.unpackBinaryHeader(); - final byte[] payload = unpacker.readPayload(payloadSize); - result.put(key, decodeObject(payload)); + + private static String unpackString(String expectedKey, MessageUnpacker unpacker) throws IOException { + unpackKey(expectedKey, unpacker); + if (unpacker.tryUnpackNil()) { + return null; + } + return unpacker.unpackString(); } - return result; - } - - private static String unpackString(String expectedKey, MessageUnpacker unpacker) - throws IOException { - unpackKey(expectedKey, unpacker); - if (unpacker.tryUnpackNil()) { - return null; + + private static long unpackLong(String expectedKey, MessageUnpacker unpacker) throws IOException { + unpackKey(expectedKey, unpacker); + return unpacker.unpackLong(); } - return unpacker.unpackString(); - } - - private static long unpackLong(String expectedKey, MessageUnpacker unpacker) throws IOException { - unpackKey(expectedKey, unpacker); - return unpacker.unpackLong(); - } - - private static int unpackInt(String expectedKey, MessageUnpacker unpacker) throws IOException { - unpackKey(expectedKey, unpacker); - return unpacker.unpackInt(); - } - - private static void unpackKey(String expectedKey, MessageUnpacker unpacker) throws IOException { - assert expectedKey.equals(unpacker.unpackString()); - } - - static Number unpackNumber(MessageUnpacker unpacker) { - Number result = null; - try { - ValueType valueType = unpacker.getNextFormat().getValueType(); - switch (valueType) { - case INTEGER: - try { - result = unpacker.unpackInt(); - } catch (MessageIntegerOverflowException e) { - result = unpacker.unpackLong(); - } - break; - case FLOAT: - result = unpacker.unpackDouble(); - break; - default: - throw new IllegalArgumentException( - "Failed to decode number. Unexpected value type " + valueType); - } - } catch (IOException e) { - throw new IllegalArgumentException("Failed to decode number.", e); + + private static int unpackInt(String expectedKey, MessageUnpacker unpacker) throws IOException { + unpackKey(expectedKey, unpacker); + return unpacker.unpackInt(); } - return result; - } - - private static Object decodeObject(final byte[] input) { - MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(input); - try { - final Value value = unpacker.unpackValue(); - return convertValueToObject(value); - } catch (IOException e) { - throw new IllegalArgumentException("Failed to decode object.", e); + + private static void unpackKey(String expectedKey, MessageUnpacker unpacker) throws IOException { + assert expectedKey.equals(unpacker.unpackString()); } - } - - private static Object convertValueToObject(final Value value) { - switch (value.getValueType()) { - case NIL: - return null; - case BOOLEAN: - return value.asBooleanValue().getBoolean(); - case INTEGER: - return value.asIntegerValue().toLong(); - case FLOAT: - return value.asFloatValue().toFloat(); - case BINARY: - return value.asBinaryValue().asByteArray(); - case STRING: - return value.asStringValue().asString(); - case ARRAY: - final List array = value.asArrayValue().list(); - final List result = new ArrayList<>(array.size()); - for (final Value element : array) { - result.add(convertValueToObject(element)); + + static Number unpackNumber(MessageUnpacker unpacker) { + Number result = null; + try { + ValueType valueType = unpacker.getNextFormat().getValueType(); + switch (valueType) { + case INTEGER: + try { + result = unpacker.unpackInt(); + } catch (MessageIntegerOverflowException e) { + result = unpacker.unpackLong(); + } + break; + case FLOAT: + result = unpacker.unpackDouble(); + break; + default: + throw new IllegalArgumentException("Failed to decode number. Unexpected value type " + valueType); + } + } catch (IOException e) { + throw new IllegalArgumentException("Failed to decode number.", e); } return result; - case MAP: - final Map map = value.asMapValue().map(); - final Map resultMap = new HashMap<>(map.size()); - for (final Map.Entry entry : map.entrySet()) { - resultMap.put( - entry.getKey().asStringValue().asString(), convertValueToObject(entry.getValue())); + } + + private static Object decodeObject(final byte[] input) { + MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(input); + try { + final Value value = unpacker.unpackValue(); + return convertValueToObject(value); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to decode object.", e); + } + } + + private static Object convertValueToObject(final Value value) { + switch (value.getValueType()) { + case NIL: + return null; + case BOOLEAN: + return value.asBooleanValue().getBoolean(); + case INTEGER: + return value.asIntegerValue().toLong(); + case FLOAT: + return value.asFloatValue().toFloat(); + case BINARY: + return value.asBinaryValue().asByteArray(); + case STRING: + return value.asStringValue().asString(); + case ARRAY: + final List array = value.asArrayValue().list(); + final List result = new ArrayList<>(array.size()); + for (final Value element : array) { + result.add(convertValueToObject(element)); + } + return result; + case MAP: + final Map map = value.asMapValue().map(); + final Map resultMap = new HashMap<>(map.size()); + for (final Map.Entry entry : map.entrySet()) { + resultMap.put(entry.getKey().asStringValue().asString(), convertValueToObject(entry.getValue())); + } + return resultMap; + default: + throw new IllegalArgumentException( + "Failed to convert value to object. Unexpected value type " + value.getValueType()); } - return resultMap; - default: - throw new IllegalArgumentException( - "Failed to convert value to object. Unexpected value type " + value.getValueType()); } - } - - private final String service; - private final String name; - private final String resource; - private final long traceId; - private final long spanId; - private final long parentId; - private final long start; - private final long duration; - private final int error; - private final Map meta; - private final Map metaStruct; - private final Map metrics; - private final String type; - private final List links; - - public SpanV04( - String service, - String name, - String resource, - long traceId, - long spanId, - long parentId, - long start, - long duration, - int error, - String type, - Map metrics, - Map meta, - Map metaStruct, - List links) { - this.service = service; - this.name = name; - this.resource = resource; - this.traceId = traceId; - this.spanId = spanId; - this.parentId = parentId; - this.start = start; - this.duration = duration; - this.error = error; - this.meta = unmodifiableMap(meta); - this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); - this.metrics = unmodifiableMap(metrics); - this.type = type; - this.links = links; - } - - public String getService() { - return service; - } - - public String getName() { - return name; - } - - public String getResource() { - return resource; - } - - public long getTraceId() { - return traceId; - } - - public long getSpanId() { - return spanId; - } - - public long getParentId() { - return parentId; - } - - public long getStart() { - return start; - } - - public long getDuration() { - return duration; - } - - public int getError() { - return error; - } - - public Map getMeta() { - return meta; - } - - public Map getMetaStruct() { - return metaStruct; - } - - public Map getMetrics() { - return metrics; - } - - public String getType() { - return type; - } - - @Override - public List getLinks() { - return links; - } - - @Override - public String toString() { - return "SpanV04{" - + "service='" - + service - + '\'' - + ", name='" - + name - + '\'' - + ", resource='" - + resource - + '\'' - + ", traceId=" - + traceId - + ", spanId=" - + spanId - + ", parentId=" - + parentId - + ", start=" - + start - + ", duration=" - + duration - + ", error=" - + error - + ", meta=" - + meta - + ", metaStruct=" - + metaStruct - + ", metrics=" - + metrics - + ", type='" - + type - + "', links=" - + links - + '}'; - } + + private final String service; + private final String name; + private final String resource; + private final long traceId; + private final long spanId; + private final long parentId; + private final long start; + private final long duration; + private final int error; + private final Map meta; + private final Map metaStruct; + private final Map metrics; + private final String type; + private final List links; + + public SpanV04( + String service, + String name, + String resource, + long traceId, + long spanId, + long parentId, + long start, + long duration, + int error, + String type, + Map metrics, + Map meta, + Map metaStruct, + List links) { + this.service = service; + this.name = name; + this.resource = resource; + this.traceId = traceId; + this.spanId = spanId; + this.parentId = parentId; + this.start = start; + this.duration = duration; + this.error = error; + this.meta = unmodifiableMap(meta); + this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); + this.metrics = unmodifiableMap(metrics); + this.type = type; + this.links = links; + } + + public String getService() { + return service; + } + + public String getName() { + return name; + } + + public String getResource() { + return resource; + } + + public long getTraceId() { + return traceId; + } + + public long getSpanId() { + return spanId; + } + + public long getParentId() { + return parentId; + } + + public long getStart() { + return start; + } + + public long getDuration() { + return duration; + } + + public int getError() { + return error; + } + + public Map getMeta() { + return meta; + } + + public Map getMetaStruct() { + return metaStruct; + } + + public Map getMetrics() { + return metrics; + } + + public String getType() { + return type; + } + + @Override + public List getLinks() { + return links; + } + + @Override + public String toString() { + return "SpanV04{" + + "service='" + + service + + '\'' + + ", name='" + + name + + '\'' + + ", resource='" + + resource + + '\'' + + ", traceId=" + + traceId + + ", spanId=" + + spanId + + ", parentId=" + + parentId + + ", start=" + + start + + ", duration=" + + duration + + ", error=" + + error + + ", meta=" + + meta + + ", metaStruct=" + + metaStruct + + ", metrics=" + + metrics + + ", type='" + + type + + "', links=" + + links + + '}'; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/TraceV04.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/TraceV04.java index 1eccc685c5c..0885e517b69 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/TraceV04.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/TraceV04.java @@ -8,41 +8,41 @@ import org.msgpack.core.MessageUnpacker; public class TraceV04 implements DecodedTrace { - public static DecodedTrace[] unpackTraces(MessageUnpacker unpacker) { - try { - int size = unpacker.unpackArrayHeader(); - if (size < 0) { - throw new IllegalArgumentException("Negative trace array size " + size); - } - DecodedTrace[] traces = new TraceV04[size]; - for (int i = 0; i < size; i++) { - traces[i] = unpack(unpacker); - } - return traces; - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } + public static DecodedTrace[] unpackTraces(MessageUnpacker unpacker) { + try { + int size = unpacker.unpackArrayHeader(); + if (size < 0) { + throw new IllegalArgumentException("Negative trace array size " + size); + } + DecodedTrace[] traces = new TraceV04[size]; + for (int i = 0; i < size; i++) { + traces[i] = unpack(unpacker); + } + return traces; + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - } - public static TraceV04 unpack(MessageUnpacker unpacker) { - return new TraceV04(SpanV04.unpackSpans(unpacker)); - } + public static TraceV04 unpack(MessageUnpacker unpacker) { + return new TraceV04(SpanV04.unpackSpans(unpacker)); + } - private final DecodedSpan[] spans; + private final DecodedSpan[] spans; - private TraceV04(DecodedSpan[] spans) { - this.spans = spans; - } + private TraceV04(DecodedSpan[] spans) { + this.spans = spans; + } - @Override - public List getSpans() { - if (spans.length == 0) { - return Collections.emptyList(); + @Override + public List getSpans() { + if (spans.length == 0) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(Arrays.asList(spans)); } - return Collections.unmodifiableList(Arrays.asList(spans)); - } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/DictionaryV05.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/DictionaryV05.java index 5e431c84166..ecf6b9eb62d 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/DictionaryV05.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/DictionaryV05.java @@ -3,33 +3,33 @@ import org.msgpack.core.MessageUnpacker; public class DictionaryV05 { - static DictionaryV05 unpack(MessageUnpacker unpacker) { - try { - int size = unpacker.unpackArrayHeader(); - if (size < 0) { - throw new IllegalArgumentException("Negative dictionary size " + size); - } - String[] strings = new String[size]; - for (int i = 0; i < size; i++) { - strings[i] = unpacker.unpackString(); - } - return new DictionaryV05(strings); - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } + static DictionaryV05 unpack(MessageUnpacker unpacker) { + try { + int size = unpacker.unpackArrayHeader(); + if (size < 0) { + throw new IllegalArgumentException("Negative dictionary size " + size); + } + String[] strings = new String[size]; + for (int i = 0; i < size; i++) { + strings[i] = unpacker.unpackString(); + } + return new DictionaryV05(strings); + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - } - private final String[] strings; + private final String[] strings; - public DictionaryV05(String[] strings) { - this.strings = strings; - } + public DictionaryV05(String[] strings) { + this.strings = strings; + } - public String at(int index) { - return strings[index]; - } + public String at(int index) { + return strings[index]; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/MessageV05.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/MessageV05.java index 77264136d39..b2b5cbdc1ec 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/MessageV05.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/MessageV05.java @@ -10,43 +10,43 @@ import org.msgpack.core.MessageUnpacker; public class MessageV05 implements DecodedMessage { - public static MessageV05 unpack(ByteBuffer buffer) { - return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); - } + public static MessageV05 unpack(ByteBuffer buffer) { + return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); + } - public static MessageV05 unpack(byte[] buffer) { - return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); - } + public static MessageV05 unpack(byte[] buffer) { + return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); + } - static MessageV05 unpack(MessageUnpacker unpacker) { - try { - int size = unpacker.unpackArrayHeader(); - if (size != 2) { - throw new IllegalArgumentException("MessageV05 array size " + size + " != 2"); - } - DictionaryV05 dictionary = DictionaryV05.unpack(unpacker); - DecodedTrace[] traces = TraceV05.unpackTraces(unpacker, dictionary); - return new MessageV05(traces); - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } + static MessageV05 unpack(MessageUnpacker unpacker) { + try { + int size = unpacker.unpackArrayHeader(); + if (size != 2) { + throw new IllegalArgumentException("MessageV05 array size " + size + " != 2"); + } + DictionaryV05 dictionary = DictionaryV05.unpack(unpacker); + DecodedTrace[] traces = TraceV05.unpackTraces(unpacker, dictionary); + return new MessageV05(traces); + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - } - private final DecodedTrace[] traces; + private final DecodedTrace[] traces; - private MessageV05(DecodedTrace[] traces) { - this.traces = traces; - } + private MessageV05(DecodedTrace[] traces) { + this.traces = traces; + } - @Override - public List getTraces() { - if (traces.length == 0) { - return Collections.emptyList(); + @Override + public List getTraces() { + if (traces.length == 0) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(Arrays.asList(traces)); } - return Collections.unmodifiableList(Arrays.asList(traces)); - } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java index 1a3c0b4333b..715dbd7f730 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java @@ -15,239 +15,234 @@ import org.msgpack.value.ValueType; public class SpanV05 implements DecodedSpan { - static DecodedSpan[] unpackSpans(MessageUnpacker unpacker, DictionaryV05 dictionary) { - try { - int size = unpacker.unpackArrayHeader(); - if (size < 0) { - throw new IllegalArgumentException("Negative span array size " + size); - } - DecodedSpan[] spans = new DecodedSpan[size]; - for (int i = 0; i < size; i++) { - spans[i] = unpack(unpacker, dictionary); - } - return spans; - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } - } - } - - static SpanV05 unpack(MessageUnpacker unpacker, DictionaryV05 dictionary) { - try { - int size = unpacker.unpackArrayHeader(); - if (size != 12) { - throw new IllegalArgumentException( - "Wrong span element array size " + size + ". Expected 12."); - } - String service = unpackString(unpacker, dictionary); // index into dictionary - String name = unpackString(unpacker, dictionary); // index into dictionary - String resource = unpackString(unpacker, dictionary); // index into dictionary - long traceId = unpacker.unpackLong(); - long spanId = unpacker.unpackLong(); - long parentId = unpacker.unpackLong(); - long start = unpacker.unpackLong(); - long duration = unpacker.unpackLong(); - int error = unpacker.unpackInt(); - int metaSize = unpacker.unpackMapHeader(); - if (metaSize < 0) { - throw new IllegalArgumentException( - "Negative meta map size " + metaSize + " for span " + spanId); - } - Map meta = new HashMap<>(metaSize); - for (int i = 0; i < metaSize; i++) { - meta.put(unpackString(unpacker, dictionary), unpackString(unpacker, dictionary)); - } - int metricsSize = unpacker.unpackMapHeader(); - if (metricsSize < 0) { - throw new IllegalArgumentException( - "Negative metrics map size " + metaSize + " for span " + spanId); - } - Map metrics = new HashMap<>(metricsSize); - for (int i = 0; i < metricsSize; i++) { - metrics.put(unpackString(unpacker, dictionary), unpackNumber(unpacker)); - } - String type = unpackString(unpacker, dictionary); // index into dictionary - - List links = DecodedSpanLinks.fromMeta(meta); - - return new SpanV05( - service, name, resource, traceId, spanId, parentId, start, duration, error, meta, metrics, - type, links); - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } - } - } - - static String unpackString(MessageUnpacker unpacker, DictionaryV05 dictionary) - throws IOException { - return dictionary.at(unpacker.unpackInt()); - } - - static Number unpackNumber(MessageUnpacker unpacker) { - Number result = null; - try { - ValueType valueType = unpacker.getNextFormat().getValueType(); - switch (valueType) { - case INTEGER: - try { - result = unpacker.unpackInt(); - } catch (MessageIntegerOverflowException e) { - result = unpacker.unpackLong(); - } - break; - case FLOAT: - result = unpacker.unpackDouble(); - break; - default: - throw new IllegalArgumentException( - "Failed to decode number. Unexpected value type " + valueType); - } - } catch (IOException e) { - throw new IllegalArgumentException("Failed to decode number.", e); - } - return result; - } - - private final String service; // index into dictionary - private final String name; // index into dictionary - private final String resource; // index into dictionary - private final long traceId; - private final long spanId; - private final long parentId; - private final long start; - private final long duration; - private final int error; - private final Map meta; // index -> index - private final Map metrics; // index -> metric - private final String type; // index into dictionary - private final List links; - - public SpanV05( - String service, - String name, - String resource, - long traceId, - long spanId, - long parentId, - long start, - long duration, - int error, - Map meta, - Map metrics, - String type, - List links) { - this.service = service; - this.name = name; - this.resource = resource; - this.traceId = traceId; - this.spanId = spanId; - this.parentId = parentId; - this.start = start; - this.duration = duration; - this.error = error; - this.meta = Collections.unmodifiableMap(meta); - this.metrics = Collections.unmodifiableMap(metrics); - this.type = type; - this.links = links; - } - - public String getService() { - return service; - } - - public String getName() { - return name; - } - - public String getResource() { - return resource; - } - - public long getTraceId() { - return traceId; - } - - public long getSpanId() { - return spanId; - } - - public long getParentId() { - return parentId; - } - - public long getStart() { - return start; - } - - public long getDuration() { - return duration; - } - - public int getError() { - return error; - } - - public Map getMeta() { - return meta; - } - - public Map getMetaStruct() { - // XXX: meta_struct is not supported in v0.5. - return emptyMap(); - } - - public Map getMetrics() { - return metrics; - } - - public String getType() { - return type; - } - - @Override - public List getLinks() { - return links; - } - - @Override - public String toString() { - return "SpanV05{" - + "service='" - + service - + '\'' - + ", name='" - + name - + '\'' - + ", resource='" - + resource - + '\'' - + ", traceId=" - + traceId - + ", spanId=" - + spanId - + ", parentId=" - + parentId - + ", start=" - + start - + ", duration=" - + duration - + ", error=" - + error - + ", meta=" - + meta - + ", metrics=" - + metrics - + ", type='" - + type - + "', links=" - + links - + '}'; - } + static DecodedSpan[] unpackSpans(MessageUnpacker unpacker, DictionaryV05 dictionary) { + try { + int size = unpacker.unpackArrayHeader(); + if (size < 0) { + throw new IllegalArgumentException("Negative span array size " + size); + } + DecodedSpan[] spans = new DecodedSpan[size]; + for (int i = 0; i < size; i++) { + spans[i] = unpack(unpacker, dictionary); + } + return spans; + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } + } + + static SpanV05 unpack(MessageUnpacker unpacker, DictionaryV05 dictionary) { + try { + int size = unpacker.unpackArrayHeader(); + if (size != 12) { + throw new IllegalArgumentException("Wrong span element array size " + size + ". Expected 12."); + } + String service = unpackString(unpacker, dictionary); // index into dictionary + String name = unpackString(unpacker, dictionary); // index into dictionary + String resource = unpackString(unpacker, dictionary); // index into dictionary + long traceId = unpacker.unpackLong(); + long spanId = unpacker.unpackLong(); + long parentId = unpacker.unpackLong(); + long start = unpacker.unpackLong(); + long duration = unpacker.unpackLong(); + int error = unpacker.unpackInt(); + int metaSize = unpacker.unpackMapHeader(); + if (metaSize < 0) { + throw new IllegalArgumentException("Negative meta map size " + metaSize + " for span " + spanId); + } + Map meta = new HashMap<>(metaSize); + for (int i = 0; i < metaSize; i++) { + meta.put(unpackString(unpacker, dictionary), unpackString(unpacker, dictionary)); + } + int metricsSize = unpacker.unpackMapHeader(); + if (metricsSize < 0) { + throw new IllegalArgumentException("Negative metrics map size " + metaSize + " for span " + spanId); + } + Map metrics = new HashMap<>(metricsSize); + for (int i = 0; i < metricsSize; i++) { + metrics.put(unpackString(unpacker, dictionary), unpackNumber(unpacker)); + } + String type = unpackString(unpacker, dictionary); // index into dictionary + + List links = DecodedSpanLinks.fromMeta(meta); + + return new SpanV05( + service, name, resource, traceId, spanId, parentId, start, duration, error, meta, metrics, type, + links); + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } + } + + static String unpackString(MessageUnpacker unpacker, DictionaryV05 dictionary) throws IOException { + return dictionary.at(unpacker.unpackInt()); + } + + static Number unpackNumber(MessageUnpacker unpacker) { + Number result = null; + try { + ValueType valueType = unpacker.getNextFormat().getValueType(); + switch (valueType) { + case INTEGER: + try { + result = unpacker.unpackInt(); + } catch (MessageIntegerOverflowException e) { + result = unpacker.unpackLong(); + } + break; + case FLOAT: + result = unpacker.unpackDouble(); + break; + default: + throw new IllegalArgumentException("Failed to decode number. Unexpected value type " + valueType); + } + } catch (IOException e) { + throw new IllegalArgumentException("Failed to decode number.", e); + } + return result; + } + + private final String service; // index into dictionary + private final String name; // index into dictionary + private final String resource; // index into dictionary + private final long traceId; + private final long spanId; + private final long parentId; + private final long start; + private final long duration; + private final int error; + private final Map meta; // index -> index + private final Map metrics; // index -> metric + private final String type; // index into dictionary + private final List links; + + public SpanV05( + String service, + String name, + String resource, + long traceId, + long spanId, + long parentId, + long start, + long duration, + int error, + Map meta, + Map metrics, + String type, + List links) { + this.service = service; + this.name = name; + this.resource = resource; + this.traceId = traceId; + this.spanId = spanId; + this.parentId = parentId; + this.start = start; + this.duration = duration; + this.error = error; + this.meta = Collections.unmodifiableMap(meta); + this.metrics = Collections.unmodifiableMap(metrics); + this.type = type; + this.links = links; + } + + public String getService() { + return service; + } + + public String getName() { + return name; + } + + public String getResource() { + return resource; + } + + public long getTraceId() { + return traceId; + } + + public long getSpanId() { + return spanId; + } + + public long getParentId() { + return parentId; + } + + public long getStart() { + return start; + } + + public long getDuration() { + return duration; + } + + public int getError() { + return error; + } + + public Map getMeta() { + return meta; + } + + public Map getMetaStruct() { + // XXX: meta_struct is not supported in v0.5. + return emptyMap(); + } + + public Map getMetrics() { + return metrics; + } + + public String getType() { + return type; + } + + @Override + public List getLinks() { + return links; + } + + @Override + public String toString() { + return "SpanV05{" + + "service='" + + service + + '\'' + + ", name='" + + name + + '\'' + + ", resource='" + + resource + + '\'' + + ", traceId=" + + traceId + + ", spanId=" + + spanId + + ", parentId=" + + parentId + + ", start=" + + start + + ", duration=" + + duration + + ", error=" + + error + + ", meta=" + + meta + + ", metrics=" + + metrics + + ", type='" + + type + + "', links=" + + links + + '}'; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/TraceV05.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/TraceV05.java index eb8f26e08bb..828896ecd26 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/TraceV05.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/TraceV05.java @@ -8,41 +8,41 @@ import org.msgpack.core.MessageUnpacker; public class TraceV05 implements DecodedTrace { - public static DecodedTrace[] unpackTraces(MessageUnpacker unpacker, DictionaryV05 dictionary) { - try { - int size = unpacker.unpackArrayHeader(); - if (size < 0) { - throw new IllegalArgumentException("Negative trace array size " + size); - } - DecodedTrace[] traces = new TraceV05[size]; - for (int i = 0; i < size; i++) { - traces[i] = unpack(unpacker, dictionary); - } - return traces; - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } + public static DecodedTrace[] unpackTraces(MessageUnpacker unpacker, DictionaryV05 dictionary) { + try { + int size = unpacker.unpackArrayHeader(); + if (size < 0) { + throw new IllegalArgumentException("Negative trace array size " + size); + } + DecodedTrace[] traces = new TraceV05[size]; + for (int i = 0; i < size; i++) { + traces[i] = unpack(unpacker, dictionary); + } + return traces; + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - } - public static TraceV05 unpack(MessageUnpacker unpacker, DictionaryV05 dictionary) { - return new TraceV05(SpanV05.unpackSpans(unpacker, dictionary)); - } + public static TraceV05 unpack(MessageUnpacker unpacker, DictionaryV05 dictionary) { + return new TraceV05(SpanV05.unpackSpans(unpacker, dictionary)); + } - private final DecodedSpan[] spans; + private final DecodedSpan[] spans; - private TraceV05(DecodedSpan[] spans) { - this.spans = spans; - } + private TraceV05(DecodedSpan[] spans) { + this.spans = spans; + } - @Override - public List getSpans() { - if (spans.length == 0) { - return Collections.emptyList(); + @Override + public List getSpans() { + if (spans.length == 0) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(Arrays.asList(spans)); } - return Collections.unmodifiableList(Arrays.asList(spans)); - } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/MessageV1.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/MessageV1.java index 6bde9aa851d..339ddc1efda 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/MessageV1.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/MessageV1.java @@ -14,98 +14,97 @@ /** MessageV1 decodes V1.0 trace payload format. */ public class MessageV1 implements DecodedMessage { - // Tracer Payload field IDs - static final int FIELD_CHUNKS = 11; + // Tracer Payload field IDs + static final int FIELD_CHUNKS = 11; - public static MessageV1 unpack(ByteBuffer buffer) { - return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); - } + public static MessageV1 unpack(ByteBuffer buffer) { + return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); + } + + public static MessageV1 unpack(byte[] buffer) { + return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); + } - public static MessageV1 unpack(byte[] buffer) { - return unpack(MessagePack.DEFAULT_UNPACKER_CONFIG.newUnpacker(buffer)); - } + static MessageV1 unpack(MessageUnpacker unpacker) { + try { + // Initialize string table for streaming string decoding + // Index 0 is reserved for empty string + List stringTable = new ArrayList<>(); + stringTable.add(""); - static MessageV1 unpack(MessageUnpacker unpacker) { - try { - // Initialize string table for streaming string decoding - // Index 0 is reserved for empty string - List stringTable = new ArrayList<>(); - stringTable.add(""); + // Check what format we have + if (!unpacker.hasNext()) { + return new MessageV1(new DecodedTrace[0]); + } - // Check what format we have - if (!unpacker.hasNext()) { - return new MessageV1(new DecodedTrace[0]); - } + ValueType firstType = unpacker.getNextFormat().getValueType(); + List traces = new ArrayList<>(); - ValueType firstType = unpacker.getNextFormat().getValueType(); - List traces = new ArrayList<>(); + if (firstType == ValueType.MAP) { + // Full payload format: map with FIELD_CHUNKS containing trace chunks. + int mapSize = unpacker.unpackMapHeader(); - if (firstType == ValueType.MAP) { - // Full payload format: map with FIELD_CHUNKS containing trace chunks. - int mapSize = unpacker.unpackMapHeader(); + for (int i = 0; i < mapSize; i++) { + int fieldId = unpacker.unpackInt(); + if (fieldId == FIELD_CHUNKS) { + // This is an array of traces + DecodedTrace[] traceArray = TraceV1.unpackTraces(unpacker, stringTable); + traces.addAll(Arrays.asList(traceArray)); + } else { + // Keep string table aligned while skipping known header fields. + skipPayloadField(unpacker, fieldId, stringTable); + } + } + } else { + throw new IllegalArgumentException("Expected Map at start of V1.0 payload, got: " + firstType); + } - for (int i = 0; i < mapSize; i++) { - int fieldId = unpacker.unpackInt(); - if (fieldId == FIELD_CHUNKS) { - // This is an array of traces - DecodedTrace[] traceArray = TraceV1.unpackTraces(unpacker, stringTable); - traces.addAll(Arrays.asList(traceArray)); - } else { - // Keep string table aligned while skipping known header fields. - skipPayloadField(unpacker, fieldId, stringTable); - } + return new MessageV1(traces.toArray(new DecodedTrace[0])); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to decode V1.0 message", e); + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } } - } else { - throw new IllegalArgumentException( - "Expected Map at start of V1.0 payload, got: " + firstType); - } - - return new MessageV1(traces.toArray(new DecodedTrace[0])); - } catch (IOException e) { - throw new IllegalArgumentException("Failed to decode V1.0 message", e); - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } } - } - private final DecodedTrace[] traces; + private final DecodedTrace[] traces; - private static void skipPayloadField( - MessageUnpacker unpacker, int fieldId, List stringTable) throws IOException { - switch (fieldId) { - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - case 8: - case 9: - SpanV1.unpackStreamingString(unpacker, stringTable); - break; - case 10: - // Header-level attributes: same triplet encoding as span attributes. - SpanV1.skipAttributes(unpacker, stringTable); - break; - default: - unpacker.skipValue(); - break; + private static void skipPayloadField(MessageUnpacker unpacker, int fieldId, List stringTable) + throws IOException { + switch (fieldId) { + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + SpanV1.unpackStreamingString(unpacker, stringTable); + break; + case 10: + // Header-level attributes: same triplet encoding as span attributes. + SpanV1.skipAttributes(unpacker, stringTable); + break; + default: + unpacker.skipValue(); + break; + } } - } - private MessageV1(DecodedTrace[] traces) { - this.traces = traces; - } + private MessageV1(DecodedTrace[] traces) { + this.traces = traces; + } - @Override - public List getTraces() { - if (traces.length == 0) { - return Collections.emptyList(); + @Override + public List getTraces() { + if (traces.length == 0) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(Arrays.asList(traces)); } - return Collections.unmodifiableList(Arrays.asList(traces)); - } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java index c4ec1e4b6b9..3b8e11d1d78 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java @@ -34,741 +34,727 @@ * */ public class SpanV1 implements DecodedSpan { - // Span field IDs (from TraceMapperV1) - static final int SPAN_FIELD_SERVICE = 1; - static final int SPAN_FIELD_NAME = 2; - static final int SPAN_FIELD_RESOURCE = 3; - static final int SPAN_FIELD_SPAN_ID = 4; - static final int SPAN_FIELD_PARENT_ID = 5; - static final int SPAN_FIELD_START = 6; - static final int SPAN_FIELD_DURATION = 7; - static final int SPAN_FIELD_ERROR = 8; - static final int SPAN_FIELD_ATTRIBUTES = 9; - static final int SPAN_FIELD_TYPE = 10; - static final int SPAN_FIELD_SPAN_LINKS = 11; - static final int SPAN_FIELD_SPAN_EVENTS = 12; - static final int SPAN_FIELD_ENV = 13; - static final int SPAN_FIELD_VERSION = 14; - static final int SPAN_FIELD_COMPONENT = 15; - static final int SPAN_FIELD_SPAN_KIND = 16; - - // Span link field IDs (from TraceMapperV1.encodeSpanLinks) - static final int LINK_FIELD_TRACE_ID = 1; - static final int LINK_FIELD_SPAN_ID = 2; - static final int LINK_FIELD_ATTRIBUTES = 3; - static final int LINK_FIELD_TRACE_STATE = 4; - static final int LINK_FIELD_TRACE_FLAGS = 5; - - // Attribute value types - static final int STRING_VALUE_TYPE = 1; - static final int BOOL_VALUE_TYPE = 2; - static final int FLOAT_VALUE_TYPE = 3; - static final int INT_VALUE_TYPE = 4; - static final int BYTES_VALUE_TYPE = 5; - static final int ARRAY_VALUE_TYPE = 6; - static final int KEY_VALUE_LIST_TYPE = 7; - - /** - * Unpacks an array of spans from the unpacker. - * - * @param unpacker the message unpacker - * @param stringTable the shared string table for streaming string decoding - * @return array of decoded spans - */ - static DecodedSpan[] unpackSpans(MessageUnpacker unpacker, List stringTable) { - return unpackSpans(unpacker, stringTable, 0); - } - - static DecodedSpan[] unpackSpans( - MessageUnpacker unpacker, List stringTable, long traceId) { - try { - int size = unpacker.unpackArrayHeader(); - if (size < 0) { - throw new IllegalArgumentException("Negative span array size " + size); - } - DecodedSpan[] spans = new DecodedSpan[size]; - for (int i = 0; i < size; i++) { - spans[i] = unpack(unpacker, stringTable, traceId); - } - return spans; - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } - } - } - - /** - * Unpacks a single span from the unpacker. - * - * @param unpacker the message unpacker - * @param stringTable the shared string table for streaming string decoding - * @return the decoded span - */ - static SpanV1 unpack(MessageUnpacker unpacker, List stringTable) { - return unpack(unpacker, stringTable, 0); - } - - static SpanV1 unpack(MessageUnpacker unpacker, List stringTable, long traceId) { - try { - int mapSize = unpacker.unpackMapHeader(); - - String service = ""; - String name = ""; - String resource = ""; - long spanId = 0; - long parentId = 0; - long start = 0; - long duration = 0; - int error = 0; - String type = ""; - String env = ""; - String version = ""; - String component = ""; - int spanKind = 0; - Map meta = new HashMap<>(); - Map metrics = new HashMap<>(); - Map metaStruct = new HashMap<>(); - List links = emptyList(); - - for (int i = 0; i < mapSize; i++) { - int fieldId = unpacker.unpackInt(); - - switch (fieldId) { - case SPAN_FIELD_SERVICE: - service = unpackStreamingString(unpacker, stringTable); - break; - case SPAN_FIELD_NAME: - name = unpackStreamingString(unpacker, stringTable); - break; - case SPAN_FIELD_RESOURCE: - resource = unpackStreamingString(unpacker, stringTable); - break; - case SPAN_FIELD_SPAN_ID: - spanId = unpackUnsignedLong(unpacker); - break; - case SPAN_FIELD_PARENT_ID: - parentId = unpackUnsignedLong(unpacker); - break; - case SPAN_FIELD_START: - start = unpacker.unpackLong(); - break; - case SPAN_FIELD_DURATION: - duration = unpacker.unpackLong(); - break; - case SPAN_FIELD_ERROR: - // V1.0 uses boolean for error - error = unpacker.unpackBoolean() ? 1 : 0; - break; - case SPAN_FIELD_ATTRIBUTES: - unpackAttributes(unpacker, stringTable, meta, metrics, metaStruct); - break; - case SPAN_FIELD_TYPE: - type = unpackStreamingString(unpacker, stringTable); - break; - case SPAN_FIELD_SPAN_LINKS: - links = unpackSpanLinks(unpacker, stringTable); - break; - case SPAN_FIELD_SPAN_EVENTS: - // TODO Span events are not decoded yet. This blind skip also consumes any streaming - // string the events introduced without registering it, desynchronizing the string - // table for the rest of the payload (see unpackSpanLinks for the table-aware pattern). - unpacker.skipValue(); - break; - case SPAN_FIELD_ENV: - env = unpackStreamingString(unpacker, stringTable); - break; - case SPAN_FIELD_VERSION: - version = unpackStreamingString(unpacker, stringTable); - break; - case SPAN_FIELD_COMPONENT: - component = unpackStreamingString(unpacker, stringTable); - break; - case SPAN_FIELD_SPAN_KIND: - spanKind = unpacker.unpackInt(); - break; - default: - // Skip unknown fields - unpacker.skipValue(); - break; + // Span field IDs (from TraceMapperV1) + static final int SPAN_FIELD_SERVICE = 1; + static final int SPAN_FIELD_NAME = 2; + static final int SPAN_FIELD_RESOURCE = 3; + static final int SPAN_FIELD_SPAN_ID = 4; + static final int SPAN_FIELD_PARENT_ID = 5; + static final int SPAN_FIELD_START = 6; + static final int SPAN_FIELD_DURATION = 7; + static final int SPAN_FIELD_ERROR = 8; + static final int SPAN_FIELD_ATTRIBUTES = 9; + static final int SPAN_FIELD_TYPE = 10; + static final int SPAN_FIELD_SPAN_LINKS = 11; + static final int SPAN_FIELD_SPAN_EVENTS = 12; + static final int SPAN_FIELD_ENV = 13; + static final int SPAN_FIELD_VERSION = 14; + static final int SPAN_FIELD_COMPONENT = 15; + static final int SPAN_FIELD_SPAN_KIND = 16; + + // Span link field IDs (from TraceMapperV1.encodeSpanLinks) + static final int LINK_FIELD_TRACE_ID = 1; + static final int LINK_FIELD_SPAN_ID = 2; + static final int LINK_FIELD_ATTRIBUTES = 3; + static final int LINK_FIELD_TRACE_STATE = 4; + static final int LINK_FIELD_TRACE_FLAGS = 5; + + // Attribute value types + static final int STRING_VALUE_TYPE = 1; + static final int BOOL_VALUE_TYPE = 2; + static final int FLOAT_VALUE_TYPE = 3; + static final int INT_VALUE_TYPE = 4; + static final int BYTES_VALUE_TYPE = 5; + static final int ARRAY_VALUE_TYPE = 6; + static final int KEY_VALUE_LIST_TYPE = 7; + + /** + * Unpacks an array of spans from the unpacker. + * + * @param unpacker the message unpacker + * @param stringTable the shared string table for streaming string decoding + * @return array of decoded spans + */ + static DecodedSpan[] unpackSpans(MessageUnpacker unpacker, List stringTable) { + return unpackSpans(unpacker, stringTable, 0); + } + + static DecodedSpan[] unpackSpans(MessageUnpacker unpacker, List stringTable, long traceId) { + try { + int size = unpacker.unpackArrayHeader(); + if (size < 0) { + throw new IllegalArgumentException("Negative span array size " + size); + } + DecodedSpan[] spans = new DecodedSpan[size]; + for (int i = 0; i < size; i++) { + spans[i] = unpack(unpacker, stringTable, traceId); + } + return spans; + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } + } + + /** + * Unpacks a single span from the unpacker. + * + * @param unpacker the message unpacker + * @param stringTable the shared string table for streaming string decoding + * @return the decoded span + */ + static SpanV1 unpack(MessageUnpacker unpacker, List stringTable) { + return unpack(unpacker, stringTable, 0); + } + + static SpanV1 unpack(MessageUnpacker unpacker, List stringTable, long traceId) { + try { + int mapSize = unpacker.unpackMapHeader(); + + String service = ""; + String name = ""; + String resource = ""; + long spanId = 0; + long parentId = 0; + long start = 0; + long duration = 0; + int error = 0; + String type = ""; + String env = ""; + String version = ""; + String component = ""; + int spanKind = 0; + Map meta = new HashMap<>(); + Map metrics = new HashMap<>(); + Map metaStruct = new HashMap<>(); + List links = emptyList(); + + for (int i = 0; i < mapSize; i++) { + int fieldId = unpacker.unpackInt(); + + switch (fieldId) { + case SPAN_FIELD_SERVICE: + service = unpackStreamingString(unpacker, stringTable); + break; + case SPAN_FIELD_NAME: + name = unpackStreamingString(unpacker, stringTable); + break; + case SPAN_FIELD_RESOURCE: + resource = unpackStreamingString(unpacker, stringTable); + break; + case SPAN_FIELD_SPAN_ID: + spanId = unpackUnsignedLong(unpacker); + break; + case SPAN_FIELD_PARENT_ID: + parentId = unpackUnsignedLong(unpacker); + break; + case SPAN_FIELD_START: + start = unpacker.unpackLong(); + break; + case SPAN_FIELD_DURATION: + duration = unpacker.unpackLong(); + break; + case SPAN_FIELD_ERROR: + // V1.0 uses boolean for error + error = unpacker.unpackBoolean() ? 1 : 0; + break; + case SPAN_FIELD_ATTRIBUTES: + unpackAttributes(unpacker, stringTable, meta, metrics, metaStruct); + break; + case SPAN_FIELD_TYPE: + type = unpackStreamingString(unpacker, stringTable); + break; + case SPAN_FIELD_SPAN_LINKS: + links = unpackSpanLinks(unpacker, stringTable); + break; + case SPAN_FIELD_SPAN_EVENTS: + // TODO Span events are not decoded yet. This blind skip also consumes any streaming + // string the events introduced without registering it, desynchronizing the string + // table for the rest of the payload (see unpackSpanLinks for the table-aware pattern). + unpacker.skipValue(); + break; + case SPAN_FIELD_ENV: + env = unpackStreamingString(unpacker, stringTable); + break; + case SPAN_FIELD_VERSION: + version = unpackStreamingString(unpacker, stringTable); + break; + case SPAN_FIELD_COMPONENT: + component = unpackStreamingString(unpacker, stringTable); + break; + case SPAN_FIELD_SPAN_KIND: + spanKind = unpacker.unpackInt(); + break; + default: + // Skip unknown fields + unpacker.skipValue(); + break; + } + } + + // Add promoted fields to meta if non-empty + if (env != null && !env.isEmpty()) { + meta.put("env", env); + } + if (version != null && !version.isEmpty()) { + meta.put("version", version); + } + if (component != null && !component.isEmpty()) { + meta.put("component", component); + } + if (spanKind > 0) { + meta.put("span.kind", getSpanKindString(spanKind)); + } + + return new SpanV1( + service, + name, + resource, + traceId, + spanId, + parentId, + start, + duration, + error, + type, + metrics, + meta, + metaStruct.isEmpty() ? null : metaStruct, + links); + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } } - } - - // Add promoted fields to meta if non-empty - if (env != null && !env.isEmpty()) { - meta.put("env", env); - } - if (version != null && !version.isEmpty()) { - meta.put("version", version); - } - if (component != null && !component.isEmpty()) { - meta.put("component", component); - } - if (spanKind > 0) { - meta.put("span.kind", getSpanKindString(spanKind)); - } - - return new SpanV1( - service, - name, - resource, - traceId, - spanId, - parentId, - start, - duration, - error, - type, - metrics, - meta, - metaStruct.isEmpty() ? null : metaStruct, - links); - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } - } - } - - /** - * Unpacks a streaming string value. - * - *

      In V1.0 format, strings use streaming encoding: - * - *

        - *
      • First occurrence: actual string is written and added to table - *
      • Subsequent occurrences: integer index is written - *
      • Index 0 is reserved for empty string - *
      - * - * @param unpacker the message unpacker - * @param stringTable the string table to use/update - * @return the decoded string - * @throws IOException if unpacking fails - */ - static String unpackStreamingString(MessageUnpacker unpacker, List stringTable) - throws IOException { - ValueType valueType = unpacker.getNextFormat().getValueType(); - if (valueType == ValueType.INTEGER) { - // Reference to existing string in table - int index = unpacker.unpackInt(); - if (index < 0 || index >= stringTable.size()) { - throw new IllegalArgumentException( - "Invalid string table index: " + index + ", table size: " + stringTable.size()); - } - return stringTable.get(index); - } else if (valueType == ValueType.STRING) { - // New string, add to table - String str = unpacker.unpackString(); - stringTable.add(str); - return str; - } else { - throw new IllegalArgumentException( - "Expected string or integer for streaming string, got: " + valueType); - } - } - - /** - * Unpacks attributes array into meta and metrics maps. - * - *

      Attributes are encoded as a flat array of triplets: (key, type, value) - * - * @param unpacker the message unpacker - * @param stringTable the string table - * @param meta output map for string attributes - * @param metrics output map for numeric attributes - * @throws IOException if unpacking fails - */ - private static void unpackAttributes( - MessageUnpacker unpacker, - List stringTable, - Map meta, - Map metrics, - Map metaStruct) - throws IOException { - int arraySize = unpacker.unpackArrayHeader(); - // Array contains triplets (key, type, value), so size must be divisible by 3 - if (arraySize % 3 != 0) { - throw new IllegalArgumentException( - "Attributes array size must be divisible by 3, got: " + arraySize); - } - - int tripletCount = arraySize / 3; - for (int i = 0; i < tripletCount; i++) { - // Key is a streaming string - String key = unpackStreamingString(unpacker, stringTable); - // Type is an integer - int valueType = unpacker.unpackInt(); - // Value depends on type - switch (valueType) { - case STRING_VALUE_TYPE: - String strValue = unpackStreamingString(unpacker, stringTable); - meta.put(key, strValue); - break; - case BOOL_VALUE_TYPE: - boolean boolValue = unpacker.unpackBoolean(); - // Store booleans as strings in meta for compatibility - meta.put(key, String.valueOf(boolValue)); - break; - case FLOAT_VALUE_TYPE: - double floatValue = unpacker.unpackDouble(); - metrics.put(key, normalizeMetricValue(key, floatValue)); - break; - case INT_VALUE_TYPE: - long intValue = unpacker.unpackLong(); - metrics.put(key, intValue); - break; - case BYTES_VALUE_TYPE: - int payloadSize = unpacker.unpackBinaryHeader(); - byte[] payload = unpacker.readPayload(payloadSize); - metaStruct.put(key, decodeObject(payload)); - break; - case ARRAY_VALUE_TYPE: - // Skip array values - unpacker.skipValue(); - break; - case KEY_VALUE_LIST_TYPE: - // Skip key-value list - unpacker.skipValue(); - break; - default: - throw new IllegalArgumentException("Unknown attribute value type: " + valueType); - } - } - } - - /** - * Unpacks the structured span links of field {@value #SPAN_FIELD_SPAN_LINKS}, as {@code - * TraceMapperV1.encodeSpanLinks} writes them. - * - *

      Link attributes and trace states are streaming strings, so they must be read through the - * table-aware helpers: skipping them blindly would consume the strings without registering them - * and desynchronize every later string index in the payload. - * - * @param unpacker The message unpacker. - * @param stringTable The shared string table for streaming string decoding. - * @return The decoded links. - * @throws IOException If unpacking fails. - */ - static List unpackSpanLinks(MessageUnpacker unpacker, List stringTable) - throws IOException { - int linkCount = unpacker.unpackArrayHeader(); - if (linkCount == 0) { - return emptyList(); - } - List links = new ArrayList<>(linkCount); - for (int i = 0; i < linkCount; i++) { - int fieldCount = unpacker.unpackMapHeader(); - long traceId = 0; - long spanId = 0; - byte traceFlags = 0; - String traceState = ""; - Map attributes = new HashMap<>(); - for (int field = 0; field < fieldCount; field++) { - switch (unpacker.unpackInt()) { - case LINK_FIELD_TRACE_ID: - traceId = unpackTraceId(unpacker); - break; - case LINK_FIELD_SPAN_ID: - spanId = unpackUnsignedLong(unpacker); - break; - case LINK_FIELD_ATTRIBUTES: - unpackLinkAttributes(unpacker, stringTable, attributes); - break; - case LINK_FIELD_TRACE_STATE: - traceState = unpackStreamingString(unpacker, stringTable); - break; - case LINK_FIELD_TRACE_FLAGS: - traceFlags = (byte) unpacker.unpackInt(); - break; - default: - // Unknown link field; numeric or binary, so it cannot hold a streaming string. - unpacker.skipValue(); - break; + } + + /** + * Unpacks a streaming string value. + * + *

      In V1.0 format, strings use streaming encoding: + * + *

        + *
      • First occurrence: actual string is written and added to table + *
      • Subsequent occurrences: integer index is written + *
      • Index 0 is reserved for empty string + *
      + * + * @param unpacker the message unpacker + * @param stringTable the string table to use/update + * @return the decoded string + * @throws IOException if unpacking fails + */ + static String unpackStreamingString(MessageUnpacker unpacker, List stringTable) throws IOException { + ValueType valueType = unpacker.getNextFormat().getValueType(); + if (valueType == ValueType.INTEGER) { + // Reference to existing string in table + int index = unpacker.unpackInt(); + if (index < 0 || index >= stringTable.size()) { + throw new IllegalArgumentException( + "Invalid string table index: " + index + ", table size: " + stringTable.size()); + } + return stringTable.get(index); + } else if (valueType == ValueType.STRING) { + // New string, add to table + String str = unpacker.unpackString(); + stringTable.add(str); + return str; + } else { + throw new IllegalArgumentException("Expected string or integer for streaming string, got: " + valueType); } - } - links.add(DecodedSpanLinks.link(traceId, spanId, traceFlags, traceState, attributes)); - } - return unmodifiableList(links); - } - - /** - * Unpacks an identifier the writer emitted as an unsigned 64-bit value. - * - *

      {@code TraceMapperV1} writes span and parent identifiers with {@code writeUnsignedLong}, so - * any identifier with its high bit set arrives as a msgpack {@code UINT64}. {@link - * MessageUnpacker#unpackLong()} rejects those, so read them through a {@link - * java.math.BigInteger} and keep the low-order 64 bits, which is the same two's-complement value - * the writer had. - * - * @param unpacker The message unpacker. - * @return The identifier. - * @throws IOException If unpacking fails. - */ - private static long unpackUnsignedLong(MessageUnpacker unpacker) throws IOException { - if (unpacker.getNextFormat() == MessageFormat.UINT64) { - return unpacker.unpackBigInteger().longValue(); - } - return unpacker.unpackLong(); - } - - /** - * Unpacks the low-order 64 bits of a 16-byte big-endian trace identifier, narrowing it the same - * way {@code TraceV1} narrows a chunk's trace identifier. - */ - private static long unpackTraceId(MessageUnpacker unpacker) throws IOException { - int payloadSize = unpacker.unpackBinaryHeader(); - byte[] payload = unpacker.readPayload(payloadSize); - long id = 0; - for (int i = Math.max(0, payloadSize - Long.BYTES); i < payloadSize; i++) { - id = (id << 8) | (payload[i] & 0xffL); - } - return id; - } - - /** Collects link attributes as strings, keeping the string table in sync. */ - private static void unpackLinkAttributes( - MessageUnpacker unpacker, List stringTable, Map attributes) - throws IOException { - forEachAttribute( - unpacker, - stringTable, - (attributeUnpacker, table, key, valueType) -> { - switch (valueType) { - case STRING_VALUE_TYPE: - attributes.put(key, unpackStreamingString(attributeUnpacker, table)); - break; - case BOOL_VALUE_TYPE: - attributes.put(key, String.valueOf(attributeUnpacker.unpackBoolean())); - break; - case FLOAT_VALUE_TYPE: - attributes.put(key, String.valueOf(attributeUnpacker.unpackDouble())); - break; - case INT_VALUE_TYPE: - attributes.put(key, String.valueOf(attributeUnpacker.unpackLong())); - break; - default: - attributeUnpacker.skipValue(); - break; - } + } + + /** + * Unpacks attributes array into meta and metrics maps. + * + *

      Attributes are encoded as a flat array of triplets: (key, type, value) + * + * @param unpacker the message unpacker + * @param stringTable the string table + * @param meta output map for string attributes + * @param metrics output map for numeric attributes + * @throws IOException if unpacking fails + */ + private static void unpackAttributes( + MessageUnpacker unpacker, + List stringTable, + Map meta, + Map metrics, + Map metaStruct) + throws IOException { + int arraySize = unpacker.unpackArrayHeader(); + // Array contains triplets (key, type, value), so size must be divisible by 3 + if (arraySize % 3 != 0) { + throw new IllegalArgumentException("Attributes array size must be divisible by 3, got: " + arraySize); + } + + int tripletCount = arraySize / 3; + for (int i = 0; i < tripletCount; i++) { + // Key is a streaming string + String key = unpackStreamingString(unpacker, stringTable); + // Type is an integer + int valueType = unpacker.unpackInt(); + // Value depends on type + switch (valueType) { + case STRING_VALUE_TYPE: + String strValue = unpackStreamingString(unpacker, stringTable); + meta.put(key, strValue); + break; + case BOOL_VALUE_TYPE: + boolean boolValue = unpacker.unpackBoolean(); + // Store booleans as strings in meta for compatibility + meta.put(key, String.valueOf(boolValue)); + break; + case FLOAT_VALUE_TYPE: + double floatValue = unpacker.unpackDouble(); + metrics.put(key, normalizeMetricValue(key, floatValue)); + break; + case INT_VALUE_TYPE: + long intValue = unpacker.unpackLong(); + metrics.put(key, intValue); + break; + case BYTES_VALUE_TYPE: + int payloadSize = unpacker.unpackBinaryHeader(); + byte[] payload = unpacker.readPayload(payloadSize); + metaStruct.put(key, decodeObject(payload)); + break; + case ARRAY_VALUE_TYPE: + // Skip array values + unpacker.skipValue(); + break; + case KEY_VALUE_LIST_TYPE: + // Skip key-value list + unpacker.skipValue(); + break; + default: + throw new IllegalArgumentException("Unknown attribute value type: " + valueType); + } + } + } + + /** + * Unpacks the structured span links of field {@value #SPAN_FIELD_SPAN_LINKS}, as {@code + * TraceMapperV1.encodeSpanLinks} writes them. + * + *

      Link attributes and trace states are streaming strings, so they must be read through the + * table-aware helpers: skipping them blindly would consume the strings without registering them + * and desynchronize every later string index in the payload. + * + * @param unpacker The message unpacker. + * @param stringTable The shared string table for streaming string decoding. + * @return The decoded links. + * @throws IOException If unpacking fails. + */ + static List unpackSpanLinks(MessageUnpacker unpacker, List stringTable) + throws IOException { + int linkCount = unpacker.unpackArrayHeader(); + if (linkCount == 0) { + return emptyList(); + } + List links = new ArrayList<>(linkCount); + for (int i = 0; i < linkCount; i++) { + int fieldCount = unpacker.unpackMapHeader(); + long traceId = 0; + long spanId = 0; + byte traceFlags = 0; + String traceState = ""; + Map attributes = new HashMap<>(); + for (int field = 0; field < fieldCount; field++) { + switch (unpacker.unpackInt()) { + case LINK_FIELD_TRACE_ID: + traceId = unpackTraceId(unpacker); + break; + case LINK_FIELD_SPAN_ID: + spanId = unpackUnsignedLong(unpacker); + break; + case LINK_FIELD_ATTRIBUTES: + unpackLinkAttributes(unpacker, stringTable, attributes); + break; + case LINK_FIELD_TRACE_STATE: + traceState = unpackStreamingString(unpacker, stringTable); + break; + case LINK_FIELD_TRACE_FLAGS: + traceFlags = (byte) unpacker.unpackInt(); + break; + default: + // Unknown link field; numeric or binary, so it cannot hold a streaming string. + unpacker.skipValue(); + break; + } + } + links.add(DecodedSpanLinks.link(traceId, spanId, traceFlags, traceState, attributes)); + } + return unmodifiableList(links); + } + + /** + * Unpacks an identifier the writer emitted as an unsigned 64-bit value. + * + *

      {@code TraceMapperV1} writes span and parent identifiers with {@code writeUnsignedLong}, so + * any identifier with its high bit set arrives as a msgpack {@code UINT64}. {@link + * MessageUnpacker#unpackLong()} rejects those, so read them through a {@link + * java.math.BigInteger} and keep the low-order 64 bits, which is the same two's-complement value + * the writer had. + * + * @param unpacker The message unpacker. + * @return The identifier. + * @throws IOException If unpacking fails. + */ + private static long unpackUnsignedLong(MessageUnpacker unpacker) throws IOException { + if (unpacker.getNextFormat() == MessageFormat.UINT64) { + return unpacker.unpackBigInteger().longValue(); + } + return unpacker.unpackLong(); + } + + /** + * Unpacks the low-order 64 bits of a 16-byte big-endian trace identifier, narrowing it the same + * way {@code TraceV1} narrows a chunk's trace identifier. + */ + private static long unpackTraceId(MessageUnpacker unpacker) throws IOException { + int payloadSize = unpacker.unpackBinaryHeader(); + byte[] payload = unpacker.readPayload(payloadSize); + long id = 0; + for (int i = Math.max(0, payloadSize - Long.BYTES); i < payloadSize; i++) { + id = (id << 8) | (payload[i] & 0xffL); + } + return id; + } + + /** Collects link attributes as strings, keeping the string table in sync. */ + private static void unpackLinkAttributes( + MessageUnpacker unpacker, List stringTable, Map attributes) throws IOException { + forEachAttribute(unpacker, stringTable, (attributeUnpacker, table, key, valueType) -> { + switch (valueType) { + case STRING_VALUE_TYPE: + attributes.put(key, unpackStreamingString(attributeUnpacker, table)); + break; + case BOOL_VALUE_TYPE: + attributes.put(key, String.valueOf(attributeUnpacker.unpackBoolean())); + break; + case FLOAT_VALUE_TYPE: + attributes.put(key, String.valueOf(attributeUnpacker.unpackDouble())); + break; + case INT_VALUE_TYPE: + attributes.put(key, String.valueOf(attributeUnpacker.unpackLong())); + break; + default: + attributeUnpacker.skipValue(); + break; + } }); - } + } + + static void skipAttributes(MessageUnpacker unpacker, List stringTable) throws IOException { + forEachAttribute(unpacker, stringTable, AttributeSkipper.INSTANCE); + } - static void skipAttributes(MessageUnpacker unpacker, List stringTable) - throws IOException { - forEachAttribute(unpacker, stringTable, AttributeSkipper.INSTANCE); - } + private static void forEachAttribute(MessageUnpacker unpacker, List stringTable, AttributeConsumer consumer) + throws IOException { + int arraySize = unpacker.unpackArrayHeader(); + // Array contains triplets (key, type, value), so size must be divisible by 3 + if (arraySize % 3 != 0) { + throw new IllegalArgumentException("Attributes array size must be divisible by 3, got: " + arraySize); + } - private static void forEachAttribute( - MessageUnpacker unpacker, List stringTable, AttributeConsumer consumer) - throws IOException { - int arraySize = unpacker.unpackArrayHeader(); - // Array contains triplets (key, type, value), so size must be divisible by 3 - if (arraySize % 3 != 0) { - throw new IllegalArgumentException( - "Attributes array size must be divisible by 3, got: " + arraySize); + int tripletCount = arraySize / 3; + for (int i = 0; i < tripletCount; i++) { + String key = unpackStreamingString(unpacker, stringTable); + int valueType = unpacker.unpackInt(); + consumer.accept(unpacker, stringTable, key, valueType); + } } - int tripletCount = arraySize / 3; - for (int i = 0; i < tripletCount; i++) { - String key = unpackStreamingString(unpacker, stringTable); - int valueType = unpacker.unpackInt(); - consumer.accept(unpacker, stringTable, key, valueType); + private interface AttributeConsumer { + void accept(MessageUnpacker unpacker, List stringTable, String key, int valueType) throws IOException; } - } - private interface AttributeConsumer { - void accept(MessageUnpacker unpacker, List stringTable, String key, int valueType) - throws IOException; - } + private enum AttributeSkipper implements AttributeConsumer { + INSTANCE; + + @Override + public void accept(MessageUnpacker unpacker, List stringTable, String key, int valueType) + throws IOException { + switch (valueType) { + case STRING_VALUE_TYPE: + unpackStreamingString(unpacker, stringTable); + break; + case BOOL_VALUE_TYPE: + unpacker.unpackBoolean(); + break; + case FLOAT_VALUE_TYPE: + unpacker.unpackDouble(); + break; + default: + unpacker.skipValue(); + break; + } + } + } - private enum AttributeSkipper implements AttributeConsumer { - INSTANCE; + /** + * Converts span kind integer to string representation. + * + * @param spanKind the OTEL span kind value + * @return the string representation + */ + private static String getSpanKindString(int spanKind) { + switch (spanKind) { + case 1: + return "internal"; + case 2: + return "server"; + case 3: + return "client"; + case 4: + return "producer"; + case 5: + return "consumer"; + default: + return "internal"; + } + } - @Override - public void accept( - MessageUnpacker unpacker, List stringTable, String key, int valueType) - throws IOException { - switch (valueType) { - case STRING_VALUE_TYPE: - unpackStreamingString(unpacker, stringTable); - break; - case BOOL_VALUE_TYPE: - unpacker.unpackBoolean(); - break; - case FLOAT_VALUE_TYPE: - unpacker.unpackDouble(); - break; - default: - unpacker.skipValue(); - break; - } - } - } - - /** - * Converts span kind integer to string representation. - * - * @param spanKind the OTEL span kind value - * @return the string representation - */ - private static String getSpanKindString(int spanKind) { - switch (spanKind) { - case 1: - return "internal"; - case 2: - return "server"; - case 3: - return "client"; - case 4: - return "producer"; - case 5: - return "consumer"; - default: - return "internal"; - } - } - - private static Number normalizeMetricValue(String key, double value) { - if (("_dd.measured".equals(key) || "_dd.top_level".equals(key)) && value % 1 == 0) { - long whole = (long) value; - if (whole >= Integer.MIN_VALUE && whole <= Integer.MAX_VALUE) { - return (int) whole; - } - return whole; - } - return value; - } - - private static Object decodeObject(final byte[] input) { - MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(input); - try { - final Value value = unpacker.unpackValue(); - return convertValueToObject(value); - } catch (IOException e) { - throw new IllegalArgumentException("Failed to decode object.", e); - } - } - - private static Object convertValueToObject(final Value value) { - switch (value.getValueType()) { - case NIL: - return null; - case BOOLEAN: - return value.asBooleanValue().getBoolean(); - case INTEGER: - return value.asIntegerValue().toLong(); - case FLOAT: - return value.asFloatValue().toFloat(); - case BINARY: - return value.asBinaryValue().asByteArray(); - case STRING: - return value.asStringValue().asString(); - case ARRAY: - final List array = value.asArrayValue().list(); - final List result = new ArrayList<>(array.size()); - for (final Value element : array) { - result.add(convertValueToObject(element)); + private static Number normalizeMetricValue(String key, double value) { + if (("_dd.measured".equals(key) || "_dd.top_level".equals(key)) && value % 1 == 0) { + long whole = (long) value; + if (whole >= Integer.MIN_VALUE && whole <= Integer.MAX_VALUE) { + return (int) whole; + } + return whole; } - return result; - case MAP: - final Map map = value.asMapValue().map(); - final Map resultMap = new HashMap<>(map.size()); - for (final Map.Entry entry : map.entrySet()) { - resultMap.put( - entry.getKey().asStringValue().asString(), convertValueToObject(entry.getValue())); + return value; + } + + private static Object decodeObject(final byte[] input) { + MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(input); + try { + final Value value = unpacker.unpackValue(); + return convertValueToObject(value); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to decode object.", e); } - return resultMap; - default: - throw new IllegalArgumentException( - "Failed to convert value to object. Unexpected value type " + value.getValueType()); - } - } - - private final String service; - private final String name; - private final String resource; - private final long traceId; - private final long spanId; - private final long parentId; - private final long start; - private final long duration; - private final int error; - private final Map meta; - private final Map metaStruct; - private final Map metrics; - private final String type; - private final List links; - - public SpanV1( - String service, - String name, - String resource, - long traceId, - long spanId, - long parentId, - long start, - long duration, - int error, - String type, - Map metrics, - Map meta, - Map metaStruct) { - this( - service, - name, - resource, - traceId, - spanId, - parentId, - start, - duration, - error, - type, - metrics, - meta, - metaStruct, - emptyList()); - } - - public SpanV1( - String service, - String name, - String resource, - long traceId, - long spanId, - long parentId, - long start, - long duration, - int error, - String type, - Map metrics, - Map meta, - Map metaStruct, - List links) { - this.links = links == null ? emptyList() : links; - this.service = service; - this.name = name; - this.resource = resource; - this.traceId = traceId; - this.spanId = spanId; - this.parentId = parentId; - this.start = start; - this.duration = duration; - this.error = error; - this.meta = unmodifiableMap(meta); - this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); - this.metrics = unmodifiableMap(metrics); - this.type = type; - } - - @Override - public List getLinks() { - return this.links; - } - - @Override - public String getService() { - return service; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getResource() { - return resource; - } - - @Override - public long getTraceId() { - return traceId; - } - - @Override - public long getSpanId() { - return spanId; - } - - @Override - public long getParentId() { - return parentId; - } - - @Override - public long getStart() { - return start; - } - - @Override - public long getDuration() { - return duration; - } - - @Override - public int getError() { - return error; - } - - @Override - public Map getMeta() { - return meta; - } - - @Override - public Map getMetaStruct() { - return metaStruct; - } - - @Override - public Map getMetrics() { - return metrics; - } - - @Override - public String getType() { - return type; - } - - @Override - public String toString() { - return "SpanV1{" - + "service='" - + service - + '\'' - + ", name='" - + name - + '\'' - + ", resource='" - + resource - + '\'' - + ", traceId=" - + traceId - + ", spanId=" - + spanId - + ", parentId=" - + parentId - + ", start=" - + start - + ", duration=" - + duration - + ", error=" - + error - + ", meta=" - + meta - + ", metaStruct=" - + metaStruct - + ", metrics=" - + metrics - + ", type='" - + type - + '\'' - + '}'; - } + } + + private static Object convertValueToObject(final Value value) { + switch (value.getValueType()) { + case NIL: + return null; + case BOOLEAN: + return value.asBooleanValue().getBoolean(); + case INTEGER: + return value.asIntegerValue().toLong(); + case FLOAT: + return value.asFloatValue().toFloat(); + case BINARY: + return value.asBinaryValue().asByteArray(); + case STRING: + return value.asStringValue().asString(); + case ARRAY: + final List array = value.asArrayValue().list(); + final List result = new ArrayList<>(array.size()); + for (final Value element : array) { + result.add(convertValueToObject(element)); + } + return result; + case MAP: + final Map map = value.asMapValue().map(); + final Map resultMap = new HashMap<>(map.size()); + for (final Map.Entry entry : map.entrySet()) { + resultMap.put(entry.getKey().asStringValue().asString(), convertValueToObject(entry.getValue())); + } + return resultMap; + default: + throw new IllegalArgumentException( + "Failed to convert value to object. Unexpected value type " + value.getValueType()); + } + } + + private final String service; + private final String name; + private final String resource; + private final long traceId; + private final long spanId; + private final long parentId; + private final long start; + private final long duration; + private final int error; + private final Map meta; + private final Map metaStruct; + private final Map metrics; + private final String type; + private final List links; + + public SpanV1( + String service, + String name, + String resource, + long traceId, + long spanId, + long parentId, + long start, + long duration, + int error, + String type, + Map metrics, + Map meta, + Map metaStruct) { + this( + service, + name, + resource, + traceId, + spanId, + parentId, + start, + duration, + error, + type, + metrics, + meta, + metaStruct, + emptyList()); + } + + public SpanV1( + String service, + String name, + String resource, + long traceId, + long spanId, + long parentId, + long start, + long duration, + int error, + String type, + Map metrics, + Map meta, + Map metaStruct, + List links) { + this.links = links == null ? emptyList() : links; + this.service = service; + this.name = name; + this.resource = resource; + this.traceId = traceId; + this.spanId = spanId; + this.parentId = parentId; + this.start = start; + this.duration = duration; + this.error = error; + this.meta = unmodifiableMap(meta); + this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); + this.metrics = unmodifiableMap(metrics); + this.type = type; + } + + @Override + public List getLinks() { + return this.links; + } + + @Override + public String getService() { + return service; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getResource() { + return resource; + } + + @Override + public long getTraceId() { + return traceId; + } + + @Override + public long getSpanId() { + return spanId; + } + + @Override + public long getParentId() { + return parentId; + } + + @Override + public long getStart() { + return start; + } + + @Override + public long getDuration() { + return duration; + } + + @Override + public int getError() { + return error; + } + + @Override + public Map getMeta() { + return meta; + } + + @Override + public Map getMetaStruct() { + return metaStruct; + } + + @Override + public Map getMetrics() { + return metrics; + } + + @Override + public String getType() { + return type; + } + + @Override + public String toString() { + return "SpanV1{" + + "service='" + + service + + '\'' + + ", name='" + + name + + '\'' + + ", resource='" + + resource + + '\'' + + ", traceId=" + + traceId + + ", spanId=" + + spanId + + ", parentId=" + + parentId + + ", start=" + + start + + ", duration=" + + duration + + ", error=" + + error + + ", meta=" + + meta + + ", metaStruct=" + + metaStruct + + ", metrics=" + + metrics + + ", type='" + + type + + '\'' + + '}'; + } } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java index 5c23f0e1fb2..4cd10ccaa6b 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/TraceV1.java @@ -18,136 +18,131 @@ */ public class TraceV1 implements DecodedTrace { - /** - * Unpacks an array of traces from the unpacker. - * - * @param unpacker the message unpacker - * @param stringTable the shared string table for streaming string decoding - * @return array of decoded traces - */ - public static DecodedTrace[] unpackTraces(MessageUnpacker unpacker, List stringTable) { - try { - int size = unpacker.unpackArrayHeader(); - if (size < 0) { - throw new IllegalArgumentException("Negative trace array size " + size); - } - DecodedTrace[] traces = new TraceV1[size]; - for (int i = 0; i < size; i++) { - traces[i] = unpackChunk(unpacker, stringTable); - } - return traces; - } catch (Throwable t) { - if (t instanceof RuntimeException) { - throw (RuntimeException) t; - } else { - throw new IllegalArgumentException(t); - } + /** + * Unpacks an array of traces from the unpacker. + * + * @param unpacker the message unpacker + * @param stringTable the shared string table for streaming string decoding + * @return array of decoded traces + */ + public static DecodedTrace[] unpackTraces(MessageUnpacker unpacker, List stringTable) { + try { + int size = unpacker.unpackArrayHeader(); + if (size < 0) { + throw new IllegalArgumentException("Negative trace array size " + size); + } + DecodedTrace[] traces = new TraceV1[size]; + for (int i = 0; i < size; i++) { + traces[i] = unpackChunk(unpacker, stringTable); + } + return traces; + } catch (Throwable t) { + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } else { + throw new IllegalArgumentException(t); + } + } } - } - private static TraceV1 unpackChunk(MessageUnpacker unpacker, List stringTable) - throws IOException { - int fieldCount = unpacker.unpackMapHeader(); - DecodedSpan[] spans = new DecodedSpan[0]; - Integer samplingPriority = null; - long traceId = 0; + private static TraceV1 unpackChunk(MessageUnpacker unpacker, List stringTable) throws IOException { + int fieldCount = unpacker.unpackMapHeader(); + DecodedSpan[] spans = new DecodedSpan[0]; + Integer samplingPriority = null; + long traceId = 0; - for (int i = 0; i < fieldCount; i++) { - int fieldId = unpacker.unpackInt(); - switch (fieldId) { - case 1: - // chunk sampling priority - samplingPriority = unpacker.unpackInt(); - break; - case 2: - // origin (streaming string) - SpanV1.unpackStreamingString(unpacker, stringTable); - break; - case 3: - // chunk attributes (triplets array) - SpanV1.skipAttributes(unpacker, stringTable); - break; - case 4: - spans = SpanV1.unpackSpans(unpacker, stringTable, traceId); - break; - case 6: - traceId = unpackTraceId(unpacker); - break; - default: - // numeric or binary fields that don't affect the string table. - unpacker.skipValue(); - break; - } - } - - return new TraceV1(withChunkFields(spans, traceId, samplingPriority), samplingPriority); - } + for (int i = 0; i < fieldCount; i++) { + int fieldId = unpacker.unpackInt(); + switch (fieldId) { + case 1: + // chunk sampling priority + samplingPriority = unpacker.unpackInt(); + break; + case 2: + // origin (streaming string) + SpanV1.unpackStreamingString(unpacker, stringTable); + break; + case 3: + // chunk attributes (triplets array) + SpanV1.skipAttributes(unpacker, stringTable); + break; + case 4: + spans = SpanV1.unpackSpans(unpacker, stringTable, traceId); + break; + case 6: + traceId = unpackTraceId(unpacker); + break; + default: + // numeric or binary fields that don't affect the string table. + unpacker.skipValue(); + break; + } + } - private static long unpackTraceId(MessageUnpacker unpacker) throws IOException { - int payloadSize = unpacker.unpackBinaryHeader(); - byte[] payload = unpacker.readPayload(payloadSize); - if (payloadSize < Long.BYTES) { - return 0; - } - long id = 0; - for (int i = payloadSize - Long.BYTES; i < payloadSize; i++) { - id = (id << 8) | (payload[i] & 0xffL); + return new TraceV1(withChunkFields(spans, traceId, samplingPriority), samplingPriority); } - return id; - } - private static DecodedSpan[] withChunkFields( - DecodedSpan[] spans, long traceId, Integer samplingPriority) { - if (spans.length == 0) { - return spans; + private static long unpackTraceId(MessageUnpacker unpacker) throws IOException { + int payloadSize = unpacker.unpackBinaryHeader(); + byte[] payload = unpacker.readPayload(payloadSize); + if (payloadSize < Long.BYTES) { + return 0; + } + long id = 0; + for (int i = payloadSize - Long.BYTES; i < payloadSize; i++) { + id = (id << 8) | (payload[i] & 0xffL); + } + return id; } - DecodedSpan[] updated = new DecodedSpan[spans.length]; - for (int i = 0; i < spans.length; i++) { - DecodedSpan span = spans[i]; - final Map metrics = new HashMap<>(span.getMetrics()); - if (samplingPriority != null - && span.getParentId() == 0 - && !metrics.containsKey("_sampling_priority_v1")) { - metrics.put("_sampling_priority_v1", samplingPriority); - } - updated[i] = - new SpanV1( - span.getService(), - span.getName(), - span.getResource(), - traceId == 0 ? span.getTraceId() : traceId, - span.getSpanId(), - span.getParentId(), - span.getStart(), - span.getDuration(), - span.getError(), - span.getType(), - metrics, - span.getMeta(), - span.getMetaStruct(), - span.getLinks()); + + private static DecodedSpan[] withChunkFields(DecodedSpan[] spans, long traceId, Integer samplingPriority) { + if (spans.length == 0) { + return spans; + } + DecodedSpan[] updated = new DecodedSpan[spans.length]; + for (int i = 0; i < spans.length; i++) { + DecodedSpan span = spans[i]; + final Map metrics = new HashMap<>(span.getMetrics()); + if (samplingPriority != null && span.getParentId() == 0 && !metrics.containsKey("_sampling_priority_v1")) { + metrics.put("_sampling_priority_v1", samplingPriority); + } + updated[i] = new SpanV1( + span.getService(), + span.getName(), + span.getResource(), + traceId == 0 ? span.getTraceId() : traceId, + span.getSpanId(), + span.getParentId(), + span.getStart(), + span.getDuration(), + span.getError(), + span.getType(), + metrics, + span.getMeta(), + span.getMetaStruct(), + span.getLinks()); + } + return updated; } - return updated; - } - private final DecodedSpan[] spans; - private final Integer chunkSamplingPriority; + private final DecodedSpan[] spans; + private final Integer chunkSamplingPriority; - private TraceV1(DecodedSpan[] spans, Integer chunkSamplingPriority) { - this.spans = spans; - this.chunkSamplingPriority = chunkSamplingPriority; - } + private TraceV1(DecodedSpan[] spans, Integer chunkSamplingPriority) { + this.spans = spans; + this.chunkSamplingPriority = chunkSamplingPriority; + } - @Override - public List getSpans() { - if (spans.length == 0) { - return Collections.emptyList(); + @Override + public List getSpans() { + if (spans.length == 0) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(Arrays.asList(spans)); } - return Collections.unmodifiableList(Arrays.asList(spans)); - } - @Override - public Integer getSamplingPriority() { - return chunkSamplingPriority; - } + @Override + public Integer getSamplingPriority() { + return chunkSamplingPriority; + } } diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/DecoderTest.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/DecoderTest.java index 4acea005e87..0b8abb19f20 100644 --- a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/DecoderTest.java +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/DecoderTest.java @@ -18,162 +18,162 @@ public class DecoderTest { - @Test - public void decodeV05() throws Throwable { - String resourceName = "/greeting.msgpack"; - byte[] buffer = readResourceFile(resourceName); - DecodedMessage message = Decoder.decodeV05(buffer); - List traces = message.getTraces(); - assertEquals(1, traces.size()); - assertNull(traces.get(0).getSamplingPriority()); - List spans = traces.get(0).getSpans(); - assertEquals(2, spans.size()); - List sorted = Decoder.sortByStart(spans); - DecodedSpan first = sorted.get(0); - long traceId = first.getTraceId(); - validateSpan( - first, - traceId, - 0, - "servlet.request", - "GET /greeting", - "smoke-test-java-app", - "web", - singletonMap("component", "tomcat-server"), - singletonMap("_dd.top_level", 1)); - DecodedSpan second = sorted.get(1); - validateSpan( - second, - traceId, - first.getSpanId(), - "spring.handler", - "WebController.greeting", - "smoke-test-java-app", - "web", - singletonMap("component", "spring-web-controller"), - singletonMap("_dd.measured", 1)); - } + @Test + public void decodeV05() throws Throwable { + String resourceName = "/greeting.msgpack"; + byte[] buffer = readResourceFile(resourceName); + DecodedMessage message = Decoder.decodeV05(buffer); + List traces = message.getTraces(); + assertEquals(1, traces.size()); + assertNull(traces.get(0).getSamplingPriority()); + List spans = traces.get(0).getSpans(); + assertEquals(2, spans.size()); + List sorted = Decoder.sortByStart(spans); + DecodedSpan first = sorted.get(0); + long traceId = first.getTraceId(); + validateSpan( + first, + traceId, + 0, + "servlet.request", + "GET /greeting", + "smoke-test-java-app", + "web", + singletonMap("component", "tomcat-server"), + singletonMap("_dd.top_level", 1)); + DecodedSpan second = sorted.get(1); + validateSpan( + second, + traceId, + first.getSpanId(), + "spring.handler", + "WebController.greeting", + "smoke-test-java-app", + "web", + singletonMap("component", "spring-web-controller"), + singletonMap("_dd.measured", 1)); + } - @Test - public void decodeV04() throws Throwable { - byte[] buffer = readResourceFile("/webflux.04.msgpack"); + @Test + public void decodeV04() throws Throwable { + byte[] buffer = readResourceFile("/webflux.04.msgpack"); - DecodedMessage message = Decoder.decodeV04(buffer); - List traces = message.getTraces(); - assertEquals(1, traces.size()); - assertNull(traces.get(0).getSamplingPriority()); - List spans = traces.get(0).getSpans(); - assertEquals(2, spans.size()); - List sorted = Decoder.sortByStart(spans); + DecodedMessage message = Decoder.decodeV04(buffer); + List traces = message.getTraces(); + assertEquals(1, traces.size()); + assertNull(traces.get(0).getSamplingPriority()); + List spans = traces.get(0).getSpans(); + assertEquals(2, spans.size()); + List sorted = Decoder.sortByStart(spans); - DecodedSpan first = sorted.get(0); - long traceId = first.getTraceId(); - validateSpan( - first, - traceId, - 0, - "netty.request", - "GET /hello", - "smoke-test-java-app", - "web", - singletonMap("component", "netty"), - singletonMap("_dd.agent_psr", 1.0)); + DecodedSpan first = sorted.get(0); + long traceId = first.getTraceId(); + validateSpan( + first, + traceId, + 0, + "netty.request", + "GET /hello", + "smoke-test-java-app", + "web", + singletonMap("component", "netty"), + singletonMap("_dd.agent_psr", 1.0)); - DecodedSpan second = sorted.get(1); - validateSpan( - second, - traceId, - first.getSpanId(), - "WebController.hello", - "WebController.hello", - "smoke-test-java-app", - "web", - singletonMap("component", "spring-webflux-controller"), - singletonMap("_dd.measured", 1)); - } + DecodedSpan second = sorted.get(1); + validateSpan( + second, + traceId, + first.getSpanId(), + "WebController.hello", + "WebController.hello", + "smoke-test-java-app", + "web", + singletonMap("component", "spring-webflux-controller"), + singletonMap("_dd.measured", 1)); + } - @Test - public void decodeV1() throws Throwable { - byte[] buffer = readResourceFile("/sample_v1.msgpack"); + @Test + public void decodeV1() throws Throwable { + byte[] buffer = readResourceFile("/sample_v1.msgpack"); - DecodedMessage message = Decoder.decodeV1(buffer); - List traces = message.getTraces(); - assertEquals(1, traces.size()); - assertEquals(1, traces.get(0).getSamplingPriority()); - List spans = traces.get(0).getSpans(); - assertEquals(2, spans.size()); - List sorted = Decoder.sortByStart(spans); + DecodedMessage message = Decoder.decodeV1(buffer); + List traces = message.getTraces(); + assertEquals(1, traces.size()); + assertEquals(1, traces.get(0).getSamplingPriority()); + List spans = traces.get(0).getSpans(); + assertEquals(2, spans.size()); + List sorted = Decoder.sortByStart(spans); - DecodedSpan first = sorted.get(0); - long traceId = first.getTraceId(); - validateSpan( - first, - traceId, - 0, - "netty.request", - "GET /hello", - "smoke-test-java-app", - "web", - singletonMap("component", "netty"), - singletonMap("_dd.agent_psr", 1.0)); + DecodedSpan first = sorted.get(0); + long traceId = first.getTraceId(); + validateSpan( + first, + traceId, + 0, + "netty.request", + "GET /hello", + "smoke-test-java-app", + "web", + singletonMap("component", "netty"), + singletonMap("_dd.agent_psr", 1.0)); - DecodedSpan second = sorted.get(1); - validateSpan( - second, - traceId, - first.getSpanId(), - "WebController.hello", - "WebController.hello", - "smoke-test-java-app", - "web", - singletonMap("component", "spring-webflux-controller"), - singletonMap("_dd.measured", 1)); - } + DecodedSpan second = sorted.get(1); + validateSpan( + second, + traceId, + first.getSpanId(), + "WebController.hello", + "WebController.hello", + "smoke-test-java-app", + "web", + singletonMap("component", "spring-webflux-controller"), + singletonMap("_dd.measured", 1)); + } - @Test - public void sortByStart() throws Throwable { - byte[] buffer = readResourceFile("/greeting.msgpack"); - DecodedMessage message = Decoder.decodeV05(buffer); - List traces = message.getTraces(); - assertEquals(1, traces.size()); - final List spans = traces.get(0).getSpans(); - assertEquals(2, spans.size()); - final List sorted = Decoder.sortByStart(spans); - assertTrue(sorted.get(0).getStart() < sorted.get(1).getStart()); - // Ensure that we cover all the branches - List tmp = Decoder.sortByStart(sorted); - assertIterableEquals(sorted, tmp); - Collections.reverse(tmp); - tmp = Decoder.sortByStart(tmp); - assertIterableEquals(sorted, tmp); - tmp.set(1, tmp.get(0)); - assertIterableEquals(tmp, Decoder.sortByStart(tmp)); - } + @Test + public void sortByStart() throws Throwable { + byte[] buffer = readResourceFile("/greeting.msgpack"); + DecodedMessage message = Decoder.decodeV05(buffer); + List traces = message.getTraces(); + assertEquals(1, traces.size()); + final List spans = traces.get(0).getSpans(); + assertEquals(2, spans.size()); + final List sorted = Decoder.sortByStart(spans); + assertTrue(sorted.get(0).getStart() < sorted.get(1).getStart()); + // Ensure that we cover all the branches + List tmp = Decoder.sortByStart(sorted); + assertIterableEquals(sorted, tmp); + Collections.reverse(tmp); + tmp = Decoder.sortByStart(tmp); + assertIterableEquals(sorted, tmp); + tmp.set(1, tmp.get(0)); + assertIterableEquals(tmp, Decoder.sortByStart(tmp)); + } - private byte[] readResourceFile(String resourceName) throws IOException { - Path path = new File(getClass().getResource(resourceName).getFile()).toPath(); - return Files.readAllBytes(path); - } + private byte[] readResourceFile(String resourceName) throws IOException { + Path path = new File(getClass().getResource(resourceName).getFile()).toPath(); + return Files.readAllBytes(path); + } - public static void validateSpan( - DecodedSpan span, - long traceId, - long parentId, - String name, - String resource, - String service, - String type, - Map meta, - Map metrics) { - assertEquals(traceId, span.getTraceId()); - assertEquals(parentId, span.getParentId()); - assertEquals(name, span.getName()); - assertEquals(resource, span.getResource()); - assertEquals(service, span.getService()); - assertTrue(span.getDuration() > 0); - assertEquals(0, span.getError()); - assertEquals(type, span.getType()); - assertMapContainsKeyValues(span.getMeta(), meta); - assertMapContainsKeyValues(span.getMetrics(), metrics); - } + public static void validateSpan( + DecodedSpan span, + long traceId, + long parentId, + String name, + String resource, + String service, + String type, + Map meta, + Map metrics) { + assertEquals(traceId, span.getTraceId()); + assertEquals(parentId, span.getParentId()); + assertEquals(name, span.getName()); + assertEquals(resource, span.getResource()); + assertEquals(service, span.getService()); + assertTrue(span.getDuration() > 0); + assertEquals(0, span.getError()); + assertEquals(type, span.getType()); + assertMapContainsKeyValues(span.getMeta(), meta); + assertMapContainsKeyValues(span.getMetrics(), metrics); + } } diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java index 78d60bf8e0f..c4724b41cb8 100644 --- a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java @@ -21,265 +21,261 @@ */ class JsonDecoderTest { - // A representative JSON trace body (an array of traces, each an array of v0.4-shaped spans), kept - // as a resource file rather than an inline literal for readability (folding, syntax - // highlighting). - private static final String TWO_TRACES = loadJson("/two-traces.json"); + // A representative JSON trace body (an array of traces, each an array of v0.4-shaped spans), kept + // as a resource file rather than an inline literal for readability (folding, syntax + // highlighting). + private static final String TWO_TRACES = loadJson("/two-traces.json"); - @Test - void decodesTraceAndSpanStructure() { - List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + @Test + void decodesTraceAndSpanStructure() { + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); - assertEquals(2, traces.size(), "trace count"); - assertEquals(2, traces.get(0).getSpans().size(), "spans in first trace"); - assertEquals(1, traces.get(1).getSpans().size(), "spans in second trace"); + assertEquals(2, traces.size(), "trace count"); + assertEquals(2, traces.get(0).getSpans().size(), "spans in first trace"); + assertEquals(1, traces.get(1).getSpans().size(), "spans in second trace"); - DecodedSpan root = traces.get(0).getSpans().get(0); - assertEquals("my-service", root.getService()); - assertEquals("servlet.request", root.getName()); - assertEquals("GET /greeting", root.getResource()); - assertEquals("web", root.getType()); - assertEquals(1234567890L, root.getTraceId()); - assertEquals(111L, root.getSpanId()); - assertEquals(0L, root.getParentId()); - assertEquals(1600000000000000000L, root.getStart()); - assertEquals(500000L, root.getDuration()); - assertEquals(0, root.getError()); - } + DecodedSpan root = traces.get(0).getSpans().get(0); + assertEquals("my-service", root.getService()); + assertEquals("servlet.request", root.getName()); + assertEquals("GET /greeting", root.getResource()); + assertEquals("web", root.getType()); + assertEquals(1234567890L, root.getTraceId()); + assertEquals(111L, root.getSpanId()); + assertEquals(0L, root.getParentId()); + assertEquals(1600000000000000000L, root.getStart()); + assertEquals(500000L, root.getDuration()); + assertEquals(0, root.getError()); + } - @Test - void mapsMetaAsStringsAndMetricsAsNumbers() { - List spans = Decoder.decodeJson(TWO_TRACES).getTraces().get(0).getSpans(); + @Test + void mapsMetaAsStringsAndMetricsAsNumbers() { + List spans = + Decoder.decodeJson(TWO_TRACES).getTraces().get(0).getSpans(); - Map meta = spans.get(0).getMeta(); - assertEquals("GET", meta.get("http.method")); - assertEquals("200", meta.get("http.status_code")); + Map meta = spans.get(0).getMeta(); + assertEquals("GET", meta.get("http.method")); + assertEquals("200", meta.get("http.status_code")); - Map metrics = spans.get(0).getMetrics(); - // Integral metrics decode to Integer and fractional metrics to Double, matching the msgpack - // decoders (SpanV04.unpackNumber) so an assertion behaves identically across both backends. - assertEquals(1, metrics.get("_dd.top_level")); - assertEquals(0.75, metrics.get("_dd.agent_psr")); + Map metrics = spans.get(0).getMetrics(); + // Integral metrics decode to Integer and fractional metrics to Double, matching the msgpack + // decoders (SpanV04.unpackNumber) so an assertion behaves identically across both backends. + assertEquals(1, metrics.get("_dd.top_level")); + assertEquals(0.75, metrics.get("_dd.agent_psr")); - // A serialized error span carries error != 0; the parent link is the enclosing root span id. - DecodedSpan child = spans.get(1); - assertEquals(1, child.getError()); - assertEquals(111L, child.getParentId()); - assertEquals("postgres", child.getMeta().get("db.type")); - } + // A serialized error span carries error != 0; the parent link is the enclosing root span id. + DecodedSpan child = spans.get(1); + assertEquals(1, child.getError()); + assertEquals(111L, child.getParentId()); + assertEquals("postgres", child.getMeta().get("db.type")); + } - @Test - void metaStructEmptyWhenAbsentAndAMapWhenPresent() { - // Absent meta_struct decodes to an empty map, matching the msgpack decoders (SpanV04). - DecodedSpan withoutMetaStruct = - Decoder.decodeJson(TWO_TRACES).getTraces().get(0).getSpans().get(0); - assertTrue(withoutMetaStruct.getMetaStruct().isEmpty()); + @Test + void metaStructEmptyWhenAbsentAndAMapWhenPresent() { + // Absent meta_struct decodes to an empty map, matching the msgpack decoders (SpanV04). + DecodedSpan withoutMetaStruct = + Decoder.decodeJson(TWO_TRACES).getTraces().get(0).getSpans().get(0); + assertTrue(withoutMetaStruct.getMetaStruct().isEmpty()); - String withMetaStruct = loadJson("/with-meta-struct.json"); - Map metaStruct = - Decoder.decodeJson(withMetaStruct).getTraces().get(0).getSpans().get(0).getMetaStruct(); - assertTrue(metaStruct.containsKey("appsec")); - } + String withMetaStruct = loadJson("/with-meta-struct.json"); + Map metaStruct = Decoder.decodeJson(withMetaStruct) + .getTraces() + .get(0) + .getSpans() + .get(0) + .getMetaStruct(); + assertTrue(metaStruct.containsKey("appsec")); + } - @Test - void emptyAndNullBodiesYieldNoTraces() { - assertTrue(Decoder.decodeJson("[]").getTraces().isEmpty(), "empty array => no traces"); - // Moshi parses the JSON literal null as a null document; decode tolerates it. - assertTrue(Decoder.decodeJson("null").getTraces().isEmpty(), "null body => no traces"); + @Test + void emptyAndNullBodiesYieldNoTraces() { + assertTrue(Decoder.decodeJson("[]").getTraces().isEmpty(), "empty array => no traces"); + // Moshi parses the JSON literal null as a null document; decode tolerates it. + assertTrue(Decoder.decodeJson("null").getTraces().isEmpty(), "null body => no traces"); - List oneEmptyTrace = Decoder.decodeJson("[[]]").getTraces(); - assertEquals(1, oneEmptyTrace.size()); - assertTrue(oneEmptyTrace.get(0).getSpans().isEmpty(), "empty trace => no spans"); - } + List oneEmptyTrace = Decoder.decodeJson("[[]]").getTraces(); + assertEquals(1, oneEmptyTrace.size()); + assertTrue(oneEmptyTrace.get(0).getSpans().isEmpty(), "empty trace => no spans"); + } - @Test - void decodesUnsignedIds() { - // Trace/span IDs are unsigned 64-bit; the agent emits them as JSON numbers that can exceed - // Long.MAX_VALUE. They must be parsed unsigned and kept as the signed bit pattern. - String maxUnsigned = Long.toUnsignedString(-1L); // 18446744073709551615 == 2^64 - 1 - String json = - "[[{" - + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\", \"type\": \"web\"," - + "\"trace_id\": " - + maxUnsigned - + ", \"span_id\": " - + maxUnsigned - + ", \"parent_id\": 0, \"start\": 0, \"duration\": 0, \"error\": 0," - + "\"meta\": {}, \"metrics\": {}" - + "}]]"; - DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); - assertEquals(-1L, span.getTraceId(), "unsigned 2^64-1 kept as its signed bit pattern"); - assertEquals(-1L, span.getSpanId()); - assertEquals(0L, span.getParentId()); - } + @Test + void decodesUnsignedIds() { + // Trace/span IDs are unsigned 64-bit; the agent emits them as JSON numbers that can exceed + // Long.MAX_VALUE. They must be parsed unsigned and kept as the signed bit pattern. + String maxUnsigned = Long.toUnsignedString(-1L); // 18446744073709551615 == 2^64 - 1 + String json = "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\", \"type\": \"web\"," + + "\"trace_id\": " + + maxUnsigned + + ", \"span_id\": " + + maxUnsigned + + ", \"parent_id\": 0, \"start\": 0, \"duration\": 0, \"error\": 0," + + "\"meta\": {}, \"metrics\": {}" + + "}]]"; + DecodedSpan span = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + assertEquals(-1L, span.getTraceId(), "unsigned 2^64-1 kept as its signed bit pattern"); + assertEquals(-1L, span.getSpanId()); + assertEquals(0L, span.getParentId()); + } - @Test - void preservesIntegralMetricTypesAndPrecision() { - // Integral metrics that fit an int stay Integer; larger integrals stay Long (exact, not rounded - // through Double); integrals beyond long range stay BigInteger (still exact, not Double); - // fractional metrics stay Double — matching SpanV04/SpanV05.unpackNumber so a metric assertion - // behaves identically whether the span came from the msgpack or JSON backend. - long aboveDoublePrecision = (1L << 53) + 1; // 9007199254740993, not representable as a double - BigInteger beyondLong = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE); // 2^63 - String json = - "[[{" - + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\"," - + "\"trace_id\": 1, \"span_id\": 1, \"parent_id\": 0, \"start\": 0, \"duration\": 0," - + "\"error\": 0, \"meta\": {}," - + "\"metrics\": {\"small\": 1, \"big\": " - + aboveDoublePrecision - + ", \"huge\": " - + beyondLong - + ", \"ratio\": 0.5}" - + "}]]"; - Map metrics = - Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0).getMetrics(); + @Test + void preservesIntegralMetricTypesAndPrecision() { + // Integral metrics that fit an int stay Integer; larger integrals stay Long (exact, not rounded + // through Double); integrals beyond long range stay BigInteger (still exact, not Double); + // fractional metrics stay Double — matching SpanV04/SpanV05.unpackNumber so a metric assertion + // behaves identically whether the span came from the msgpack or JSON backend. + long aboveDoublePrecision = (1L << 53) + 1; // 9007199254740993, not representable as a double + BigInteger beyondLong = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE); // 2^63 + String json = "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\"," + + "\"trace_id\": 1, \"span_id\": 1, \"parent_id\": 0, \"start\": 0, \"duration\": 0," + + "\"error\": 0, \"meta\": {}," + + "\"metrics\": {\"small\": 1, \"big\": " + + aboveDoublePrecision + + ", \"huge\": " + + beyondLong + + ", \"ratio\": 0.5}" + + "}]]"; + Map metrics = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0).getMetrics(); - assertEquals(1, metrics.get("small"), "int-range integral stays Integer"); - assertEquals(aboveDoublePrecision, metrics.get("big"), "large integral stays Long, exact"); - assertEquals(beyondLong, metrics.get("huge"), "beyond-long integral stays BigInteger, exact"); - assertEquals(0.5, metrics.get("ratio"), "fractional stays Double"); - } + assertEquals(1, metrics.get("small"), "int-range integral stays Integer"); + assertEquals(aboveDoublePrecision, metrics.get("big"), "large integral stays Long, exact"); + assertEquals(beyondLong, metrics.get("huge"), "beyond-long integral stays BigInteger, exact"); + assertEquals(0.5, metrics.get("ratio"), "fractional stays Double"); + } - @Test - void rejectsSpansMissingRequiredV04Fields() { - // The agent's Span schema marks name/trace_id/span_id/start/duration as required; a truncated - // or - // schema-mismatched response omitting any of them must fail loudly rather than decode into a - // span whose fields silently default to 0/null. - assertThrows( - IllegalStateException.class, - () -> - Decoder.decodeJson(oneSpan("\"trace_id\":1,\"span_id\":1,\"start\":0,\"duration\":0")), - "missing name"); - assertThrows( - IllegalStateException.class, - () -> - Decoder.decodeJson(oneSpan("\"name\":\"n\",\"span_id\":1,\"start\":0,\"duration\":0")), - "missing trace_id"); - assertThrows( - IllegalStateException.class, - () -> - Decoder.decodeJson(oneSpan("\"name\":\"n\",\"trace_id\":1,\"start\":0,\"duration\":0")), - "missing span_id"); - assertThrows( - IllegalStateException.class, - () -> - Decoder.decodeJson( - oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"duration\":0")), - "missing start"); - assertThrows( - IllegalStateException.class, - () -> - Decoder.decodeJson(oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"start\":0")), - "missing duration"); + @Test + void rejectsSpansMissingRequiredV04Fields() { + // The agent's Span schema marks name/trace_id/span_id/start/duration as required; a truncated + // or + // schema-mismatched response omitting any of them must fail loudly rather than decode into a + // span whose fields silently default to 0/null. + assertThrows( + IllegalStateException.class, + () -> Decoder.decodeJson(oneSpan("\"trace_id\":1,\"span_id\":1,\"start\":0,\"duration\":0")), + "missing name"); + assertThrows( + IllegalStateException.class, + () -> Decoder.decodeJson(oneSpan("\"name\":\"n\",\"span_id\":1,\"start\":0,\"duration\":0")), + "missing trace_id"); + assertThrows( + IllegalStateException.class, + () -> Decoder.decodeJson(oneSpan("\"name\":\"n\",\"trace_id\":1,\"start\":0,\"duration\":0")), + "missing span_id"); + assertThrows( + IllegalStateException.class, + () -> Decoder.decodeJson(oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"duration\":0")), + "missing start"); + assertThrows( + IllegalStateException.class, + () -> Decoder.decodeJson(oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"start\":0")), + "missing duration"); - // error and parent_id are optional per the agent schema: a span omitting both still decodes, - // with error defaulting to 0 (no error) and parent_id to 0 (root). - DecodedSpan span = - Decoder.decodeJson( - oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"start\":0,\"duration\":0")) - .getTraces() - .get(0) - .getSpans() - .get(0); - assertEquals(0, span.getError(), "absent error => 0"); - assertEquals(0L, span.getParentId(), "absent parent_id => 0 (root)"); - } + // error and parent_id are optional per the agent schema: a span omitting both still decodes, + // with error defaulting to 0 (no error) and parent_id to 0 (root). + DecodedSpan span = Decoder.decodeJson( + oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"start\":0,\"duration\":0")) + .getTraces() + .get(0) + .getSpans() + .get(0); + assertEquals(0, span.getError(), "absent error => 0"); + assertEquals(0L, span.getParentId(), "absent parent_id => 0 (root)"); + } - @Test - void rejectsNullTraceEntry() { - // A null trace element ([null]) is corruption, not an empty trace; fail rather than silently - // decode it into an empty trace that still inflates the trace count. - assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("[null]")); - } + @Test + void rejectsNullTraceEntry() { + // A null trace element ([null]) is corruption, not an empty trace; fail rather than silently + // decode it into an empty trace that still inflates the trace count. + assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("[null]")); + } - @Test - void decodedTraceListIsUnmodifiable() { - // Matches the msgpack DecodedMessage implementations, which all expose an unmodifiable list. - List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); - assertThrows(UnsupportedOperationException.class, traces::clear); - } + @Test + void decodedTraceListIsUnmodifiable() { + // Matches the msgpack DecodedMessage implementations, which all expose an unmodifiable list. + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + assertThrows(UnsupportedOperationException.class, traces::clear); + } - /** Wraps a single span's JSON fields into a one-trace, one-span payload. */ - private static String oneSpan(String fields) { - return "[[{" + fields + "}]]"; - } + /** Wraps a single span's JSON fields into a one-trace, one-span payload. */ + private static String oneSpan(String fields) { + return "[[{" + fields + "}]]"; + } - /** Reads a JSON test fixture from the classpath (kept as a resource for readability). */ - private static String loadJson(String resourceName) { - try (InputStream in = JsonDecoderTest.class.getResourceAsStream(resourceName)) { - if (in == null) { - throw new IllegalStateException("Missing test resource: " + resourceName); - } - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buffer = new byte[4096]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - return out.toString("UTF-8"); - } catch (IOException e) { - throw new UncheckedIOException("Failed to read test resource: " + resourceName, e); + /** Reads a JSON test fixture from the classpath (kept as a resource for readability). */ + private static String loadJson(String resourceName) { + try (InputStream in = JsonDecoderTest.class.getResourceAsStream(resourceName)) { + if (in == null) { + throw new IllegalStateException("Missing test resource: " + resourceName); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toString("UTF-8"); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read test resource: " + resourceName, e); + } } - } - @Test - void preservesMetaStructNumericTypesAcrossNesting() { - // meta_struct numeric leaves must decode to the same types the msgpack decoder produces - // (SpanV04.convertValueToObject): integers -> Long, floating-point -> Float, recursively - // through - // nested objects and arrays, with strings/booleans/null preserved. Otherwise an exact - // metaStruct(...) assertion or a leaf cast would behave differently across the two backends. - long aboveDoublePrecision = (1L << 53) + 1; // 9007199254740993, not representable as a double - BigInteger beyondLong = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE); // 2^63 - String json = - "[[{" - + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\"," - + "\"trace_id\": 1, \"span_id\": 1, \"parent_id\": 0, \"start\": 0, \"duration\": 0," - + "\"error\": 0, \"meta\": {}, \"metrics\": {}," - + "\"meta_struct\": {\"appsec\": {" - + " \"count\": 3," - + " \"rate\": 0.5," - + " \"whole\": 3.0," - + " \"nested\": {\"big\": " - + aboveDoublePrecision - + ", \"huge\": " - + beyondLong - + "}," - + " \"list\": [1, 2.5, \"x\", true, null]" - + "}}" - + "}]]"; - Map metaStruct = - Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0).getMetaStruct(); + @Test + void preservesMetaStructNumericTypesAcrossNesting() { + // meta_struct numeric leaves must decode to the same types the msgpack decoder produces + // (SpanV04.convertValueToObject): integers -> Long, floating-point -> Float, recursively + // through + // nested objects and arrays, with strings/booleans/null preserved. Otherwise an exact + // metaStruct(...) assertion or a leaf cast would behave differently across the two backends. + long aboveDoublePrecision = (1L << 53) + 1; // 9007199254740993, not representable as a double + BigInteger beyondLong = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE); // 2^63 + String json = "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\"," + + "\"trace_id\": 1, \"span_id\": 1, \"parent_id\": 0, \"start\": 0, \"duration\": 0," + + "\"error\": 0, \"meta\": {}, \"metrics\": {}," + + "\"meta_struct\": {\"appsec\": {" + + " \"count\": 3," + + " \"rate\": 0.5," + + " \"whole\": 3.0," + + " \"nested\": {\"big\": " + + aboveDoublePrecision + + ", \"huge\": " + + beyondLong + + "}," + + " \"list\": [1, 2.5, \"x\", true, null]" + + "}}" + + "}]]"; + Map metaStruct = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0).getMetaStruct(); - @SuppressWarnings("unchecked") - Map appsec = (Map) metaStruct.get("appsec"); - assertEquals(3L, appsec.get("count"), "integer leaf -> Long"); - assertEquals(0.5F, appsec.get("rate"), "float leaf -> Float"); - // A whole-number float: the live agent emits it as "3.0" (json.dumps keeps the decimal point), - // so it must box to Float like the msgpack FLOAT leaf, not Long. Verified via a real - // round-trip. - assertEquals(3.0F, appsec.get("whole"), "whole-number float keeps .0 -> Float"); + @SuppressWarnings("unchecked") + Map appsec = (Map) metaStruct.get("appsec"); + assertEquals(3L, appsec.get("count"), "integer leaf -> Long"); + assertEquals(0.5F, appsec.get("rate"), "float leaf -> Float"); + // A whole-number float: the live agent emits it as "3.0" (json.dumps keeps the decimal point), + // so it must box to Float like the msgpack FLOAT leaf, not Long. Verified via a real + // round-trip. + assertEquals(3.0F, appsec.get("whole"), "whole-number float keeps .0 -> Float"); - @SuppressWarnings("unchecked") - Map nested = (Map) appsec.get("nested"); - assertEquals(aboveDoublePrecision, nested.get("big"), "large integer stays Long, exact"); - assertEquals(beyondLong, nested.get("huge"), "beyond-long integer stays BigInteger, exact"); + @SuppressWarnings("unchecked") + Map nested = (Map) appsec.get("nested"); + assertEquals(aboveDoublePrecision, nested.get("big"), "large integer stays Long, exact"); + assertEquals(beyondLong, nested.get("huge"), "beyond-long integer stays BigInteger, exact"); - @SuppressWarnings("unchecked") - List list = (List) appsec.get("list"); - assertEquals(1L, list.get(0), "integer in array -> Long"); - assertEquals(2.5F, list.get(1), "float in array -> Float"); - assertEquals("x", list.get(2), "string in array preserved"); - assertEquals(Boolean.TRUE, list.get(3), "boolean in array preserved"); - assertNull(list.get(4), "null leaf preserved"); - } + @SuppressWarnings("unchecked") + List list = (List) appsec.get("list"); + assertEquals(1L, list.get(0), "integer in array -> Long"); + assertEquals(2.5F, list.get(1), "float in array -> Float"); + assertEquals("x", list.get(2), "string in array preserved"); + assertEquals(Boolean.TRUE, list.get(3), "boolean in array preserved"); + assertNull(list.get(4), "null leaf preserved"); + } - @Test - void malformedJsonThrows() { - IllegalStateException e = - assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("{ not valid json")); - assertTrue(e.getMessage().contains("{ not valid json"), "message should include the body"); - } + @Test + void malformedJsonThrows() { + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("{ not valid json")); + assertTrue(e.getMessage().contains("{ not valid json"), "message should include the body"); + } } diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/SpanLinksDecoderTest.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/SpanLinksDecoderTest.java index 19f12012dba..8cdf2f718cb 100644 --- a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/SpanLinksDecoderTest.java +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/SpanLinksDecoderTest.java @@ -13,221 +13,214 @@ import org.junit.jupiter.api.Test; class SpanLinksDecoderTest { - // The decoder keeps the tag name private, so spell it out the way the tracer writes it. - private static final String SPAN_LINKS_TAG = "_dd.span_links"; - private static final String TRACE_ID_128 = "11223344556677889900aabbccddeeff"; - private static final long TRACE_ID_LOW = 0x9900aabbccddeeffL; - - @Test - void absentOrEmptyTagYieldsNoLink() { - assertTrue(DecodedSpanLinks.fromMeta(emptyMap()).isEmpty(), "no tag in meta"); - assertTrue(fromTag(null).isEmpty(), "null tag"); - assertTrue(fromTag("").isEmpty(), "empty tag"); - assertTrue(fromTag("[]").isEmpty(), "empty array"); - assertTrue(fromTag("null").isEmpty(), "JSON null"); - } - - @Test - void decodesMinimalLinkApplyingDefaults() { - List links = - fromTag("[{\"span_id\":\"123456789abcdef0\",\"trace_id\":\"" + TRACE_ID_128 + "\"}]"); - - assertEquals(1, links.size()); - DecodedSpanLink link = links.get(0); - assertEquals(TRACE_ID_LOW, link.getTraceId(), "a 128-bit id narrows to its low-order half"); - assertEquals(0x123456789abcdef0L, link.getSpanId()); - assertEquals((byte) 0, link.getTraceFlags(), "absent flags default to 0"); - assertEquals("", link.getTraceState(), "absent tracestate defaults to empty"); - assertTrue(link.getAttributes().isEmpty(), "absent attributes default to empty"); - } - - @Test - void decodesFullLink() { - List links = - fromTag( - "[{\"trace_id\":\"" + // The decoder keeps the tag name private, so spell it out the way the tracer writes it. + private static final String SPAN_LINKS_TAG = "_dd.span_links"; + private static final String TRACE_ID_128 = "11223344556677889900aabbccddeeff"; + private static final long TRACE_ID_LOW = 0x9900aabbccddeeffL; + + @Test + void absentOrEmptyTagYieldsNoLink() { + assertTrue(DecodedSpanLinks.fromMeta(emptyMap()).isEmpty(), "no tag in meta"); + assertTrue(fromTag(null).isEmpty(), "null tag"); + assertTrue(fromTag("").isEmpty(), "empty tag"); + assertTrue(fromTag("[]").isEmpty(), "empty array"); + assertTrue(fromTag("null").isEmpty(), "JSON null"); + } + + @Test + void decodesMinimalLinkApplyingDefaults() { + List links = + fromTag("[{\"span_id\":\"123456789abcdef0\",\"trace_id\":\"" + TRACE_ID_128 + "\"}]"); + + assertEquals(1, links.size()); + DecodedSpanLink link = links.get(0); + assertEquals(TRACE_ID_LOW, link.getTraceId(), "a 128-bit id narrows to its low-order half"); + assertEquals(0x123456789abcdef0L, link.getSpanId()); + assertEquals((byte) 0, link.getTraceFlags(), "absent flags default to 0"); + assertEquals("", link.getTraceState(), "absent tracestate defaults to empty"); + assertTrue(link.getAttributes().isEmpty(), "absent attributes default to empty"); + } + + @Test + void decodesFullLink() { + List links = fromTag("[{\"trace_id\":\"" + TRACE_ID_128 + "\",\"span_id\":\"a\",\"flags\":1," + "\"tracestate\":\"dd=s:1\"," + "\"attributes\":{\"link.kind\":\"span-pointer\",\"ptr.dir\":\"d\"}}]"); - DecodedSpanLink link = links.get(0); - assertEquals(0xaL, link.getSpanId(), "an unpadded span id decodes"); - assertEquals((byte) 1, link.getTraceFlags()); - assertEquals("dd=s:1", link.getTraceState()); - Map expected = new HashMap<>(); - expected.put("link.kind", "span-pointer"); - expected.put("ptr.dir", "d"); - assertEquals(expected, link.getAttributes()); - } - - @Test - void preservesLinkOrderAndDecodesUnsignedSpanIds() { - List links = - fromTag( - "[{\"trace_id\":\"0\",\"span_id\":\"1\"}," + DecodedSpanLink link = links.get(0); + assertEquals(0xaL, link.getSpanId(), "an unpadded span id decodes"); + assertEquals((byte) 1, link.getTraceFlags()); + assertEquals("dd=s:1", link.getTraceState()); + Map expected = new HashMap<>(); + expected.put("link.kind", "span-pointer"); + expected.put("ptr.dir", "d"); + assertEquals(expected, link.getAttributes()); + } + + @Test + void preservesLinkOrderAndDecodesUnsignedSpanIds() { + List links = fromTag("[{\"trace_id\":\"0\",\"span_id\":\"1\"}," + "{\"trace_id\":\"0\",\"span_id\":\"ffffffffffffffff\"}," + "{\"trace_id\":\"0\",\"span_id\":\"2\"}]"); - assertEquals(3, links.size()); - assertEquals(1L, links.get(0).getSpanId()); - assertEquals(-1L, links.get(1).getSpanId(), "an unsigned span id above Long.MAX_VALUE"); - assertEquals(2L, links.get(2).getSpanId()); - } - - @Test - void decodesAShortTraceId() { - DecodedSpanLink link = fromTag("[{\"trace_id\":\"abcd\",\"span_id\":\"1\"}]").get(0); - - assertEquals(0xabcdL, link.getTraceId()); - } - - @Test - void unknownKeysAreIgnored() { - DecodedSpanLink link = - fromTag("[{\"trace_id\":\"1\",\"span_id\":\"1\",\"something_new\":\"whatever\"}]").get(0); - - assertEquals(1L, link.getTraceId()); - } - - @Test - void malformedTagsThrow() { - assertThrows(IllegalStateException.class, () -> fromTag("not json"), "malformed JSON"); - assertThrows( - IllegalStateException.class, () -> fromTag("[{\"span_id\":\"1\"}]"), "missing trace_id"); - assertThrows( - IllegalStateException.class, () -> fromTag("[{\"trace_id\":\"1\"}]"), "missing span_id"); - assertThrows( - IllegalStateException.class, - () -> fromTag("[{\"trace_id\":\"1\",\"span_id\":\"zz\"}]"), - "malformed span id"); - assertThrows(IllegalStateException.class, () -> fromTag("[null]"), "null link entry"); - } - - @Test - void linkFactoryNormalizesAbsentValues() { - DecodedSpanLink link = DecodedSpanLinks.link(2L, 3L, (byte) 4, null, null); - - assertEquals(2L, link.getTraceId()); - assertEquals(3L, link.getSpanId()); - assertEquals((byte) 4, link.getTraceFlags()); - assertEquals("", link.getTraceState()); - assertTrue(link.getAttributes().isEmpty()); - - DecodedSpanLink withValues = - DecodedSpanLinks.link(0L, 0L, (byte) 0, "dd=s:2", singletonMap("k", "v")); - assertEquals("dd=s:2", withValues.getTraceState()); - assertEquals(singletonMap("k", "v"), withValues.getAttributes()); - } - - @Test - void rendersIdentifiersAsHexadecimal() { - String rendered = DecodedSpanLinks.link(TRACE_ID_LOW, 0xaL, (byte) 0, "", null).toString(); - - assertTrue(rendered.contains("traceId=9900aabbccddeeff"), "renders the trace id: " + rendered); - assertTrue(rendered.contains("spanId=a"), "renders the span id: " + rendered); - } - - @Test - void treatsAnExplicitlyEmptyAttributeMapAsNone() { - DecodedSpanLink link = - DecodedSpanLinks.link(0L, 0L, (byte) 0, "", java.util.Collections.emptyMap()); - - assertTrue(link.getAttributes().isEmpty()); - } - - private static List fromTag(String tagValue) { - return DecodedSpanLinks.fromMeta(singletonMap(SPAN_LINKS_TAG, tagValue)); - } - - @Test - void decodedSpanExposesLinksAndKeepsTheTagVisible() { - String tag = "[{\\\"trace_id\\\":\\\"" + TRACE_ID_128 + "\\\",\\\"span_id\\\":\\\"a\\\"}]"; - String json = - "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," - + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," - + "\"metrics\":{},\"meta\":{\"" - + SPAN_LINKS_TAG - + "\":\"" - + tag - + "\"}}]]"; - - DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); - - assertEquals(1, span.getLinks().size()); - assertEquals(0xaL, span.getLinks().get(0).getSpanId()); - assertNotNull( - span.getMeta().get(SPAN_LINKS_TAG), "the tag stays visible as the untyped meta entry"); - } - - @Test - void decodesStructuredSpanLinksFieldFromAV1Payload() { - // The agent converts a v1.0 payload's structured links into a top-level span_links field, - // with decimal identifiers, rather than into the meta tag. - String json = - "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," - + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," - + "\"metrics\":{},\"meta\":{}," - + "\"span_links\":[{\"trace_id\":11068046444225730559,\"trace_id_high\":1234," - + "\"span_id\":10,\"flags\":1,\"tracestate\":\"dd=s:1\"," - + "\"attributes\":{\"link.kind\":\"span-pointer\"}}]}]]"; - - DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); - - assertEquals(1, span.getLinks().size()); - DecodedSpanLink link = span.getLinks().get(0); - assertEquals( - Long.parseUnsignedLong("11068046444225730559"), - link.getTraceId(), - "an unsigned id above Long.MAX_VALUE survives"); - assertEquals(10L, link.getSpanId()); - assertEquals((byte) 1, link.getTraceFlags()); - assertEquals("dd=s:1", link.getTraceState()); - assertEquals(singletonMap("link.kind", "span-pointer"), link.getAttributes()); - } - - @Test - void structuredSpanLinksFieldWinsOverTheMetaTag() { - String json = - "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," - + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," - + "\"metrics\":{},\"meta\":{\"" - + SPAN_LINKS_TAG - + "\":\"[{\\\"trace_id\\\":\\\"1\\\",\\\"span_id\\\":\\\"99\\\"}]\"}," - + "\"span_links\":[{\"trace_id\":1,\"span_id\":7}]}]]"; - - DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); - - assertEquals(1, span.getLinks().size()); - assertEquals(7L, span.getLinks().get(0).getSpanId(), "the structured field is preferred"); - } - - @Test - void anEmptyStructuredSpanLinksFieldWinsOverTheMetaTag() { - // A v1.0 payload always carries the span_links field, so an empty one means the span has no - // link, whichever legacy meta tag the payload also holds. - String json = - "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," - + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," - + "\"metrics\":{},\"meta\":{\"" - + SPAN_LINKS_TAG - + "\":\"[{\\\"trace_id\\\":\\\"1\\\",\\\"span_id\\\":\\\"99\\\"}]\"}," - + "\"span_links\":[]}]]"; - - DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); - - assertTrue(span.getLinks().isEmpty(), "the structured field is preferred, even when empty"); - } - - @Test - void decodedSpanWithoutTheTagExposesNoLink() { - String json = - "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," - + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," - + "\"metrics\":{},\"meta\":{}}]]"; - - DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); - - assertTrue(span.getLinks().isEmpty()); - } + assertEquals(3, links.size()); + assertEquals(1L, links.get(0).getSpanId()); + assertEquals(-1L, links.get(1).getSpanId(), "an unsigned span id above Long.MAX_VALUE"); + assertEquals(2L, links.get(2).getSpanId()); + } + + @Test + void decodesAShortTraceId() { + DecodedSpanLink link = + fromTag("[{\"trace_id\":\"abcd\",\"span_id\":\"1\"}]").get(0); + + assertEquals(0xabcdL, link.getTraceId()); + } + + @Test + void unknownKeysAreIgnored() { + DecodedSpanLink link = fromTag("[{\"trace_id\":\"1\",\"span_id\":\"1\",\"something_new\":\"whatever\"}]") + .get(0); + + assertEquals(1L, link.getTraceId()); + } + + @Test + void malformedTagsThrow() { + assertThrows(IllegalStateException.class, () -> fromTag("not json"), "malformed JSON"); + assertThrows(IllegalStateException.class, () -> fromTag("[{\"span_id\":\"1\"}]"), "missing trace_id"); + assertThrows(IllegalStateException.class, () -> fromTag("[{\"trace_id\":\"1\"}]"), "missing span_id"); + assertThrows( + IllegalStateException.class, + () -> fromTag("[{\"trace_id\":\"1\",\"span_id\":\"zz\"}]"), + "malformed span id"); + assertThrows(IllegalStateException.class, () -> fromTag("[null]"), "null link entry"); + } + + @Test + void linkFactoryNormalizesAbsentValues() { + DecodedSpanLink link = DecodedSpanLinks.link(2L, 3L, (byte) 4, null, null); + + assertEquals(2L, link.getTraceId()); + assertEquals(3L, link.getSpanId()); + assertEquals((byte) 4, link.getTraceFlags()); + assertEquals("", link.getTraceState()); + assertTrue(link.getAttributes().isEmpty()); + + DecodedSpanLink withValues = DecodedSpanLinks.link(0L, 0L, (byte) 0, "dd=s:2", singletonMap("k", "v")); + assertEquals("dd=s:2", withValues.getTraceState()); + assertEquals(singletonMap("k", "v"), withValues.getAttributes()); + } + + @Test + void rendersIdentifiersAsHexadecimal() { + String rendered = + DecodedSpanLinks.link(TRACE_ID_LOW, 0xaL, (byte) 0, "", null).toString(); + + assertTrue(rendered.contains("traceId=9900aabbccddeeff"), "renders the trace id: " + rendered); + assertTrue(rendered.contains("spanId=a"), "renders the span id: " + rendered); + } + + @Test + void treatsAnExplicitlyEmptyAttributeMapAsNone() { + DecodedSpanLink link = DecodedSpanLinks.link(0L, 0L, (byte) 0, "", java.util.Collections.emptyMap()); + + assertTrue(link.getAttributes().isEmpty()); + } + + private static List fromTag(String tagValue) { + return DecodedSpanLinks.fromMeta(singletonMap(SPAN_LINKS_TAG, tagValue)); + } + + @Test + void decodedSpanExposesLinksAndKeepsTheTagVisible() { + String tag = "[{\\\"trace_id\\\":\\\"" + TRACE_ID_128 + "\\\",\\\"span_id\\\":\\\"a\\\"}]"; + String json = "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + + "\"metrics\":{},\"meta\":{\"" + + SPAN_LINKS_TAG + + "\":\"" + + tag + + "\"}}]]"; + + DecodedSpan span = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + + assertEquals(1, span.getLinks().size()); + assertEquals(0xaL, span.getLinks().get(0).getSpanId()); + assertNotNull(span.getMeta().get(SPAN_LINKS_TAG), "the tag stays visible as the untyped meta entry"); + } + + @Test + void decodesStructuredSpanLinksFieldFromAV1Payload() { + // The agent converts a v1.0 payload's structured links into a top-level span_links field, + // with decimal identifiers, rather than into the meta tag. + String json = "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + + "\"metrics\":{},\"meta\":{}," + + "\"span_links\":[{\"trace_id\":11068046444225730559,\"trace_id_high\":1234," + + "\"span_id\":10,\"flags\":1,\"tracestate\":\"dd=s:1\"," + + "\"attributes\":{\"link.kind\":\"span-pointer\"}}]}]]"; + + DecodedSpan span = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + + assertEquals(1, span.getLinks().size()); + DecodedSpanLink link = span.getLinks().get(0); + assertEquals( + Long.parseUnsignedLong("11068046444225730559"), + link.getTraceId(), + "an unsigned id above Long.MAX_VALUE survives"); + assertEquals(10L, link.getSpanId()); + assertEquals((byte) 1, link.getTraceFlags()); + assertEquals("dd=s:1", link.getTraceState()); + assertEquals(singletonMap("link.kind", "span-pointer"), link.getAttributes()); + } + + @Test + void structuredSpanLinksFieldWinsOverTheMetaTag() { + String json = "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + + "\"metrics\":{},\"meta\":{\"" + + SPAN_LINKS_TAG + + "\":\"[{\\\"trace_id\\\":\\\"1\\\",\\\"span_id\\\":\\\"99\\\"}]\"}," + + "\"span_links\":[{\"trace_id\":1,\"span_id\":7}]}]]"; + + DecodedSpan span = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + + assertEquals(1, span.getLinks().size()); + assertEquals(7L, span.getLinks().get(0).getSpanId(), "the structured field is preferred"); + } + + @Test + void anEmptyStructuredSpanLinksFieldWinsOverTheMetaTag() { + // A v1.0 payload always carries the span_links field, so an empty one means the span has no + // link, whichever legacy meta tag the payload also holds. + String json = "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + + "\"metrics\":{},\"meta\":{\"" + + SPAN_LINKS_TAG + + "\":\"[{\\\"trace_id\\\":\\\"1\\\",\\\"span_id\\\":\\\"99\\\"}]\"}," + + "\"span_links\":[]}]]"; + + DecodedSpan span = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + + assertTrue(span.getLinks().isEmpty(), "the structured field is preferred, even when empty"); + } + + @Test + void decodedSpanWithoutTheTagExposesNoLink() { + String json = "[[{\"service\":\"s\",\"name\":\"n\",\"resource\":\"r\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + + "\"metrics\":{},\"meta\":{}}]]"; + + DecodedSpan span = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + + assertTrue(span.getLinks().isEmpty()); + } } diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java index 03e4ece9d6e..174ea1c9920 100644 --- a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1Test.java @@ -19,77 +19,77 @@ * {@code UINT64} — a shape the {@code sample_v1.msgpack} fixture never produces. */ class SpanV1Test { - // 0xfedcba9876543210: an unsigned 64-bit identifier above Long.MAX_VALUE. - private static final String UNSIGNED_ID = "18364758544493064720"; - private static final long UNSIGNED_ID_BITS = 0xfedcba9876543210L; + // 0xfedcba9876543210: an unsigned 64-bit identifier above Long.MAX_VALUE. + private static final String UNSIGNED_ID = "18364758544493064720"; + private static final long UNSIGNED_ID_BITS = 0xfedcba9876543210L; - @Test - void decodesUnsignedSpanAndParentIds() throws IOException { - MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); - packer.packMapHeader(2); - packer.packInt(SpanV1.SPAN_FIELD_SPAN_ID); - packer.packBigInteger(new BigInteger(UNSIGNED_ID)); - packer.packInt(SpanV1.SPAN_FIELD_PARENT_ID); - packer.packBigInteger(new BigInteger(UNSIGNED_ID)); + @Test + void decodesUnsignedSpanAndParentIds() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packMapHeader(2); + packer.packInt(SpanV1.SPAN_FIELD_SPAN_ID); + packer.packBigInteger(new BigInteger(UNSIGNED_ID)); + packer.packInt(SpanV1.SPAN_FIELD_PARENT_ID); + packer.packBigInteger(new BigInteger(UNSIGNED_ID)); - SpanV1 span = SpanV1.unpack(unpacker(packer), new ArrayList<>()); + SpanV1 span = SpanV1.unpack(unpacker(packer), new ArrayList<>()); - assertEquals(UNSIGNED_ID_BITS, span.getSpanId(), "span id above Long.MAX_VALUE"); - assertEquals(UNSIGNED_ID_BITS, span.getParentId(), "parent id above Long.MAX_VALUE"); - } + assertEquals(UNSIGNED_ID_BITS, span.getSpanId(), "span id above Long.MAX_VALUE"); + assertEquals(UNSIGNED_ID_BITS, span.getParentId(), "parent id above Long.MAX_VALUE"); + } - @Test - void decodesUnsignedSignedAndZeroSpanIdsAlike() throws IOException { - assertEquals(UNSIGNED_ID_BITS, spanIdOf(new BigInteger(UNSIGNED_ID))); - assertEquals(42L, spanIdOf(BigInteger.valueOf(42))); - assertEquals(0L, spanIdOf(BigInteger.ZERO)); - } + @Test + void decodesUnsignedSignedAndZeroSpanIdsAlike() throws IOException { + assertEquals(UNSIGNED_ID_BITS, spanIdOf(new BigInteger(UNSIGNED_ID))); + assertEquals(42L, spanIdOf(BigInteger.valueOf(42))); + assertEquals(0L, spanIdOf(BigInteger.ZERO)); + } - @Test - void decodesUnsignedLinkSpanId() throws IOException { - MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); - packer.packArrayHeader(1); - packer.packMapHeader(2); - packer.packInt(SpanV1.LINK_FIELD_TRACE_ID); - packer.packBinaryHeader(16); - packer.writePayload(traceIdBytes()); - packer.packInt(SpanV1.LINK_FIELD_SPAN_ID); - packer.packBigInteger(new BigInteger(UNSIGNED_ID)); + @Test + void decodesUnsignedLinkSpanId() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packArrayHeader(1); + packer.packMapHeader(2); + packer.packInt(SpanV1.LINK_FIELD_TRACE_ID); + packer.packBinaryHeader(16); + packer.writePayload(traceIdBytes()); + packer.packInt(SpanV1.LINK_FIELD_SPAN_ID); + packer.packBigInteger(new BigInteger(UNSIGNED_ID)); - List links = SpanV1.unpackSpanLinks(unpacker(packer), new ArrayList<>()); + List links = SpanV1.unpackSpanLinks(unpacker(packer), new ArrayList<>()); - assertEquals(1, links.size()); - assertEquals(UNSIGNED_ID_BITS, links.get(0).getSpanId(), "link span id above Long.MAX_VALUE"); - assertEquals(UNSIGNED_ID_BITS, links.get(0).getTraceId(), "link trace id low-order half"); - } + assertEquals(1, links.size()); + assertEquals(UNSIGNED_ID_BITS, links.get(0).getSpanId(), "link span id above Long.MAX_VALUE"); + assertEquals(UNSIGNED_ID_BITS, links.get(0).getTraceId(), "link trace id low-order half"); + } - @Test - void noSpanLinksDecodeToAnEmptyList() throws IOException { - MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); - packer.packArrayHeader(0); + @Test + void noSpanLinksDecodeToAnEmptyList() throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packArrayHeader(0); - assertEquals(emptyList(), SpanV1.unpackSpanLinks(unpacker(packer), new ArrayList<>())); - } + assertEquals(emptyList(), SpanV1.unpackSpanLinks(unpacker(packer), new ArrayList<>())); + } - private static long spanIdOf(BigInteger id) throws IOException { - MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); - packer.packMapHeader(1); - packer.packInt(SpanV1.SPAN_FIELD_SPAN_ID); - packer.packBigInteger(id); - return SpanV1.unpack(unpacker(packer), new ArrayList<>()).getSpanId(); - } + private static long spanIdOf(BigInteger id) throws IOException { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + packer.packMapHeader(1); + packer.packInt(SpanV1.SPAN_FIELD_SPAN_ID); + packer.packBigInteger(id); + return SpanV1.unpack(unpacker(packer), new ArrayList<>()).getSpanId(); + } - /** A 16-byte trace identifier whose low-order half is {@link #UNSIGNED_ID_BITS}. */ - private static byte[] traceIdBytes() { - byte[] bytes = new byte[16]; - for (int i = 0; i < 8; i++) { - bytes[8 + i] = (byte) (UNSIGNED_ID_BITS >>> (8 * (7 - i))); + /** A 16-byte trace identifier whose low-order half is {@link #UNSIGNED_ID_BITS}. */ + private static byte[] traceIdBytes() { + byte[] bytes = new byte[16]; + for (int i = 0; i < 8; i++) { + bytes[8 + i] = (byte) (UNSIGNED_ID_BITS >>> (8 * (7 - i))); + } + return bytes; } - return bytes; - } - private static MessageUnpacker unpacker(MessageBufferPacker packer) throws IOException { - packer.close(); - return MessagePack.newDefaultUnpacker(packer.toByteArray()); - } + private static MessageUnpacker unpacker(MessageBufferPacker packer) throws IOException { + packer.close(); + return MessagePack.newDefaultUnpacker(packer.toByteArray()); + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java index f68d4f813c0..87c5380ecbf 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/AbstractClassConstantConvertor.java @@ -6,61 +6,59 @@ import org.junit.jupiter.params.converter.ArgumentConverter; public abstract class AbstractClassConstantConvertor implements ArgumentConverter { - /** - * Returns the class name (simple class name, not fully qualified) of the constants to convert. - * - * @return The simple class name. - */ - protected abstract String className(); + /** + * Returns the class name (simple class name, not fully qualified) of the constants to convert. + * + * @return The simple class name. + */ + protected abstract String className(); - /** - * Returns the constant mapping between their string representations to their values. - * - * @return The constant mapping between their string representations to their values. - */ - protected abstract Map mapping(); + /** + * Returns the constant mapping between their string representations to their values. + * + * @return The constant mapping between their string representations to their values. + */ + protected abstract Map mapping(); - @Override - public T convert(Object source, ParameterContext context) { - if (source == null) { - return null; - } - String className = className(); - int length = className.length(); - String s = source.toString(); - if (s.startsWith(className) && s.charAt(length) == '.') { - s = s.substring(length + 1); - } - T mappedValue = mapping().get(s); - if (mappedValue == null) { - if (throwsOnUnsupportedValue()) { - throw new ArgumentConversionException( - "Unsupported constant " + source + " from " + className); - } - return convertWhenNoMapping(source); + @Override + public T convert(Object source, ParameterContext context) { + if (source == null) { + return null; + } + String className = className(); + int length = className.length(); + String s = source.toString(); + if (s.startsWith(className) && s.charAt(length) == '.') { + s = s.substring(length + 1); + } + T mappedValue = mapping().get(s); + if (mappedValue == null) { + if (throwsOnUnsupportedValue()) { + throw new ArgumentConversionException("Unsupported constant " + source + " from " + className); + } + return convertWhenNoMapping(source); + } + return mappedValue; } - return mappedValue; - } - protected boolean throwsOnUnsupportedValue() { - return true; - } - - protected T convertWhenNoMapping(Object source) { - return null; - } + protected boolean throwsOnUnsupportedValue() { + return true; + } - public abstract static class AbstractStringFallThruConverter - extends AbstractClassConstantConvertor { - @Override - public String convert(Object source, ParameterContext context) { - String convert = super.convert(source, context); - return convert == null ? source.toString() : convert; + protected T convertWhenNoMapping(Object source) { + return null; } - @Override - protected boolean throwsOnUnsupportedValue() { - return false; + public abstract static class AbstractStringFallThruConverter extends AbstractClassConstantConvertor { + @Override + public String convert(Object source, ParameterContext context) { + String convert = super.convert(source, context); + return convert == null ? source.toString() : convert; + } + + @Override + protected boolean throwsOnUnsupportedValue() { + return false; + } } - } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ConfigDefaultsConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ConfigDefaultsConverter.java index 856b73ebbba..c803698ca8b 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ConfigDefaultsConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ConfigDefaultsConverter.java @@ -8,22 +8,21 @@ import java.util.Map; public class ConfigDefaultsConverter extends AbstractStringFallThruConverter { - private static final Map MAPPING; + private static final Map MAPPING; - static { - MAPPING = new HashMap<>(); - MAPPING.put("DEFAULT_SERVICE_NAME", DEFAULT_SERVICE_NAME); - MAPPING.put( - "DEFAULT_SERVLET_ROOT_CONTEXT_SERVICE_NAME", DEFAULT_SERVLET_ROOT_CONTEXT_SERVICE_NAME); - } + static { + MAPPING = new HashMap<>(); + MAPPING.put("DEFAULT_SERVICE_NAME", DEFAULT_SERVICE_NAME); + MAPPING.put("DEFAULT_SERVLET_ROOT_CONTEXT_SERVICE_NAME", DEFAULT_SERVLET_ROOT_CONTEXT_SERVICE_NAME); + } - @Override - protected String className() { - return "ConfigDefaults"; - } + @Override + protected String className() { + return "ConfigDefaults"; + } - @Override - protected Map mapping() { - return MAPPING; - } + @Override + protected Map mapping() { + return MAPPING; + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/DDSpanTypesConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/DDSpanTypesConverter.java index 24cc9190908..01f4c18f589 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/DDSpanTypesConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/DDSpanTypesConverter.java @@ -37,51 +37,51 @@ import java.util.Map; public class DDSpanTypesConverter extends AbstractClassConstantConvertor { - private static final Map MAPPING; + private static final Map MAPPING; - static { - MAPPING = new HashMap<>(); - MAPPING.put("HTTP_CLIENT", HTTP_CLIENT); - MAPPING.put("HTTP_SERVER", HTTP_SERVER); - MAPPING.put("RPC", RPC); - MAPPING.put("CACHE", CACHE); - MAPPING.put("SOAP", SOAP); - MAPPING.put("SQL", SQL); - MAPPING.put("MONGO", MONGO); - MAPPING.put("CASSANDRA", CASSANDRA); - MAPPING.put("COUCHBASE", COUCHBASE); - MAPPING.put("REDIS", REDIS); - MAPPING.put("MEMCACHED", MEMCACHED); - MAPPING.put("ELASTICSEARCH", ELASTICSEARCH); - MAPPING.put("OPENSEARCH", OPENSEARCH); - MAPPING.put("HIBERNATE", HIBERNATE); - MAPPING.put("AEROSPIKE", AEROSPIKE); - MAPPING.put("DATANUCLEUS", DATANUCLEUS); - MAPPING.put("MESSAGE_CLIENT", MESSAGE_CLIENT); - MAPPING.put("MESSAGE_CONSUMER", MESSAGE_CONSUMER); - MAPPING.put("MESSAGE_PRODUCER", MESSAGE_PRODUCER); - MAPPING.put("MESSAGE_BROKER", MESSAGE_BROKER); - MAPPING.put("GRAPHQL", GRAPHQL); - MAPPING.put("TEST", TEST); - MAPPING.put("TEST_SUITE_END", TEST_SUITE_END); - MAPPING.put("TEST_MODULE_END", TEST_MODULE_END); - MAPPING.put("TEST_SESSION_END", TEST_SESSION_END); - MAPPING.put("VULNERABILITY", VULNERABILITY); - MAPPING.put("PROTOBUF", PROTOBUF); - MAPPING.put("MULE", MULE); - MAPPING.put("VALKEY", VALKEY); - MAPPING.put("WEBSOCKET", WEBSOCKET); - MAPPING.put("SERVERLESS", SERVERLESS); - MAPPING.put("LLMOBS", LLMOBS); - } + static { + MAPPING = new HashMap<>(); + MAPPING.put("HTTP_CLIENT", HTTP_CLIENT); + MAPPING.put("HTTP_SERVER", HTTP_SERVER); + MAPPING.put("RPC", RPC); + MAPPING.put("CACHE", CACHE); + MAPPING.put("SOAP", SOAP); + MAPPING.put("SQL", SQL); + MAPPING.put("MONGO", MONGO); + MAPPING.put("CASSANDRA", CASSANDRA); + MAPPING.put("COUCHBASE", COUCHBASE); + MAPPING.put("REDIS", REDIS); + MAPPING.put("MEMCACHED", MEMCACHED); + MAPPING.put("ELASTICSEARCH", ELASTICSEARCH); + MAPPING.put("OPENSEARCH", OPENSEARCH); + MAPPING.put("HIBERNATE", HIBERNATE); + MAPPING.put("AEROSPIKE", AEROSPIKE); + MAPPING.put("DATANUCLEUS", DATANUCLEUS); + MAPPING.put("MESSAGE_CLIENT", MESSAGE_CLIENT); + MAPPING.put("MESSAGE_CONSUMER", MESSAGE_CONSUMER); + MAPPING.put("MESSAGE_PRODUCER", MESSAGE_PRODUCER); + MAPPING.put("MESSAGE_BROKER", MESSAGE_BROKER); + MAPPING.put("GRAPHQL", GRAPHQL); + MAPPING.put("TEST", TEST); + MAPPING.put("TEST_SUITE_END", TEST_SUITE_END); + MAPPING.put("TEST_MODULE_END", TEST_MODULE_END); + MAPPING.put("TEST_SESSION_END", TEST_SESSION_END); + MAPPING.put("VULNERABILITY", VULNERABILITY); + MAPPING.put("PROTOBUF", PROTOBUF); + MAPPING.put("MULE", MULE); + MAPPING.put("VALKEY", VALKEY); + MAPPING.put("WEBSOCKET", WEBSOCKET); + MAPPING.put("SERVERLESS", SERVERLESS); + MAPPING.put("LLMOBS", LLMOBS); + } - @Override - protected String className() { - return "DDSpanTypes"; - } + @Override + protected String className() { + return "DDSpanTypes"; + } - @Override - protected Map mapping() { - return MAPPING; - } + @Override + protected Map mapping() { + return MAPPING; + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/PrioritySamplingConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/PrioritySamplingConverter.java index 958d114048e..4afc870a7c8 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/PrioritySamplingConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/PrioritySamplingConverter.java @@ -10,24 +10,24 @@ import java.util.Map; public class PrioritySamplingConverter extends AbstractClassConstantConvertor { - private static final Map MAPPING; + private static final Map MAPPING; - static { - MAPPING = new HashMap<>(); - MAPPING.put("UNSET", UNSET); - MAPPING.put("SAMPLER_KEEP", SAMPLER_KEEP); - MAPPING.put("SAMPLER_DROP", SAMPLER_DROP); - MAPPING.put("USER_DROP", USER_DROP); - MAPPING.put("USER_KEEP", USER_KEEP); - } + static { + MAPPING = new HashMap<>(); + MAPPING.put("UNSET", UNSET); + MAPPING.put("SAMPLER_KEEP", SAMPLER_KEEP); + MAPPING.put("SAMPLER_DROP", SAMPLER_DROP); + MAPPING.put("USER_DROP", USER_DROP); + MAPPING.put("USER_KEEP", USER_KEEP); + } - @Override - protected String className() { - return "PrioritySampling"; - } + @Override + protected String className() { + return "PrioritySampling"; + } - @Override - protected Map mapping() { - return MAPPING; - } + @Override + protected Map mapping() { + return MAPPING; + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ProductTraceSourceConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ProductTraceSourceConverter.java index e41876d964f..f964672a48c 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ProductTraceSourceConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/ProductTraceSourceConverter.java @@ -10,24 +10,24 @@ import java.util.Map; public class ProductTraceSourceConverter extends AbstractClassConstantConvertor { - private static final Map MAPPING; + private static final Map MAPPING; - static { - MAPPING = new HashMap<>(); - MAPPING.put("UNSET", UNSET); - MAPPING.put("APM", APM); - MAPPING.put("ASM", ASM); - MAPPING.put("DSM", DSM); - MAPPING.put("DBM", DBM); - } + static { + MAPPING = new HashMap<>(); + MAPPING.put("UNSET", UNSET); + MAPPING.put("APM", APM); + MAPPING.put("ASM", ASM); + MAPPING.put("DSM", DSM); + MAPPING.put("DBM", DBM); + } - @Override - protected String className() { - return "ProductTraceSource"; - } + @Override + protected String className() { + return "ProductTraceSource"; + } - @Override - protected Map mapping() { - return MAPPING; - } + @Override + protected Map mapping() { + return MAPPING; + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java index 2b30a4e82dd..d8b49f609ea 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/SamplingMechanismConverter.java @@ -19,43 +19,43 @@ import java.util.Map; public class SamplingMechanismConverter extends AbstractClassConstantConvertor { - private static final Map MAPPING; - - static { - MAPPING = new HashMap<>(); - MAPPING.put("UNKNOWN", UNKNOWN); - MAPPING.put("DEFAULT", DEFAULT); - MAPPING.put("AGENT_RATE", AGENT_RATE); - MAPPING.put("REMOTE_AUTO_RATE", REMOTE_AUTO_RATE); - MAPPING.put("LOCAL_USER_RULE", LOCAL_USER_RULE); - MAPPING.put("MANUAL", MANUAL); - MAPPING.put("APPSEC", APPSEC); - MAPPING.put("REMOTE_USER_RATE", REMOTE_USER_RATE); - MAPPING.put("SPAN_SAMPLING_RATE", SPAN_SAMPLING_RATE); - MAPPING.put("DATA_JOBS", DATA_JOBS); - MAPPING.put("REMOTE_USER_RULE", REMOTE_USER_RULE); - MAPPING.put("REMOTE_ADAPTIVE_RULE", REMOTE_ADAPTIVE_RULE); - MAPPING.put("AI_GUARD", AI_GUARD); - MAPPING.put("EXTERNAL_OVERRIDE", EXTERNAL_OVERRIDE); - } - - @Override - protected String className() { - return "SamplingMechanism"; - } - - @Override - protected Map mapping() { - return MAPPING; - } - - @Override - protected boolean throwsOnUnsupportedValue() { - return false; - } - - @Override - protected Byte convertWhenNoMapping(Object source) { - return Byte.parseByte((String) source); - } + private static final Map MAPPING; + + static { + MAPPING = new HashMap<>(); + MAPPING.put("UNKNOWN", UNKNOWN); + MAPPING.put("DEFAULT", DEFAULT); + MAPPING.put("AGENT_RATE", AGENT_RATE); + MAPPING.put("REMOTE_AUTO_RATE", REMOTE_AUTO_RATE); + MAPPING.put("LOCAL_USER_RULE", LOCAL_USER_RULE); + MAPPING.put("MANUAL", MANUAL); + MAPPING.put("APPSEC", APPSEC); + MAPPING.put("REMOTE_USER_RATE", REMOTE_USER_RATE); + MAPPING.put("SPAN_SAMPLING_RATE", SPAN_SAMPLING_RATE); + MAPPING.put("DATA_JOBS", DATA_JOBS); + MAPPING.put("REMOTE_USER_RULE", REMOTE_USER_RULE); + MAPPING.put("REMOTE_ADAPTIVE_RULE", REMOTE_ADAPTIVE_RULE); + MAPPING.put("AI_GUARD", AI_GUARD); + MAPPING.put("EXTERNAL_OVERRIDE", EXTERNAL_OVERRIDE); + } + + @Override + protected String className() { + return "SamplingMechanism"; + } + + @Override + protected Map mapping() { + return MAPPING; + } + + @Override + protected boolean throwsOnUnsupportedValue() { + return false; + } + + @Override + protected Byte convertWhenNoMapping(Object source) { + return Byte.parseByte((String) source); + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TagsConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TagsConverter.java index ad5ab38402f..5e4b58bcd6f 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TagsConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TagsConverter.java @@ -22,45 +22,45 @@ import java.util.Map; public class TagsConverter extends AbstractStringFallThruConverter { - private static final Map MAPPING; + private static final Map MAPPING; - static { - MAPPING = new HashMap<>(); - // Tags mapping (class name will be trimmed) - MAPPING.put("SPAN_KIND_SERVER", SPAN_KIND_SERVER); - MAPPING.put("SPAN_KIND_CLIENT", SPAN_KIND_CLIENT); - MAPPING.put("SPAN_KIND_PRODUCER", SPAN_KIND_PRODUCER); - MAPPING.put("SPAN_KIND_CONSUMER", SPAN_KIND_CONSUMER); - MAPPING.put("SPAN_KIND_BROKER", SPAN_KIND_BROKER); - MAPPING.put("PEER_SERVICE", PEER_SERVICE); - MAPPING.put("HTTP_URL", HTTP_URL); - MAPPING.put("HTTP_STATUS", HTTP_STATUS); - MAPPING.put("HTTP_METHOD", HTTP_METHOD); - // DDTags mapping with class name - MAPPING.put("DDTags.SPAN_TYPE", SPAN_TYPE); - MAPPING.put("DDTags.SERVICE_NAME", SERVICE_NAME); - MAPPING.put("DDTags.RESOURCE_NAME", RESOURCE_NAME); - MAPPING.put("DDTags.THREAD_NAME", THREAD_NAME); - MAPPING.put("DDTags.THREAD_ID", THREAD_ID); - MAPPING.put("DDTags.MANUAL_KEEP", MANUAL_KEEP); - MAPPING.put("DDTags.MANUAL_DROP", MANUAL_DROP); - // DDTags mapping with direct field name - MAPPING.put("SPAN_TYPE", SPAN_TYPE); - MAPPING.put("SERVICE_NAME", SERVICE_NAME); - MAPPING.put("RESOURCE_NAME", RESOURCE_NAME); - MAPPING.put("THREAD_NAME", THREAD_NAME); - MAPPING.put("THREAD_ID", THREAD_ID); - MAPPING.put("MANUAL_KEEP", MANUAL_KEEP); - MAPPING.put("MANUAL_DROP", MANUAL_DROP); - } + static { + MAPPING = new HashMap<>(); + // Tags mapping (class name will be trimmed) + MAPPING.put("SPAN_KIND_SERVER", SPAN_KIND_SERVER); + MAPPING.put("SPAN_KIND_CLIENT", SPAN_KIND_CLIENT); + MAPPING.put("SPAN_KIND_PRODUCER", SPAN_KIND_PRODUCER); + MAPPING.put("SPAN_KIND_CONSUMER", SPAN_KIND_CONSUMER); + MAPPING.put("SPAN_KIND_BROKER", SPAN_KIND_BROKER); + MAPPING.put("PEER_SERVICE", PEER_SERVICE); + MAPPING.put("HTTP_URL", HTTP_URL); + MAPPING.put("HTTP_STATUS", HTTP_STATUS); + MAPPING.put("HTTP_METHOD", HTTP_METHOD); + // DDTags mapping with class name + MAPPING.put("DDTags.SPAN_TYPE", SPAN_TYPE); + MAPPING.put("DDTags.SERVICE_NAME", SERVICE_NAME); + MAPPING.put("DDTags.RESOURCE_NAME", RESOURCE_NAME); + MAPPING.put("DDTags.THREAD_NAME", THREAD_NAME); + MAPPING.put("DDTags.THREAD_ID", THREAD_ID); + MAPPING.put("DDTags.MANUAL_KEEP", MANUAL_KEEP); + MAPPING.put("DDTags.MANUAL_DROP", MANUAL_DROP); + // DDTags mapping with direct field name + MAPPING.put("SPAN_TYPE", SPAN_TYPE); + MAPPING.put("SERVICE_NAME", SERVICE_NAME); + MAPPING.put("RESOURCE_NAME", RESOURCE_NAME); + MAPPING.put("THREAD_NAME", THREAD_NAME); + MAPPING.put("THREAD_ID", THREAD_ID); + MAPPING.put("MANUAL_KEEP", MANUAL_KEEP); + MAPPING.put("MANUAL_DROP", MANUAL_DROP); + } - @Override - protected String className() { - return "Tags"; - } + @Override + protected String className() { + return "Tags"; + } - @Override - protected Map mapping() { - return MAPPING; - } + @Override + protected Map mapping() { + return MAPPING; + } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TraceIdConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TraceIdConverter.java index db2ec553dbe..da7cb55b305 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TraceIdConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/converter/TraceIdConverter.java @@ -22,42 +22,42 @@ *

      All other values are passed through unchanged. */ public class TraceIdConverter implements ArgumentConverter { - // 2^64 - 1 - public static final String TRACE_ID_MAX = Long.toUnsignedString(-1L); - // 2^64 - 2 - public static final String TRACE_ID_MAX_MINUS_1 = Long.toUnsignedString(-2L); - // 2^64 (first out-of-range value) - public static final String TRACE_ID_MAX_PLUS_1 = new BigInteger(TRACE_ID_MAX).add(ONE).toString(); + // 2^64 - 1 + public static final String TRACE_ID_MAX = Long.toUnsignedString(-1L); + // 2^64 - 2 + public static final String TRACE_ID_MAX_MINUS_1 = Long.toUnsignedString(-2L); + // 2^64 (first out-of-range value) + public static final String TRACE_ID_MAX_PLUS_1 = + new BigInteger(TRACE_ID_MAX).add(ONE).toString(); - @Override - public Object convert(Object source, ParameterContext context) - throws ArgumentConversionException { - if (source == null) { - return null; - } - String s = source.toString(); - String traceId; - switch (s) { - case "MAX": - traceId = TRACE_ID_MAX; - break; - case "MAX-1": - traceId = TRACE_ID_MAX_MINUS_1; - break; - case "MAX+1": - traceId = TRACE_ID_MAX_PLUS_1; - break; - default: - traceId = s; - } + @Override + public Object convert(Object source, ParameterContext context) throws ArgumentConversionException { + if (source == null) { + return null; + } + String s = source.toString(); + String traceId; + switch (s) { + case "MAX": + traceId = TRACE_ID_MAX; + break; + case "MAX-1": + traceId = TRACE_ID_MAX_MINUS_1; + break; + case "MAX+1": + traceId = TRACE_ID_MAX_PLUS_1; + break; + default: + traceId = s; + } - Class parameterType = context.getParameter().getType(); - if (parameterType.isAssignableFrom(DDTraceId.class)) { - return DDTraceId.from(s); - } else if (parameterType.isAssignableFrom(Long.class)) { - return DDTraceId.from(s).toLong(); - } else { - return traceId; + Class parameterType = context.getParameter().getType(); + if (parameterType.isAssignableFrom(DDTraceId.class)) { + return DDTraceId.from(s); + } else if (parameterType.isAssignableFrom(Long.class)) { + return DDTraceId.from(s).toLong(); + } else { + return traceId; + } } - } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/BoxedValueConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/BoxedValueConverter.java index c91eeb62daf..ab3b8d5d616 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/BoxedValueConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/BoxedValueConverter.java @@ -22,39 +22,38 @@ */ public class BoxedValueConverter implements ArgumentConverter { - @Override - public Object convert(Object source, ParameterContext context) - throws ArgumentConversionException { - if (source == null) { - return null; - } + @Override + public Object convert(Object source, ParameterContext context) throws ArgumentConversionException { + if (source == null) { + return null; + } - String s = source.toString(); - switch (s) { - case "": - return null; - case "true": - return Boolean.TRUE; - case "false": - return Boolean.FALSE; - } - if (s.endsWith("f")) { - try { - return Float.parseFloat(s.substring(0, s.length() - 1)); - } catch (NumberFormatException ignored) { - } - } + String s = source.toString(); + switch (s) { + case "": + return null; + case "true": + return Boolean.TRUE; + case "false": + return Boolean.FALSE; + } + if (s.endsWith("f")) { + try { + return Float.parseFloat(s.substring(0, s.length() - 1)); + } catch (NumberFormatException ignored) { + } + } - if (s.contains(".")) { - try { - return Double.parseDouble(s); - } catch (NumberFormatException ignored) { - } - } - try { - return Integer.parseInt(s); - } catch (NumberFormatException ignored) { + if (s.contains(".")) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException ignored) { + } + } + try { + return Integer.parseInt(s); + } catch (NumberFormatException ignored) { + } + return s; } - return s; - } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/ConfigValueConverter.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/ConfigValueConverter.java index 7ccf1ff9df3..fe32e08ca5a 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/ConfigValueConverter.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/ConfigValueConverter.java @@ -20,39 +20,38 @@ */ public class ConfigValueConverter implements ArgumentConverter { - private static final String BITSET_PREFIX = "bits("; - private static final Pattern COMMA = Pattern.compile(","); + private static final String BITSET_PREFIX = "bits("; + private static final Pattern COMMA = Pattern.compile(","); - @Override - public Object convert(Object source, ParameterContext context) - throws ArgumentConversionException { - if (source == null) return null; - if (source instanceof String) { - String s = ((String) source).trim(); - if (s.startsWith(BITSET_PREFIX) && s.endsWith(")")) { - return parseBitSet(s.substring(BITSET_PREFIX.length(), s.length() - 1)); - } + @Override + public Object convert(Object source, ParameterContext context) throws ArgumentConversionException { + if (source == null) return null; + if (source instanceof String) { + String s = ((String) source).trim(); + if (s.startsWith(BITSET_PREFIX) && s.endsWith(")")) { + return parseBitSet(s.substring(BITSET_PREFIX.length(), s.length() - 1)); + } + } + // Lists and maps are already parsed by TableTest + return source; } - // Lists and maps are already parsed by TableTest - return source; - } - private static BitSet parseBitSet(String intervals) { - BitSet bitSet = new BitSet(); - for (String token : COMMA.split(intervals)) { - String trimmed = token.trim(); - if (trimmed.isEmpty()) { - continue; - } - int dash = trimmed.indexOf('-'); - if (dash > 0) { - int start = Integer.parseInt(trimmed.substring(0, dash).trim()); - int end = Integer.parseInt(trimmed.substring(dash + 1).trim()); - bitSet.set(start, end); - } else { - bitSet.set(Integer.parseInt(trimmed)); - } + private static BitSet parseBitSet(String intervals) { + BitSet bitSet = new BitSet(); + for (String token : COMMA.split(intervals)) { + String trimmed = token.trim(); + if (trimmed.isEmpty()) { + continue; + } + int dash = trimmed.indexOf('-'); + if (dash > 0) { + int start = Integer.parseInt(trimmed.substring(0, dash).trim()); + int end = Integer.parseInt(trimmed.substring(dash + 1).trim()); + bitSet.set(start, end); + } else { + bitSet.set(Integer.parseInt(trimmed)); + } + } + return bitSet; } - return bitSet; - } } diff --git a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java index 72b8e3a129b..ff3323cdf89 100644 --- a/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java +++ b/utils/test-junit-converter-utils/src/main/java/datadog/trace/test/junit/utils/tabletest/TableTestTypeConverters.java @@ -7,94 +7,94 @@ /** Shared converters for JUnit 5 TableTest tests that use unparsable constants. */ public final class TableTestTypeConverters { - private TableTestTypeConverters() {} + private TableTestTypeConverters() {} - @TypeConverter - public static TrackType toTrackType(String value) { - if (value == null) { - return null; - } - String token = value.trim(); - if (token.startsWith("TrackType.")) { - token = token.substring("TrackType.".length()); + @TypeConverter + public static TrackType toTrackType(String value) { + if (value == null) { + return null; + } + String token = value.trim(); + if (token.startsWith("TrackType.")) { + token = token.substring("TrackType.".length()); + } + return TrackType.valueOf(token); } - return TrackType.valueOf(token); - } - @TypeConverter - public static ProtocolVersion toProtocolVersion(String value) { - if (value == null) { - return null; - } - String token = value.trim(); - if (token.startsWith("ProtocolVersion.")) { - token = token.substring("ProtocolVersion.".length()); + @TypeConverter + public static ProtocolVersion toProtocolVersion(String value) { + if (value == null) { + return null; + } + String token = value.trim(); + if (token.startsWith("ProtocolVersion.")) { + token = token.substring("ProtocolVersion.".length()); + } + return ProtocolVersion.valueOf(token); } - return ProtocolVersion.valueOf(token); - } - @TypeConverter - public static long toLong(String value) { - if (value == null) { - throw new IllegalArgumentException("Value cannot be null"); - } - String token = value.trim(); - switch (token) { - case "Long.MAX_VALUE": - return Long.MAX_VALUE; - case "Long.MIN_VALUE": - return Long.MIN_VALUE; - default: - return Long.decode(token); + @TypeConverter + public static long toLong(String value) { + if (value == null) { + throw new IllegalArgumentException("Value cannot be null"); + } + String token = value.trim(); + switch (token) { + case "Long.MAX_VALUE": + return Long.MAX_VALUE; + case "Long.MIN_VALUE": + return Long.MIN_VALUE; + default: + return Long.decode(token); + } } - } - @TypeConverter - public static int toInt(String value) { - if (value == null) { - throw new IllegalArgumentException("Value cannot be null"); - } - String token = value.trim(); - switch (token) { - case "Integer.MAX_VALUE": - return Integer.MAX_VALUE; - case "Integer.MIN_VALUE": - return Integer.MIN_VALUE; - default: - return Integer.decode(token); + @TypeConverter + public static int toInt(String value) { + if (value == null) { + throw new IllegalArgumentException("Value cannot be null"); + } + String token = value.trim(); + switch (token) { + case "Integer.MAX_VALUE": + return Integer.MAX_VALUE; + case "Integer.MIN_VALUE": + return Integer.MIN_VALUE; + default: + return Integer.decode(token); + } } - } - @TypeConverter - public static Number toNumber(String value) { - if (value == null) { - throw new IllegalArgumentException("Value cannot be null"); - } - switch (value) { - case "Integer.MAX_VALUE": - return Integer.MAX_VALUE; - case "Integer.MIN_VALUE": - return Integer.MIN_VALUE; - case "Short.MAX_VALUE": - return Short.MAX_VALUE; - case "Short.MIN_VALUE": - return Short.MIN_VALUE; - case "Float.MAX_VALUE": - return Float.MAX_VALUE; - case "Float.MIN_VALUE": - return Float.MIN_VALUE; - case "Double.MAX_VALUE": - return Double.MAX_VALUE; - case "Double.MIN_VALUE": - return Double.MIN_VALUE; - default: - if (value.endsWith("f")) { - return Float.parseFloat(value); + @TypeConverter + public static Number toNumber(String value) { + if (value == null) { + throw new IllegalArgumentException("Value cannot be null"); } - if (value.endsWith("d") || value.contains(".")) { - return Double.parseDouble(value); + switch (value) { + case "Integer.MAX_VALUE": + return Integer.MAX_VALUE; + case "Integer.MIN_VALUE": + return Integer.MIN_VALUE; + case "Short.MAX_VALUE": + return Short.MAX_VALUE; + case "Short.MIN_VALUE": + return Short.MIN_VALUE; + case "Float.MAX_VALUE": + return Float.MAX_VALUE; + case "Float.MIN_VALUE": + return Float.MIN_VALUE; + case "Double.MAX_VALUE": + return Double.MAX_VALUE; + case "Double.MIN_VALUE": + return Double.MIN_VALUE; + default: + if (value.endsWith("f")) { + return Float.parseFloat(value); + } + if (value.endsWith("d") || value.contains(".")) { + return Double.parseDouble(value); + } + return Integer.decode(value); } - return Integer.decode(value); } - } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Any.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Any.java index a7a506f9457..f309ee75a38 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Any.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Any.java @@ -11,20 +11,20 @@ * @param the type of the value being matched */ public class Any implements Matcher { - Any() {} + Any() {} - @Override - public Optional expected() { - return Optional.empty(); - } + @Override + public Optional expected() { + return Optional.empty(); + } - @Override - public String failureReason() { - return ""; - } + @Override + public String failureReason() { + return ""; + } - @Override - public boolean test(T t) { - return true; - } + @Override + public boolean test(T t) { + return true; + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Is.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Is.java index f7a3346e64e..ecd114119bd 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Is.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Is.java @@ -9,24 +9,24 @@ * @param The type of the value being matched. */ public class Is implements Matcher { - private final T expected; + private final T expected; - Is(T expected) { - this.expected = expected; - } + Is(T expected) { + this.expected = expected; + } - @Override - public Optional expected() { - return Optional.of(this.expected); - } + @Override + public Optional expected() { + return Optional.of(this.expected); + } - @Override - public String failureReason() { - return "Unexpected value"; - } + @Override + public String failureReason() { + return "Unexpected value"; + } - @Override - public boolean test(T t) { - return this.expected.equals(t); - } + @Override + public boolean test(T t) { + return this.expected.equals(t); + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsFalse.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsFalse.java index a22a746a69b..75156d84bca 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsFalse.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsFalse.java @@ -4,20 +4,20 @@ /** A {@link Matcher} implementation that checks if a given boolean value is {@code false}. */ public class IsFalse implements Matcher { - IsFalse() {} + IsFalse() {} - @Override - public Optional expected() { - return Optional.of(false); - } + @Override + public Optional expected() { + return Optional.of(false); + } - @Override - public String failureReason() { - return "False expected"; - } + @Override + public String failureReason() { + return "False expected"; + } - @Override - public boolean test(Boolean t) { - return !t; - } + @Override + public boolean test(Boolean t) { + return !t; + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNonNull.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNonNull.java index 91246e13b40..d74ac5941e5 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNonNull.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNonNull.java @@ -8,20 +8,20 @@ * @param The type of the value being matched. */ public class IsNonNull implements Matcher { - IsNonNull() {} + IsNonNull() {} - @Override - public Optional expected() { - return Optional.empty(); - } + @Override + public Optional expected() { + return Optional.empty(); + } - @Override - public String failureReason() { - return "Non-null value expected"; - } + @Override + public String failureReason() { + return "Non-null value expected"; + } - @Override - public boolean test(T t) { - return t != null; - } + @Override + public boolean test(T t) { + return t != null; + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNull.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNull.java index 5a4a4b32892..b26a8696b76 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNull.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsNull.java @@ -8,20 +8,20 @@ * @param The type of the value being matched. */ public class IsNull implements Matcher { - IsNull() {} + IsNull() {} - @Override - public Optional expected() { - return Optional.empty(); - } + @Override + public Optional expected() { + return Optional.empty(); + } - @Override - public String failureReason() { - return "Null value expected"; - } + @Override + public String failureReason() { + return "Null value expected"; + } - @Override - public boolean test(T t) { - return t == null; - } + @Override + public boolean test(T t) { + return t == null; + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsTrue.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsTrue.java index e56f0c97d37..4841455b9e9 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsTrue.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/IsTrue.java @@ -4,20 +4,20 @@ /** A {@link Matcher} implementation that checks if a given boolean value is {@code true}. */ public class IsTrue implements Matcher { - IsTrue() {} + IsTrue() {} - @Override - public Optional expected() { - return Optional.of(true); - } + @Override + public Optional expected() { + return Optional.of(true); + } - @Override - public String failureReason() { - return "True expected"; - } + @Override + public String failureReason() { + return "True expected"; + } - @Override - public boolean test(Boolean t) { - return t; - } + @Override + public boolean test(Boolean t) { + return t; + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matcher.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matcher.java index 9395097e35a..a54ca0353b5 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matcher.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matcher.java @@ -10,18 +10,18 @@ * @param the type of the value being matched. */ public interface Matcher extends Predicate { - /** - * Gets the expected value for this matcher, if any. - * - * @return The expected value wrapped into an {@link Optional}, or {@link Optional#empty()} if no - * specific value is expected. - */ - Optional expected(); + /** + * Gets the expected value for this matcher, if any. + * + * @return The expected value wrapped into an {@link Optional}, or {@link Optional#empty()} if no + * specific value is expected. + */ + Optional expected(); - /** - * Explains the reason why the value does not match the expectation. - * - * @return The failure reason. - */ - String failureReason(); + /** + * Explains the reason why the value does not match the expectation. + * + * @return The failure reason. + */ + String failureReason(); } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matchers.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matchers.java index 8be26fd99ce..82bf48835ec 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matchers.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matchers.java @@ -9,118 +9,117 @@ /** This class is a utility class to create generic matchers. */ public final class Matchers { - private Matchers() {} + private Matchers() {} - /** - * Creates a matcher that checks if the provided value is equal to the expected value. - * - * @param The type of the value being matched. - * @param expected The value to compare against - * @return A {@link Matcher} that verifies equality with the expected value. - */ - public static Matcher is(T expected) { - return new Is<>(expected); - } + /** + * Creates a matcher that checks if the provided value is equal to the expected value. + * + * @param The type of the value being matched. + * @param expected The value to compare against + * @return A {@link Matcher} that verifies equality with the expected value. + */ + public static Matcher is(T expected) { + return new Is<>(expected); + } - /** - * Creates a matcher that checks if a given value is {@code null}. - * - * @param The type of the value being matched. - * @return A {@link Matcher} that verifies the value is {@code null}. - */ - public static Matcher isNull() { - return new IsNull<>(); - } + /** + * Creates a matcher that checks if a given value is {@code null}. + * + * @param The type of the value being matched. + * @return A {@link Matcher} that verifies the value is {@code null}. + */ + public static Matcher isNull() { + return new IsNull<>(); + } - /** - * Creates a matcher that checks if a given value is not {@code null}. - * - * @param The type of the value being matched. - * @return A {@link Matcher} that verifies the value is not {@code null}. - */ - public static Matcher isNonNull() { - return new IsNonNull<>(); - } + /** + * Creates a matcher that checks if a given value is not {@code null}. + * + * @param The type of the value being matched. + * @return A {@link Matcher} that verifies the value is not {@code null}. + */ + public static Matcher isNonNull() { + return new IsNonNull<>(); + } - /** - * Creates a matcher that checks if a given boolean value is {@code true}. - * - * @return A {@link Matcher} that verifies the value is {@code true}. - */ - public static Matcher isTrue() { - return new IsTrue(); - } + /** + * Creates a matcher that checks if a given boolean value is {@code true}. + * + * @return A {@link Matcher} that verifies the value is {@code true}. + */ + public static Matcher isTrue() { + return new IsTrue(); + } - /** - * Creates a matcher that checks if a given boolean value is {@code false}. - * - * @return A {@link Matcher} that verifies the value is {@code false}. - */ - public static Matcher isFalse() { - return new IsFalse(); - } + /** + * Creates a matcher that checks if a given boolean value is {@code false}. + * + * @return A {@link Matcher} that verifies the value is {@code false}. + */ + public static Matcher isFalse() { + return new IsFalse(); + } - /** - * Creates a {@link Matcher} that checks if a given string matches a specified regular expression. - * - * @param regex The regular expression pattern used for matching. - * @return A {@link Matcher} that validates if a string matches the provided regular expression. - */ - public static Matcher matches(String regex) { - return new Matches(Pattern.compile(regex)); - } + /** + * Creates a {@link Matcher} that checks if a given string matches a specified regular expression. + * + * @param regex The regular expression pattern used for matching. + * @return A {@link Matcher} that validates if a string matches the provided regular expression. + */ + public static Matcher matches(String regex) { + return new Matches(Pattern.compile(regex)); + } - /** - * Creates a {@link Matcher} that checks if a given string matches a specified {@link Pattern}. - * - * @param pattern The regular expression pattern used for matching. - * @return A {@link Matcher} that validates if a string matches the provided {@link Pattern}. - */ - public static Matcher matches(Pattern pattern) { - return new Matches(pattern); - } + /** + * Creates a {@link Matcher} that checks if a given string matches a specified {@link Pattern}. + * + * @param pattern The regular expression pattern used for matching. + * @return A {@link Matcher} that validates if a string matches the provided {@link Pattern}. + */ + public static Matcher matches(Pattern pattern) { + return new Matches(pattern); + } - /** - * Creates a {@link Matcher} that validates a given value based on the provided {@link Predicate}. - * This method allows specifying custom validation logic for matching input values. - * - * @param The type of the value being validated. - * @param validator A {@link Predicate} representing the custom validation logic to be applied. - * @return A {@link Matcher} that uses the provided {@link Predicate} to validate input values. - */ - public static Matcher validates(Predicate validator) { - return new Validates<>(validator); - } + /** + * Creates a {@link Matcher} that validates a given value based on the provided {@link Predicate}. + * This method allows specifying custom validation logic for matching input values. + * + * @param The type of the value being validated. + * @param validator A {@link Predicate} representing the custom validation logic to be applied. + * @return A {@link Matcher} that uses the provided {@link Predicate} to validate input values. + */ + public static Matcher validates(Predicate validator) { + return new Validates<>(validator); + } - /** - * Creates a matcher that always accepts any input value. - * - * @param The type of the value being matched. - * @return A {@link Matcher} that accepts any value and always matches. - */ - public static Matcher any() { - return new Any<>(); - } + /** + * Creates a matcher that always accepts any input value. + * + * @param The type of the value being matched. + * @return A {@link Matcher} that accepts any value and always matches. + */ + public static Matcher any() { + return new Any<>(); + } - /** - * Asserts that a value matches a given matcher. If the value does not match, an assertion error - * is thrown. - * - * @param matcher The matcher to test the value against, {@code null} if no validation is - * required. - * @param value The value to be tested, {@code null} if no value is available. - * @param message The error message to include in the assertion failure. - * @param The type of the value being tested. - */ - public static void assertValue( - @Nullable Matcher matcher, @Nullable T value, String message) { - if (matcher != null && !matcher.test(value)) { - Optional expected = matcher.expected(); - assertionFailure() - .message(message + ". " + matcher.failureReason()) - .expected(expected.orElse(null)) - .actual(value) - .buildAndThrow(); + /** + * Asserts that a value matches a given matcher. If the value does not match, an assertion error + * is thrown. + * + * @param matcher The matcher to test the value against, {@code null} if no validation is + * required. + * @param value The value to be tested, {@code null} if no value is available. + * @param message The error message to include in the assertion failure. + * @param The type of the value being tested. + */ + public static void assertValue(@Nullable Matcher matcher, @Nullable T value, String message) { + if (matcher != null && !matcher.test(value)) { + Optional expected = matcher.expected(); + assertionFailure() + .message(message + ". " + matcher.failureReason()) + .expected(expected.orElse(null)) + .actual(value) + .buildAndThrow(); + } } - } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matches.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matches.java index e19dfa7554f..b3be11dacd9 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matches.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Matches.java @@ -9,24 +9,24 @@ * regular expression. */ public class Matches implements Matcher { - private final Pattern pattern; + private final Pattern pattern; - Matches(Pattern pattern) { - this.pattern = pattern; - } + Matches(Pattern pattern) { + this.pattern = pattern; + } - @Override - public Optional expected() { - return Optional.empty(); - } + @Override + public Optional expected() { + return Optional.empty(); + } - @Override - public String failureReason() { - return "Non matching value"; - } + @Override + public String failureReason() { + return "Non matching value"; + } - @Override - public boolean test(CharSequence s) { - return this.pattern.matcher(s).matches(); - } + @Override + public boolean test(CharSequence s) { + return this.pattern.matcher(s).matches(); + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Validates.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Validates.java index ffa0773dab5..529babfe0d3 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Validates.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/assertions/Validates.java @@ -11,24 +11,24 @@ * @param The type of the value being validated. */ public class Validates implements Matcher { - private final Predicate validator; + private final Predicate validator; - Validates(Predicate validator) { - this.validator = validator; - } + Validates(Predicate validator) { + this.validator = validator; + } - @Override - public Optional expected() { - return Optional.empty(); - } + @Override + public Optional expected() { + return Optional.empty(); + } - @Override - public String failureReason() { - return "Invalid value"; - } + @Override + public String failureReason() { + return "Invalid value"; + } - @Override - public boolean test(T t) { - return this.validator.test(t); - } + @Override + public boolean test(T t) { + return this.validator.test(t); + } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfig.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfig.java index 61dfb4b1d99..be7ca4c34ff 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfig.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfig.java @@ -34,18 +34,18 @@ @Repeatable(WithConfigs.class) @ExtendWith(WithConfigExtension.class) public @interface WithConfig { - /** - * Config key (e.g. {@code "trace.resolver.enabled"}). The {@code dd.}/{@code DD_} prefix is - * auto-added unless {@link #addPrefix()} is {@code false}. - */ - String key(); + /** + * Config key (e.g. {@code "trace.resolver.enabled"}). The {@code dd.}/{@code DD_} prefix is + * auto-added unless {@link #addPrefix()} is {@code false}. + */ + String key(); - /** Config value. */ - String value(); + /** Config value. */ + String value(); - /** If {@code true}, sets an environment variable instead of a system property. */ - boolean env() default false; + /** If {@code true}, sets an environment variable instead of a system property. */ + boolean env() default false; - /** If {@code false}, the key is used as-is without adding the {@code dd.}/{@code DD_} prefix. */ - boolean addPrefix() default true; + /** If {@code false}, the key is used as-is without adding the {@code dd.}/{@code DD_} prefix. */ + boolean addPrefix() default true; } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigExtension.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigExtension.java index 0e8857a9f4d..3927f69df09 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigExtension.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigExtension.java @@ -35,271 +35,261 @@ * used explicitly via {@code @ExtendWith(WithConfigExtension.class)}. */ @SuppressForbidden -public class WithConfigExtension - implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, AfterAllCallback { - - static final String INST_CONFIG = "datadog.trace.api.InstrumenterConfig"; - static final String CONFIG = "datadog.trace.api.Config"; - - private static final Field instConfigInstanceField; - private static final Constructor instConfigConstructor; - private static final Field configInstanceField; - private static final Constructor configConstructor; - - static { - ensureConfigInstrumentationHasBeenApplied(); - try { - Class instCfg = Class.forName(INST_CONFIG); - instConfigInstanceField = instCfg.getDeclaredField("INSTANCE"); - instConfigConstructor = instCfg.getDeclaredConstructor(); - instConfigConstructor.setAccessible(true); - Class cfg = Class.forName(CONFIG); - configInstanceField = cfg.getDeclaredField("INSTANCE"); - configConstructor = cfg.getDeclaredConstructor(); - configConstructor.setAccessible(true); - } catch (ReflectiveOperationException e) { - throw new ExceptionInInitializerError(e); +public class WithConfigExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, AfterAllCallback { + + static final String INST_CONFIG = "datadog.trace.api.InstrumenterConfig"; + static final String CONFIG = "datadog.trace.api.Config"; + + private static final Field instConfigInstanceField; + private static final Constructor instConfigConstructor; + private static final Field configInstanceField; + private static final Constructor configConstructor; + + static { + ensureConfigInstrumentationHasBeenApplied(); + try { + Class instCfg = Class.forName(INST_CONFIG); + instConfigInstanceField = instCfg.getDeclaredField("INSTANCE"); + instConfigConstructor = instCfg.getDeclaredConstructor(); + instConfigConstructor.setAccessible(true); + Class cfg = Class.forName(CONFIG); + configInstanceField = cfg.getDeclaredField("INSTANCE"); + configConstructor = cfg.getDeclaredConstructor(); + configConstructor.setAccessible(true); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } } - } - static final TestEnvironmentVariables environmentVariables = TestEnvironmentVariables.setup(); + static final TestEnvironmentVariables environmentVariables = TestEnvironmentVariables.setup(); - private static Properties originalSystemProperties; + private static Properties originalSystemProperties; - // region JUnit lifecycle callbacks + // region JUnit lifecycle callbacks - @Override - public void beforeAll(ExtensionContext context) { - // Back up config and apply class-level config values. - if (originalSystemProperties == null) { - saveProperties(); - } - // Apply class-level @WithConfig so config is available before @BeforeAll methods - applyClassLevelConfig(context); - rebuildConfig(); - } - - @Override - public void beforeEach(ExtensionContext context) { - restoreProperties(); - environmentVariables.clear(); - applyDeclaredConfig(context); - rebuildConfig(); - } - - @Override - public void afterEach(ExtensionContext context) { - environmentVariables.clear(); - restoreProperties(); - rebuildConfig(); - } - - @Override - public void afterAll(ExtensionContext context) { - restoreProperties(); - rebuildConfig(); - } - - private static void applyDeclaredConfig(ExtensionContext context) { - applyClassLevelConfig(context); - applyMethodLevelConfig(context); - } - - private static void applyClassLevelConfig(ExtensionContext context) { - // Walk the entire class hierarchy so annotations on superclasses and apply topmost first, then - // subclass overrides. - Class testClass = context.getRequiredTestClass(); - List> hierarchy = new ArrayList<>(); - for (Class cls = testClass; cls != null; cls = cls.getSuperclass()) { - hierarchy.add(cls); + @Override + public void beforeAll(ExtensionContext context) { + // Back up config and apply class-level config values. + if (originalSystemProperties == null) { + saveProperties(); + } + // Apply class-level @WithConfig so config is available before @BeforeAll methods + applyClassLevelConfig(context); + rebuildConfig(); } - for (int i = hierarchy.size() - 1; i >= 0; i--) { - List classConfigs = - AnnotationSupport.findRepeatableAnnotations(hierarchy.get(i), WithConfig.class); - for (WithConfig cfg : classConfigs) { - applyConfig(cfg); - } + + @Override + public void beforeEach(ExtensionContext context) { + restoreProperties(); + environmentVariables.clear(); + applyDeclaredConfig(context); + rebuildConfig(); } - } - - private static void applyMethodLevelConfig(ExtensionContext context) { - // Method-level @WithConfig annotations (supports composed/meta-annotations) - context - .getTestMethod() - .ifPresent( - method -> { - List methodConfigs = - AnnotationSupport.findRepeatableAnnotations(method, WithConfig.class); - for (WithConfig cfg : methodConfigs) { - applyConfig(cfg); - } - }); - } - - private static void applyConfig(WithConfig cfg) { - if (cfg.env()) { - setEnvVariable(cfg.key(), cfg.value(), cfg.addPrefix()); - } else { - setSysProperty(cfg.key(), cfg.value(), cfg.addPrefix()); + + @Override + public void afterEach(ExtensionContext context) { + environmentVariables.clear(); + restoreProperties(); + rebuildConfig(); } - } - private static void setSysProperty(String name, String value, boolean addPrefix) { - String prefixedName = addPrefix && !name.startsWith("dd.") ? "dd." + name : name; - System.setProperty(prefixedName, value); - } + @Override + public void afterAll(ExtensionContext context) { + restoreProperties(); + rebuildConfig(); + } - private static void setEnvVariable(String name, String value, boolean addPrefix) { - String prefixedName = addPrefix && !name.startsWith("DD_") ? "DD_" + name : name; - environmentVariables.set(prefixedName, value); - } + private static void applyDeclaredConfig(ExtensionContext context) { + applyClassLevelConfig(context); + applyMethodLevelConfig(context); + } - // endregion + private static void applyClassLevelConfig(ExtensionContext context) { + // Walk the entire class hierarchy so annotations on superclasses and apply topmost first, then + // subclass overrides. + Class testClass = context.getRequiredTestClass(); + List> hierarchy = new ArrayList<>(); + for (Class cls = testClass; cls != null; cls = cls.getSuperclass()) { + hierarchy.add(cls); + } + for (int i = hierarchy.size() - 1; i >= 0; i--) { + List classConfigs = + AnnotationSupport.findRepeatableAnnotations(hierarchy.get(i), WithConfig.class); + for (WithConfig cfg : classConfigs) { + applyConfig(cfg); + } + } + } - // region Public static API for imperative config injection + private static void applyMethodLevelConfig(ExtensionContext context) { + // Method-level @WithConfig annotations (supports composed/meta-annotations) + context.getTestMethod().ifPresent(method -> { + List methodConfigs = AnnotationSupport.findRepeatableAnnotations(method, WithConfig.class); + for (WithConfig cfg : methodConfigs) { + applyConfig(cfg); + } + }); + } - public static void injectSysConfig(String name, String value) { - injectSysConfig(name, value, true); - } + private static void applyConfig(WithConfig cfg) { + if (cfg.env()) { + setEnvVariable(cfg.key(), cfg.value(), cfg.addPrefix()); + } else { + setSysProperty(cfg.key(), cfg.value(), cfg.addPrefix()); + } + } - public static void injectSysConfig(String name, String value, boolean addPrefix) { - setSysProperty(name, value, addPrefix); - rebuildConfig(); - } + private static void setSysProperty(String name, String value, boolean addPrefix) { + String prefixedName = addPrefix && !name.startsWith("dd.") ? "dd." + name : name; + System.setProperty(prefixedName, value); + } - public static void removeSysConfig(String name) { - removeSysConfig(name, true); - } + private static void setEnvVariable(String name, String value, boolean addPrefix) { + String prefixedName = addPrefix && !name.startsWith("DD_") ? "DD_" + name : name; + environmentVariables.set(prefixedName, value); + } - public static void removeSysConfig(String name, boolean addPrefix) { - String prefixedName = addPrefix && !name.startsWith("dd.") ? "dd." + name : name; - System.clearProperty(prefixedName); - rebuildConfig(); - } + // endregion - public static void injectEnvConfig(String name, String value) { - injectEnvConfig(name, value, true); - } + // region Public static API for imperative config injection - public static void injectEnvConfig(String name, String value, boolean addPrefix) { - setEnvVariable(name, value, addPrefix); - rebuildConfig(); - } + public static void injectSysConfig(String name, String value) { + injectSysConfig(name, value, true); + } - public static void removeEnvConfig(String name) { - removeEnvConfig(name, true); - } + public static void injectSysConfig(String name, String value, boolean addPrefix) { + setSysProperty(name, value, addPrefix); + rebuildConfig(); + } - public static void removeEnvConfig(String name, boolean addPrefix) { - String prefixedName = addPrefix && !name.startsWith("DD_") ? "DD_" + name : name; - environmentVariables.removePrefixed(prefixedName); - rebuildConfig(); - } + public static void removeSysConfig(String name) { + removeSysConfig(name, true); + } - // endregion + public static void removeSysConfig(String name, boolean addPrefix) { + String prefixedName = addPrefix && !name.startsWith("dd.") ? "dd." + name : name; + System.clearProperty(prefixedName); + rebuildConfig(); + } - // region Config infrastructure setup + public static void injectEnvConfig(String name, String value) { + injectEnvConfig(name, value, true); + } - private static void ensureConfigInstrumentationHasBeenApplied() { - if (isWritableInstance(CONFIG) && isWritableInstance(INST_CONFIG)) { - return; + public static void injectEnvConfig(String name, String value, boolean addPrefix) { + setEnvVariable(name, value, addPrefix); + rebuildConfig(); } - throw new IllegalStateException( - "Config/InstrumenterConfig INSTANCE fields are not modifiable. " - + "Need the '-javaagent:modifiable-config-agent.jar' on the test JVM " - + "(the dd-trace-java.configure-tests Gradle convention plugin wires this automatically)."); - } - - private static boolean isWritableInstance(String className) { - try { - int m = Class.forName(className).getDeclaredField("INSTANCE").getModifiers(); - return Modifier.isPublic(m) - && Modifier.isStatic(m) - && Modifier.isVolatile(m) - && !Modifier.isFinal(m); - } catch (ClassNotFoundException | NoSuchFieldException e) { - return false; + + public static void removeEnvConfig(String name) { + removeEnvConfig(name, true); } - } - - private static void rebuildConfig() { - synchronized (WithConfigExtension.class) { - try { - Object newInstConfig = instConfigConstructor.newInstance(); - instConfigInstanceField.set(null, newInstConfig); - Object newConfig = configConstructor.newInstance(); - configInstanceField.set(null, newConfig); - } catch (ReflectiveOperationException e) { - throw new AssertionError("Failed to rebuild config", e); - } + + public static void removeEnvConfig(String name, boolean addPrefix) { + String prefixedName = addPrefix && !name.startsWith("DD_") ? "DD_" + name : name; + environmentVariables.removePrefixed(prefixedName); + rebuildConfig(); } - } - // endregion + // endregion - // region Property management + // region Config infrastructure setup - static void saveProperties() { - originalSystemProperties = new Properties(); - originalSystemProperties.putAll(System.getProperties()); - } + private static void ensureConfigInstrumentationHasBeenApplied() { + if (isWritableInstance(CONFIG) && isWritableInstance(INST_CONFIG)) { + return; + } + throw new IllegalStateException("Config/InstrumenterConfig INSTANCE fields are not modifiable. " + + "Need the '-javaagent:modifiable-config-agent.jar' on the test JVM " + + "(the dd-trace-java.configure-tests Gradle convention plugin wires this automatically)."); + } - static void restoreProperties() { - if (originalSystemProperties != null) { - Properties copy = new Properties(); - copy.putAll(originalSystemProperties); - System.setProperties(copy); + private static boolean isWritableInstance(String className) { + try { + int m = Class.forName(className).getDeclaredField("INSTANCE").getModifiers(); + return Modifier.isPublic(m) && Modifier.isStatic(m) && Modifier.isVolatile(m) && !Modifier.isFinal(m); + } catch (ClassNotFoundException | NoSuchFieldException e) { + return false; + } } - } - // endregion + private static void rebuildConfig() { + synchronized (WithConfigExtension.class) { + try { + Object newInstConfig = instConfigConstructor.newInstance(); + instConfigInstanceField.set(null, newInstConfig); + Object newConfig = configConstructor.newInstance(); + configInstanceField.set(null, newConfig); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to rebuild config", e); + } + } + } - /** Test-only environment variable provider that replaces the real one during tests. */ - public static class TestEnvironmentVariables - extends EnvironmentVariables.EnvironmentVariablesProvider { - private final Map env = new HashMap<>(); + // endregion - TestEnvironmentVariables(String... kv) { - for (int i = 0; i + 1 < kv.length; i += 2) { - this.env.put(kv[i], kv[i + 1]); - } - } + // region Property management - @Override - public String get(String name) { - return env.get(name); + static void saveProperties() { + originalSystemProperties = new Properties(); + originalSystemProperties.putAll(System.getProperties()); } - @Override - public Map getAll() { - return env; + static void restoreProperties() { + if (originalSystemProperties != null) { + Properties copy = new Properties(); + copy.putAll(originalSystemProperties); + System.setProperties(copy); + } } - public void set(String name, String value) { - env.put(name, value); - } + // endregion - public void removePrefixed(String prefix) { - env.keySet().removeIf(k -> k.startsWith(prefix)); - } + /** Test-only environment variable provider that replaces the real one during tests. */ + public static class TestEnvironmentVariables extends EnvironmentVariables.EnvironmentVariablesProvider { + private final Map env = new HashMap<>(); - public void clear() { - env.clear(); - } + TestEnvironmentVariables(String... kv) { + for (int i = 0; i + 1 < kv.length; i += 2) { + this.env.put(kv[i], kv[i + 1]); + } + } - @SuppressForbidden - static TestEnvironmentVariables setup(String... kv) { - TestEnvironmentVariables provider = new TestEnvironmentVariables(kv); - EnvironmentVariables.provider = provider; + @Override + public String get(String name) { + return env.get(name); + } - String propagateVars = System.getenv("TEST_ENV_PROPAGATE_VARS"); - if (propagateVars != null) { - for (String envVar : propagateVars.split(",")) { - provider.env.put(envVar, System.getenv(envVar)); + @Override + public Map getAll() { + return env; } - } - return provider; + public void set(String name, String value) { + env.put(name, value); + } + + public void removePrefixed(String prefix) { + env.keySet().removeIf(k -> k.startsWith(prefix)); + } + + public void clear() { + env.clear(); + } + + @SuppressForbidden + static TestEnvironmentVariables setup(String... kv) { + TestEnvironmentVariables provider = new TestEnvironmentVariables(kv); + EnvironmentVariables.provider = provider; + + String propagateVars = System.getenv("TEST_ENV_PROPAGATE_VARS"); + if (propagateVars != null) { + for (String envVar : propagateVars.split(",")) { + provider.env.put(envVar, System.getenv(envVar)); + } + } + + return provider; + } } - } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigs.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigs.java index 55681b56857..130b665c7d1 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigs.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/config/WithConfigs.java @@ -13,5 +13,5 @@ @Target({TYPE, METHOD}) @ExtendWith(WithConfigExtension.class) public @interface WithConfigs { - WithConfig[] value(); + WithConfig[] value(); } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/AllowContextTestingExtension.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/AllowContextTestingExtension.java index a9b9c44861c..a3217d6f512 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/AllowContextTestingExtension.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/AllowContextTestingExtension.java @@ -15,13 +15,17 @@ @SuppressForbidden public class AllowContextTestingExtension implements BeforeAllCallback { - @Override - public void beforeAll(ExtensionContext context) { - try { - Class.forName("datadog.context.ContextManager").getMethod("allowTesting").invoke(null); - Class.forName("datadog.context.ContextBinder").getMethod("allowTesting").invoke(null); - } catch (Throwable ignore) { - // don't block testing if context types aren't available + @Override + public void beforeAll(ExtensionContext context) { + try { + Class.forName("datadog.context.ContextManager") + .getMethod("allowTesting") + .invoke(null); + Class.forName("datadog.context.ContextBinder") + .getMethod("allowTesting") + .invoke(null); + } catch (Throwable ignore) { + // don't block testing if context types aren't available + } } - } } diff --git a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/LegacyContextTestingExtension.java b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/LegacyContextTestingExtension.java index 0b51926c5ce..acf58edc46d 100644 --- a/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/LegacyContextTestingExtension.java +++ b/utils/test-junit-utils/src/main/java/datadog/trace/test/junit/utils/context/LegacyContextTestingExtension.java @@ -7,14 +7,14 @@ /** JUnit 5 extension that decides whether to register the legacy approach to managing contexts. */ @SuppressForbidden public class LegacyContextTestingExtension implements BeforeAllCallback { - @Override - public void beforeAll(ExtensionContext context) { - try { - Class.forName("datadog.trace.bootstrap.instrumentation.api.AgentTracer") - .getMethod("maybeInstallLegacyContextManager") - .invoke(null); - } catch (Throwable ignore) { - // don't block testing if the legacy approach to managing contexts isn't available + @Override + public void beforeAll(ExtensionContext context) { + try { + Class.forName("datadog.trace.bootstrap.instrumentation.api.AgentTracer") + .getMethod("maybeInstallLegacyContextManager") + .invoke(null); + } catch (Throwable ignore) { + // don't block testing if the legacy approach to managing contexts isn't available + } } - } } diff --git a/utils/test-utils/src/main/groovy/datadog/trace/test/util/Flaky.java b/utils/test-utils/src/main/groovy/datadog/trace/test/util/Flaky.java index c4625a3de38..66f1a04d8e7 100644 --- a/utils/test-utils/src/main/groovy/datadog/trace/test/util/Flaky.java +++ b/utils/test-utils/src/main/groovy/datadog/trace/test/util/Flaky.java @@ -15,26 +15,26 @@ @Target({ElementType.TYPE, ElementType.METHOD}) @Tag("flaky") public @interface Flaky { - /** Reason why the test is flaky (optional). */ - String value() default ""; + /** Reason why the test is flaky (optional). */ + String value() default ""; - /** - * Fully qualified name of the test suite classes where this test is flaky. Only required when the - * test is flaky only when run in a subclass. - */ - String[] suites() default {}; + /** + * Fully qualified name of the test suite classes where this test is flaky. Only required when the + * test is flaky only when run in a subclass. + */ + String[] suites() default {}; - /** - * Closure with a predicate to test at runtime if the actual spec is flaky (e.g. check the JVM - * vendor), the parameter is the actual name of the spec under test - */ - Class> condition() default True.class; + /** + * Closure with a predicate to test at runtime if the actual spec is flaky (e.g. check the JVM + * vendor), the parameter is the actual name of the spec under test + */ + Class> condition() default True.class; - class True implements Predicate { + class True implements Predicate { - @Override - public boolean test(final String spec) { - return true; + @Override + public boolean test(final String spec) { + return true; + } } - } } diff --git a/utils/test-utils/src/main/groovy/datadog/trace/test/util/NonRetryable.java b/utils/test-utils/src/main/groovy/datadog/trace/test/util/NonRetryable.java index ecfb3cf3179..0e70ce6c9a9 100644 --- a/utils/test-utils/src/main/groovy/datadog/trace/test/util/NonRetryable.java +++ b/utils/test-utils/src/main/groovy/datadog/trace/test/util/NonRetryable.java @@ -16,6 +16,6 @@ @Target({ElementType.TYPE, ElementType.METHOD}) @Tag("NonRetryable") public @interface NonRetryable { - /** Reason why the test is non-retryable (optional). */ - String value() default ""; + /** Reason why the test is non-retryable (optional). */ + String value() default ""; } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/logging/CapturedLog.java b/utils/test-utils/src/main/java/datadog/trace/test/logging/CapturedLog.java index 93664924bb4..6638ad2610a 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/logging/CapturedLog.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/logging/CapturedLog.java @@ -5,46 +5,46 @@ import org.slf4j.Marker; public final class CapturedLog { - public final Marker marker; - public final String level; - public final String template; - public final Object[] arguments; - public final String message; + public final Marker marker; + public final String level; + public final String template; + public final Object[] arguments; + public final String message; - public CapturedLog( - final Marker marker, - final String level, - final String template, - final Object[] arguments, - final String message) { - this.marker = marker; - this.level = level; - this.template = template; - this.arguments = arguments; - this.message = message; - } + public CapturedLog( + final Marker marker, + final String level, + final String template, + final Object[] arguments, + final String message) { + this.marker = marker; + this.level = level; + this.template = template; + this.arguments = arguments; + this.message = message; + } - @Override - public String toString() { - return "CapturedLog{" - + "marker=" - + marker - + ", level='" - + level - + '\'' - + ", template='" - + template - + '\'' - + ", arguments=..." - + ", message='" - + message - + '\'' - + '}'; - } + @Override + public String toString() { + return "CapturedLog{" + + "marker=" + + marker + + ", level='" + + level + + '\'' + + ", template='" + + template + + '\'' + + ", arguments=..." + + ", message='" + + message + + '\'' + + '}'; + } - @Override - public int hashCode() { - final int argsHash = arguments == null ? 0 : Arrays.hashCode(arguments); - return Objects.hash(marker, level, template, argsHash, message); - } + @Override + public int hashCode() { + final int argsHash = arguments == null ? 0 : Arrays.hashCode(arguments); + return Objects.hash(marker, level, template, argsHash, message); + } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogCollector.java b/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogCollector.java index 9f5571ae063..8efade41fe4 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogCollector.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogCollector.java @@ -6,63 +6,62 @@ import java.util.concurrent.atomic.AtomicReference; public final class TestLogCollector { - static final TestLogCollector INSTANCE = new TestLogCollector(); + static final TestLogCollector INSTANCE = new TestLogCollector(); - private final AtomicReference> logs = - new AtomicReference<>(null); + private final AtomicReference> logs = new AtomicReference<>(null); - private TestLogCollector() {} + private TestLogCollector() {} - /** - * Enable the test log collector. This should be called before any test that needs the logs. - * {@link #disable()} must always be called at cleanup. - */ - public static void enable() { - INSTANCE._enable(); - } + /** + * Enable the test log collector. This should be called before any test that needs the logs. + * {@link #disable()} must always be called at cleanup. + */ + public static void enable() { + INSTANCE._enable(); + } - /** Must be called at least once after {@link #enable()} to cleanup the test log collector. */ - public static void disable() { - INSTANCE._disable(); - } + /** Must be called at least once after {@link #enable()} to cleanup the test log collector. */ + public static void disable() { + INSTANCE._disable(); + } - /** - * Get all captured logs and clear the internal buffer. {@link #enable()} must have been called - * before. - */ - public static List drainCapturedLogs() { - return INSTANCE._drainCapturedLogs(); - } + /** + * Get all captured logs and clear the internal buffer. {@link #enable()} must have been called + * before. + */ + public static List drainCapturedLogs() { + return INSTANCE._drainCapturedLogs(); + } - private void _enable() { - final LinkedBlockingDeque logs = new LinkedBlockingDeque<>(); - if (!this.logs.compareAndSet(null, logs)) { - throw new IllegalStateException("TestLogCollector was enabled without prior cleanup"); + private void _enable() { + final LinkedBlockingDeque logs = new LinkedBlockingDeque<>(); + if (!this.logs.compareAndSet(null, logs)) { + throw new IllegalStateException("TestLogCollector was enabled without prior cleanup"); + } } - } - private List _drainCapturedLogs() { - final List result = new ArrayList<>(); - final LinkedBlockingDeque logs = this.logs.get(); - if (logs == null) { - throw new IllegalStateException("TestLogCollector was not enabled before draining logs"); + private List _drainCapturedLogs() { + final List result = new ArrayList<>(); + final LinkedBlockingDeque logs = this.logs.get(); + if (logs == null) { + throw new IllegalStateException("TestLogCollector was not enabled before draining logs"); + } + logs.drainTo(result); + return result; } - logs.drainTo(result); - return result; - } - private void _disable() { - final LinkedBlockingDeque logs = this.logs.getAndSet(null); - if (logs == null) { - return; + private void _disable() { + final LinkedBlockingDeque logs = this.logs.getAndSet(null); + if (logs == null) { + return; + } + logs.clear(); } - logs.clear(); - } - void addLog(final CapturedLog log) { - final LinkedBlockingDeque logs = this.logs.get(); - if (logs != null) { - logs.add(log); + void addLog(final CapturedLog log) { + final LinkedBlockingDeque logs = this.logs.get(); + if (logs != null) { + logs.add(log); + } } - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogbackAppender.java b/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogbackAppender.java index 3afbda23bae..6ed12abd6d8 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogbackAppender.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/logging/TestLogbackAppender.java @@ -25,15 +25,14 @@ */ public final class TestLogbackAppender extends AppenderBase { - @Override - protected void append(final ILoggingEvent event) { - final CapturedLog log = - new CapturedLog( - event.getMarker(), - event.getLevel().levelStr, - event.getMessage(), - event.getArgumentArray(), - event.getFormattedMessage()); - TestLogCollector.INSTANCE.addLog(log); - } + @Override + protected void append(final ILoggingEvent event) { + final CapturedLog log = new CapturedLog( + event.getMarker(), + event.getLevel().levelStr, + event.getMessage(), + event.getArgumentArray(), + event.getFormattedMessage()); + TestLogCollector.INSTANCE.addLog(log); + } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/AnyStackRunner.java b/utils/test-utils/src/main/java/datadog/trace/test/util/AnyStackRunner.java index 051d58ee59a..15ec5afa823 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/AnyStackRunner.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/AnyStackRunner.java @@ -9,40 +9,39 @@ */ public class AnyStackRunner { - /** - * Call the given runnable from a class with the given name. - * - * @param parentClass Caller class. - * @param runnable Runnable to call. - */ - public static void callWithinStack(final String parentClass, final Runnable runnable) { - // Create a copy of the RunnerRunner template class with the given name. - final Class dynamicType = - new ByteBuddy() - .redefine(RunnerRunner.class) - .name(parentClass) - .make() - .load(AnyStackRunner.class.getClassLoader()) - .getLoaded(); - try { - Consumer obj = - (Consumer) dynamicType.getDeclaredConstructor().newInstance(); - obj.accept(runnable); - } catch (ReflectiveOperationException ex) { - throw new RuntimeException(ex); + /** + * Call the given runnable from a class with the given name. + * + * @param parentClass Caller class. + * @param runnable Runnable to call. + */ + public static void callWithinStack(final String parentClass, final Runnable runnable) { + // Create a copy of the RunnerRunner template class with the given name. + final Class dynamicType = new ByteBuddy() + .redefine(RunnerRunner.class) + .name(parentClass) + .make() + .load(AnyStackRunner.class.getClassLoader()) + .getLoaded(); + try { + Consumer obj = + (Consumer) dynamicType.getDeclaredConstructor().newInstance(); + obj.accept(runnable); + } catch (ReflectiveOperationException ex) { + throw new RuntimeException(ex); + } } - } - /** Public to ease code-generation. Not meant to be used out of the parent class. */ - public interface Consumer { - void accept(T arg); - } + /** Public to ease code-generation. Not meant to be used out of the parent class. */ + public interface Consumer { + void accept(T arg); + } - /** Public to ease code-generation. Not meant to be used out of the parent class. */ - public static class RunnerRunner implements Consumer { - @Override - public void accept(final Runnable runnable) { - runnable.run(); + /** Public to ease code-generation. Not meant to be used out of the parent class. */ + public static class RunnerRunner implements Consumer { + @Override + public void accept(final Runnable runnable) { + runnable.run(); + } } - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/AssertionsUtils.java b/utils/test-utils/src/main/java/datadog/trace/test/util/AssertionsUtils.java index d846b61bcc3..79a17bc797d 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/AssertionsUtils.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/AssertionsUtils.java @@ -5,16 +5,12 @@ import java.util.Map; public class AssertionsUtils { - private AssertionsUtils() { - // No-op. - } + private AssertionsUtils() { + // No-op. + } - public static void assertMapContainsKeyValues(Map actual, Map expectedSubset) { - expectedSubset.forEach( - (k, v) -> - assertEquals( - v, - actual.get(k), - () -> "Mismatch for key [" + k + "]: expected=" + v + ", actual=" + actual.get(k))); - } + public static void assertMapContainsKeyValues(Map actual, Map expectedSubset) { + expectedSubset.forEach((k, v) -> assertEquals( + v, actual.get(k), () -> "Mismatch for key [" + k + "]: expected=" + v + ", actual=" + actual.get(k))); + } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/CircularBuffer.java b/utils/test-utils/src/main/java/datadog/trace/test/util/CircularBuffer.java index 585b2d376c0..fc296b81cd3 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/CircularBuffer.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/CircularBuffer.java @@ -9,41 +9,41 @@ * Iterable by insertion order. The iterator omits nulls. */ public class CircularBuffer implements Iterable { - private int index; - private final int lengthMask; - private final T[] buffer; + private int index; + private final int lengthMask; + private final T[] buffer; - public CircularBuffer(final int capacity) { - assert capacity > 0; - // Stick to power of 2 - assert (capacity & (capacity - 1)) == 0; - lengthMask = capacity - 1; - index = 0; - buffer = createBuffer(capacity); - } + public CircularBuffer(final int capacity) { + assert capacity > 0; + // Stick to power of 2 + assert (capacity & (capacity - 1)) == 0; + lengthMask = capacity - 1; + index = 0; + buffer = createBuffer(capacity); + } - public T add(final T obj) { - final T current = buffer[index]; - buffer[index] = obj; - index = (index + 1) & lengthMask; - return current; - } + public T add(final T obj) { + final T current = buffer[index]; + buffer[index] = obj; + index = (index + 1) & lengthMask; + return current; + } - public Iterator iterator() { - final List list = new ArrayList<>(); - int iterIndex = (index + 1) & lengthMask; - for (int i = 0; i < buffer.length; i++) { - final T el = buffer[iterIndex]; - if (el != null) { - list.add(el); - } - iterIndex = (iterIndex + 1) & lengthMask; + public Iterator iterator() { + final List list = new ArrayList<>(); + int iterIndex = (index + 1) & lengthMask; + for (int i = 0; i < buffer.length; i++) { + final T el = buffer[iterIndex]; + if (el != null) { + list.add(el); + } + iterIndex = (iterIndex + 1) & lengthMask; + } + return list.iterator(); } - return list.iterator(); - } - @SuppressWarnings("unchecked") - private T[] createBuffer(final int capacity) { - return (T[]) new Object[capacity]; - } + @SuppressWarnings("unchecked") + private T[] createBuffer(final int capacity) { + return (T[]) new Object[capacity]; + } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/CleanConfigStateExtension.java b/utils/test-utils/src/main/java/datadog/trace/test/util/CleanConfigStateExtension.java index 09c18ced5a4..2df35750471 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/CleanConfigStateExtension.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/CleanConfigStateExtension.java @@ -22,59 +22,55 @@ @SuppressForbidden public class CleanConfigStateExtension implements BeforeAllCallback, AfterAllCallback { - private static final List ALLOWED_SYS_PROPS = - Arrays.asList( - "dd.appsec.enabled", "dd.iast.enabled", "dd.integration.grizzly-filterchain.enabled"); + private static final List ALLOWED_SYS_PROPS = + Arrays.asList("dd.appsec.enabled", "dd.iast.enabled", "dd.integration.grizzly-filterchain.enabled"); - private static final Predicate DATADOG_ENV_VAR_FILTER = k -> k.startsWith("DD_"); - private static final Predicate DATADOG_SYS_PROPERTIES_FILTER = - o -> { + private static final Predicate DATADOG_ENV_VAR_FILTER = k -> k.startsWith("DD_"); + private static final Predicate DATADOG_SYS_PROPERTIES_FILTER = o -> { String key = (String) o; return key.startsWith("DD_") && !ALLOWED_SYS_PROPS.contains(key); - }; + }; - @Override - public void beforeAll(ExtensionContext context) { - assertClean("before"); - } - - @Override - public void afterAll(ExtensionContext context) { - assertClean("after"); - } + @Override + public void beforeAll(ExtensionContext context) { + assertClean("before"); + } - private static void assertClean(String phase) { - Map leakedEnv = - filterMap(EnvironmentVariables.getAll(), DATADOG_ENV_VAR_FILTER); - Map leakedSys = - filterMap(System.getProperties(), DATADOG_SYS_PROPERTIES_FILTER); - if (!leakedEnv.isEmpty() || !leakedSys.isEmpty()) { - assertionFailure() - .message("Leaked Datadog configuration detected " + phase + " test class") - .reason(formatLeaks(leakedEnv, leakedSys)) - .buildAndThrow(); + @Override + public void afterAll(ExtensionContext context) { + assertClean("after"); } - } - private static Map filterMap(Map map, Predicate keyFilter) { - return map.entrySet().stream() - .filter(e -> keyFilter.test(e.getKey())) - .collect(Collectors.toMap(Entry::getKey, Entry::getValue, (a, b) -> a, TreeMap::new)); - } + private static void assertClean(String phase) { + Map leakedEnv = filterMap(EnvironmentVariables.getAll(), DATADOG_ENV_VAR_FILTER); + Map leakedSys = filterMap(System.getProperties(), DATADOG_SYS_PROPERTIES_FILTER); + if (!leakedEnv.isEmpty() || !leakedSys.isEmpty()) { + assertionFailure() + .message("Leaked Datadog configuration detected " + phase + " test class") + .reason(formatLeaks(leakedEnv, leakedSys)) + .buildAndThrow(); + } + } - private static String formatLeaks(Map env, Map sys) { - StringBuilder sb = new StringBuilder(); - if (!env.isEmpty()) { - sb.append("environment variables:"); - env.forEach((k, v) -> sb.append("\n ").append(k).append('=').append(v)); + private static Map filterMap(Map map, Predicate keyFilter) { + return map.entrySet().stream() + .filter(e -> keyFilter.test(e.getKey())) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue, (a, b) -> a, TreeMap::new)); } - if (!sys.isEmpty()) { - if (sb.length() > 0) { - sb.append('\n'); - } - sb.append("system properties:"); - sys.forEach((k, v) -> sb.append("\n ").append(k).append('=').append(v)); + + private static String formatLeaks(Map env, Map sys) { + StringBuilder sb = new StringBuilder(); + if (!env.isEmpty()) { + sb.append("environment variables:"); + env.forEach((k, v) -> sb.append("\n ").append(k).append('=').append(v)); + } + if (!sys.isEmpty()) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append("system properties:"); + sys.forEach((k, v) -> sb.append("\n ").append(k).append('=').append(v)); + } + return sb.toString(); } - return sb.toString(); - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/DDJavaSpecification.java b/utils/test-utils/src/main/java/datadog/trace/test/util/DDJavaSpecification.java index d4780e89098..dd518695c39 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/DDJavaSpecification.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/DDJavaSpecification.java @@ -11,74 +11,67 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.ExtendWith; -@ExtendWith({ - CleanConfigStateExtension.class, - WithConfigExtension.class, - AllowContextTestingExtension.class -}) +@ExtendWith({CleanConfigStateExtension.class, WithConfigExtension.class, AllowContextTestingExtension.class}) @SuppressForbidden public class DDJavaSpecification { - private static final long CHECK_TIMEOUT_MS = 3000; + private static final long CHECK_TIMEOUT_MS = 3000; - protected boolean assertThreadsEachCleanup = true; - private static volatile boolean ignoreThreadCleanup; + protected boolean assertThreadsEachCleanup = true; + private static volatile boolean ignoreThreadCleanup; - @BeforeAll - static void beforeAll() { - if (getDDThreads().isEmpty()) { - ignoreThreadCleanup = false; - } else { - System.out.println( - "Found DD threads before test started. Ignoring thread cleanup for this test class"); - ignoreThreadCleanup = true; + @BeforeAll + static void beforeAll() { + if (getDDThreads().isEmpty()) { + ignoreThreadCleanup = false; + } else { + System.out.println("Found DD threads before test started. Ignoring thread cleanup for this test class"); + ignoreThreadCleanup = true; + } } - } - @AfterAll - static void afterAll() { - checkThreads(); - } - - @AfterEach - void cleanup() { - if (assertThreadsEachCleanup) { - checkThreads(); + @AfterAll + static void afterAll() { + checkThreads(); } - } - static Set getDDThreads() { - return Thread.getAllStackTraces().keySet().stream() - .filter( - t -> - t.getName().startsWith("dd-") - && !t.getName().equals("dd-task-scheduler") - && !t.getName().equals("dd-cassandra-session-executor")) - .collect(Collectors.toSet()); - } + @AfterEach + void cleanup() { + if (assertThreadsEachCleanup) { + checkThreads(); + } + } - static void checkThreads() { - if (ignoreThreadCleanup) { - return; + static Set getDDThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(t -> t.getName().startsWith("dd-") + && !t.getName().equals("dd-task-scheduler") + && !t.getName().equals("dd-cassandra-session-executor")) + .collect(Collectors.toSet()); } - long deadline = System.currentTimeMillis() + CHECK_TIMEOUT_MS; + static void checkThreads() { + if (ignoreThreadCleanup) { + return; + } - Set threads = getDDThreads(); - while (System.currentTimeMillis() < deadline && !threads.isEmpty()) { - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - threads = getDDThreads(); - } + long deadline = System.currentTimeMillis() + CHECK_TIMEOUT_MS; + + Set threads = getDDThreads(); + while (System.currentTimeMillis() < deadline && !threads.isEmpty()) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + threads = getDDThreads(); + } - if (!threads.isEmpty()) { - System.out.println("WARNING: DD threads still active. Forget to close() a tracer?"); - List names = threads.stream().map(Thread::getName).collect(Collectors.toList()); - System.out.println(names); + if (!threads.isEmpty()) { + System.out.println("WARNING: DD threads still active. Forget to close() a tracer?"); + List names = threads.stream().map(Thread::getName).collect(Collectors.toList()); + System.out.println(names); + } } - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/ForkedTestUtils.java b/utils/test-utils/src/main/java/datadog/trace/test/util/ForkedTestUtils.java index 03e8a279ad5..f4b17b3889a 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/ForkedTestUtils.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/ForkedTestUtils.java @@ -1,11 +1,11 @@ package datadog.trace.test.util; public class ForkedTestUtils { - public static String getMaxMemoryArgumentForFork() { - return "-Xmx" + System.getProperty("datadog.forkedMaxHeapSize", "1024M"); - } + public static String getMaxMemoryArgumentForFork() { + return "-Xmx" + System.getProperty("datadog.forkedMaxHeapSize", "1024M"); + } - public static String getMinMemoryArgumentForFork() { - return "-Xms" + System.getProperty("datadog.forkedMinHeapSize", "64M"); - } + public static String getMinMemoryArgumentForFork() { + return "-Xms" + System.getProperty("datadog.forkedMinHeapSize", "64M"); + } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/GCUtils.java b/utils/test-utils/src/main/java/datadog/trace/test/util/GCUtils.java index 93683ee22b2..ae26aef1872 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/GCUtils.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/GCUtils.java @@ -7,38 +7,38 @@ public abstract class GCUtils { - public static void awaitGC() throws InterruptedException { - Object obj = new Object(); - final WeakReference ref = new WeakReference<>(obj); - obj = null; - awaitGC(ref); - } + public static void awaitGC() throws InterruptedException { + Object obj = new Object(); + final WeakReference ref = new WeakReference<>(obj); + obj = null; + awaitGC(ref); + } - public static void awaitGC(final WeakReference ref) throws InterruptedException { - awaitGC(ref, 1, MINUTES); - } + public static void awaitGC(final WeakReference ref) throws InterruptedException { + awaitGC(ref, 1, MINUTES); + } - public static void awaitGC(final WeakReference ref, final long duration, final TimeUnit unit) - throws InterruptedException { - System.gc(); - System.runFinalization(); - final long waitNanos = unit.toNanos(duration); - final long start = System.nanoTime(); - while (System.nanoTime() - start < waitNanos) { - if (ref.get() == null) { - return; - } - if (Thread.interrupted()) { - throw new InterruptedException(); - } - System.gc(); - System.runFinalization(); - try { - Thread.sleep(100); - } catch (final InterruptedException e) { - throw new RuntimeException("Interrupted while waiting for " + ref.get() + " to be GCed"); - } + public static void awaitGC(final WeakReference ref, final long duration, final TimeUnit unit) + throws InterruptedException { + System.gc(); + System.runFinalization(); + final long waitNanos = unit.toNanos(duration); + final long start = System.nanoTime(); + while (System.nanoTime() - start < waitNanos) { + if (ref.get() == null) { + return; + } + if (Thread.interrupted()) { + throw new InterruptedException(); + } + System.gc(); + System.runFinalization(); + try { + Thread.sleep(100); + } catch (final InterruptedException e) { + throw new RuntimeException("Interrupted while waiting for " + ref.get() + " to be GCed"); + } + } + throw new RuntimeException("Timed out waiting for " + ref.get() + " to be GCed"); } - throw new RuntimeException("Timed out waiting for " + ref.get() + " to be GCed"); - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PollingConditions.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PollingConditions.java index 654bcc758b7..436439e5897 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/PollingConditions.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PollingConditions.java @@ -26,116 +26,113 @@ * } */ public class PollingConditions { - private double timeout = 1; // seconds - private double initialDelay = 0; // seconds - private double delay = 0.1; // seconds - private double factor = 1.0; // delay multiplier between attempts + private double timeout = 1; // seconds + private double initialDelay = 0; // seconds + private double delay = 0.1; // seconds + private double factor = 1.0; // delay multiplier between attempts - public PollingConditions() {} + public PollingConditions() {} - /** - * @param timeoutSeconds the timeout in seconds (the most common single setting). - * @throws IllegalArgumentException if {@code timeoutSeconds} is negative. - */ - public PollingConditions(double timeoutSeconds) { - this.timeout = requireNonNegative(timeoutSeconds, "timeout"); - } - - /** - * @param seconds how long to keep retrying before failing. Defaults to {@code 1}. - */ - public PollingConditions timeout(double seconds) { - this.timeout = requireNonNegative(seconds, "timeout"); - return this; - } + /** + * @param timeoutSeconds the timeout in seconds (the most common single setting). + * @throws IllegalArgumentException if {@code timeoutSeconds} is negative. + */ + public PollingConditions(double timeoutSeconds) { + this.timeout = requireNonNegative(timeoutSeconds, "timeout"); + } - /** - * @param seconds how long to wait before the very first attempt. Defaults to {@code 0}. - */ - public PollingConditions initialDelay(double seconds) { - this.initialDelay = requireNonNegative(seconds, "initialDelay"); - return this; - } + /** + * @param seconds how long to keep retrying before failing. Defaults to {@code 1}. + */ + public PollingConditions timeout(double seconds) { + this.timeout = requireNonNegative(seconds, "timeout"); + return this; + } - /** - * @param seconds how long to wait between attempts. Defaults to {@code 0.1}. - */ - public PollingConditions delay(double seconds) { - this.delay = requireNonNegative(seconds, "delay"); - return this; - } + /** + * @param seconds how long to wait before the very first attempt. Defaults to {@code 0}. + */ + public PollingConditions initialDelay(double seconds) { + this.initialDelay = requireNonNegative(seconds, "initialDelay"); + return this; + } - /** - * @param factor multiplier applied to the delay after each attempt (exponential back-off). - * Defaults to {@code 1.0} (constant delay). - */ - public PollingConditions factor(double factor) { - this.factor = requireNonNegative(factor, "factor"); - return this; - } + /** + * @param seconds how long to wait between attempts. Defaults to {@code 0.1}. + */ + public PollingConditions delay(double seconds) { + this.delay = requireNonNegative(seconds, "delay"); + return this; + } - /** Retries {@code conditions} until it passes or the configured {@link #timeout} elapses. */ - public void eventually(ThrowingRunnable conditions) { - within(this.timeout, conditions); - } + /** + * @param factor multiplier applied to the delay after each attempt (exponential back-off). + * Defaults to {@code 1.0} (constant delay). + */ + public PollingConditions factor(double factor) { + this.factor = requireNonNegative(factor, "factor"); + return this; + } - /** - * Retries {@code conditions} until it passes or {@code seconds} elapse, overriding the configured - * {@link #timeout} for this call. - */ - public void within(double seconds, ThrowingRunnable conditions) { - final long timeoutMillis = toMillis(requireNonNegative(seconds, "timeout")); - final long start = System.currentTimeMillis(); - if (this.initialDelay > 0) { - sleep(toMillis(this.initialDelay)); + /** Retries {@code conditions} until it passes or the configured {@link #timeout} elapses. */ + public void eventually(ThrowingRunnable conditions) { + within(this.timeout, conditions); } - long currDelay = toMillis(this.delay); - int attempts = 0; - long elapsed = 0; - Throwable lastFailure = null; - while (elapsed <= timeoutMillis) { - try { - attempts++; - conditions.run(); - return; - } catch (final Throwable e) { - elapsed = System.currentTimeMillis() - start; - lastFailure = e; - // Never sleep past the deadline. - final long sleepMillis = - Math.min(currDelay, start + timeoutMillis - System.currentTimeMillis()); - if (sleepMillis > 0) { - sleep(sleepMillis); + + /** + * Retries {@code conditions} until it passes or {@code seconds} elapse, overriding the configured + * {@link #timeout} for this call. + */ + public void within(double seconds, ThrowingRunnable conditions) { + final long timeoutMillis = toMillis(requireNonNegative(seconds, "timeout")); + final long start = System.currentTimeMillis(); + if (this.initialDelay > 0) { + sleep(toMillis(this.initialDelay)); + } + long currDelay = toMillis(this.delay); + int attempts = 0; + long elapsed = 0; + Throwable lastFailure = null; + while (elapsed <= timeoutMillis) { + try { + attempts++; + conditions.run(); + return; + } catch (final Throwable e) { + elapsed = System.currentTimeMillis() - start; + lastFailure = e; + // Never sleep past the deadline. + final long sleepMillis = Math.min(currDelay, start + timeoutMillis - System.currentTimeMillis()); + if (sleepMillis > 0) { + sleep(sleepMillis); + } + currDelay = (long) (currDelay * factor); + } } - currDelay = (long) (currDelay * factor); - } + throw new AssertionError( + String.format("Condition not satisfied after %1.2f seconds and %d attempts", elapsed / 1000d, attempts), + lastFailure); } - throw new AssertionError( - String.format( - "Condition not satisfied after %1.2f seconds and %d attempts", - elapsed / 1000d, attempts), - lastFailure); - } - private static long toMillis(final double seconds) { - return Math.round(seconds * 1000); - } + private static long toMillis(final double seconds) { + return Math.round(seconds * 1000); + } - private static double requireNonNegative(double value, String name) { - if (value < 0) { - throw new IllegalArgumentException(name + " must be >= 0 but was " + value); + private static double requireNonNegative(double value, String name) { + if (value < 0) { + throw new IllegalArgumentException(name + " must be >= 0 but was " + value); + } + return value; } - return value; - } - // VisibleForTesting to allow test code to override it and avoid flaky timing-based assertions. - @VisibleForTesting - void sleep(final long millis) { - try { - Thread.sleep(millis); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted while waiting for condition", e); + // VisibleForTesting to allow test code to override it and avoid flaky timing-based assertions. + @VisibleForTesting + void sleep(final long millis) { + try { + Thread.sleep(millis); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for condition", e); + } } - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java index aa1c90bc73d..79ea80d6b5d 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java @@ -19,96 +19,93 @@ * */ public final class PortableCommand { - private static final String MIN_HEAP = "-Xms8m"; - private static final String MAX_HEAP = "-Xmx16m"; + private static final String MIN_HEAP = "-Xms8m"; + private static final String MAX_HEAP = "-Xmx16m"; - /** Windows has no usable native equivalent for any of these commands. */ - private static final boolean EMULATED = OperatingSystem.isWindows(); + /** Windows has no usable native equivalent for any of these commands. */ + private static final boolean EMULATED = OperatingSystem.isWindows(); - private PortableCommand() {} + private PortableCommand() {} - public static String[] echo(String value) { - return echo(value, EMULATED); - } + public static String[] echo(String value) { + return echo(value, EMULATED); + } + + public static String[] cat() { + return cat(EMULATED); + } + + public static String[] sleep(long durationSec) { + return sleep(durationSec, EMULATED); + } + + public static String[] runForever() { + return runForever(EMULATED); + } - public static String[] cat() { - return cat(EMULATED); - } + @VisibleForTesting + static String[] echo(String value, boolean emulated) { + return emulated ? emulate("echo", value) : new String[] {"echo", value}; + } - public static String[] sleep(long durationSec) { - return sleep(durationSec, EMULATED); - } + @VisibleForTesting + static String[] cat(boolean emulated) { + return emulated ? emulate("cat") : new String[] {"cat"}; + } - public static String[] runForever() { - return runForever(EMULATED); - } + @VisibleForTesting + static String[] sleep(long durationSec, boolean emulated) { + if (durationSec < 0) { + throw new IllegalArgumentException("Sleep duration must not be negative: " + durationSec); + } + // The native sleep takes seconds; the emulated runner takes milliseconds. + return emulated + ? emulate("sleep", Long.toString(durationSec * 1000)) + : new String[] {"sleep", Long.toString(durationSec)}; + } - @VisibleForTesting - static String[] echo(String value, boolean emulated) { - return emulated ? emulate("echo", value) : new String[] {"echo", value}; - } + @VisibleForTesting + static String[] runForever(boolean emulated) { + return emulated ? emulate("sleep", Long.toString(Long.MAX_VALUE)) : new String[] {"tail", "-f", "/dev/null"}; + } - @VisibleForTesting - static String[] cat(boolean emulated) { - return emulated ? emulate("cat") : new String[] {"cat"}; - } + private static String[] emulate(String... arguments) { + Path executable = javaExecutable(); + Path classpath = classpathEntry(); + + List command = new ArrayList<>(); + command.add(executable.toString()); + command.add(MIN_HEAP); + command.add(MAX_HEAP); + command.add("-cp"); + command.add(classpath.toString()); + command.add(PortableCommandRunner.class.getName()); + command.addAll(Arrays.asList(arguments)); + return command.toArray(new String[0]); + } - @VisibleForTesting - static String[] sleep(long durationSec, boolean emulated) { - if (durationSec < 0) { - throw new IllegalArgumentException("Sleep duration must not be negative: " + durationSec); + private static Path javaExecutable() { + return javaExecutable(Paths.get(System.getProperty("java.home"))); } - // The native sleep takes seconds; the emulated runner takes milliseconds. - return emulated - ? emulate("sleep", Long.toString(durationSec * 1000)) - : new String[] {"sleep", Long.toString(durationSec)}; - } - - @VisibleForTesting - static String[] runForever(boolean emulated) { - return emulated - ? emulate("sleep", Long.toString(Long.MAX_VALUE)) - : new String[] {"tail", "-f", "/dev/null"}; - } - - private static String[] emulate(String... arguments) { - Path executable = javaExecutable(); - Path classpath = classpathEntry(); - - List command = new ArrayList<>(); - command.add(executable.toString()); - command.add(MIN_HEAP); - command.add(MAX_HEAP); - command.add("-cp"); - command.add(classpath.toString()); - command.add(PortableCommandRunner.class.getName()); - command.addAll(Arrays.asList(arguments)); - return command.toArray(new String[0]); - } - - private static Path javaExecutable() { - return javaExecutable(Paths.get(System.getProperty("java.home"))); - } - - @VisibleForTesting - static Path javaExecutable(Path javaHome) { - Path bin = javaHome.resolve("bin"); - for (String name : new String[] {"java", "java.exe"}) { - Path candidate = bin.resolve(name); - if (Files.isRegularFile(candidate)) { - return candidate; - } + + @VisibleForTesting + static Path javaExecutable(Path javaHome) { + Path bin = javaHome.resolve("bin"); + for (String name : new String[] {"java", "java.exe"}) { + Path candidate = bin.resolve(name); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + throw new IllegalStateException("Could not find a Java executable under " + bin); } - throw new IllegalStateException("Could not find a Java executable under " + bin); - } - - private static Path classpathEntry() { - CodeSource source = PortableCommand.class.getProtectionDomain().getCodeSource(); - try { - return Paths.get(source.getLocation().toURI()); - } catch (Exception e) { - throw new IllegalStateException( - "Cannot determine the classpath of " + PortableCommand.class.getName(), e); + + private static Path classpathEntry() { + CodeSource source = PortableCommand.class.getProtectionDomain().getCodeSource(); + try { + return Paths.get(source.getLocation().toURI()); + } catch (Exception e) { + throw new IllegalStateException("Cannot determine the classpath of " + PortableCommand.class.getName(), e); + } } - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java index db47270f6ec..0df32cfae28 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java @@ -13,43 +13,43 @@ * no usable native equivalent. Spawned as a child process; not meant to be called directly. */ public final class PortableCommandRunner { - private PortableCommandRunner() {} + private PortableCommandRunner() {} - @SuppressForbidden - public static void main(String[] arguments) throws IOException, InterruptedException { - execute(arguments, System.in, System.out); - System.out.flush(); - } - - @VisibleForTesting - static void execute(String[] arguments, InputStream input, OutputStream output) - throws IOException, InterruptedException { - if (arguments.length == 0) { - throw new IllegalArgumentException("Missing command"); + @SuppressForbidden + public static void main(String[] arguments) throws IOException, InterruptedException { + execute(arguments, System.in, System.out); + System.out.flush(); } - switch (arguments[0]) { - case "echo": - output.write((argument(arguments) + System.lineSeparator()).getBytes(defaultCharset())); - break; - case "cat": - byte[] buffer = new byte[8192]; - int read; - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); + + @VisibleForTesting + static void execute(String[] arguments, InputStream input, OutputStream output) + throws IOException, InterruptedException { + if (arguments.length == 0) { + throw new IllegalArgumentException("Missing command"); + } + switch (arguments[0]) { + case "echo": + output.write((argument(arguments) + System.lineSeparator()).getBytes(defaultCharset())); + break; + case "cat": + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + break; + case "sleep": + Thread.sleep(Long.parseLong(argument(arguments))); + break; + default: + throw new IllegalArgumentException("Unknown command: " + arguments[0]); } - break; - case "sleep": - Thread.sleep(Long.parseLong(argument(arguments))); - break; - default: - throw new IllegalArgumentException("Unknown command: " + arguments[0]); } - } - private static String argument(String[] arguments) { - if (arguments.length < 2) { - throw new IllegalArgumentException("Command '" + arguments[0] + "' requires an argument"); + private static String argument(String[] arguments) { + if (arguments.length < 2) { + throw new IllegalArgumentException("Command '" + arguments[0] + "' requires an argument"); + } + return arguments[1]; } - return arguments[1]; - } } diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/ThreadUtils.java b/utils/test-utils/src/main/java/datadog/trace/test/util/ThreadUtils.java index 8439c2c038a..b4c723c078a 100644 --- a/utils/test-utils/src/main/java/datadog/trace/test/util/ThreadUtils.java +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/ThreadUtils.java @@ -9,109 +9,103 @@ public class ThreadUtils { - /** A {@link Runnable} whose {@link #run()} may throw a checked exception. */ - @FunctionalInterface - public interface ThrowingRunnable { - void run() throws Throwable; - } + /** A {@link Runnable} whose {@link #run()} may throw a checked exception. */ + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws Throwable; + } - /** - * Utility to easily run a piece of code in parallel, e.g. in a JUnit test like this: - * - *
      {@code
      -   * runConcurrently(
      -   *     10,
      -   *     100,
      -   *     () -> {
      -   *       Object something = computeSomething();
      -   *       Object other = computeOther();
      -   *       assertEquals(something, other);
      -   *     });
      -   * }
      - * - * @param concurrency the number of concurrent invocations - * @param totalInvocations the total number of invocations - * @param runnable the code to run - * @return {@code true} if everything went well - * @throws Throwable if anything went wrong - */ - public static boolean runConcurrently( - final int concurrency, final int totalInvocations, final ThrowingRunnable runnable) - throws Throwable { - return runConcurrently( - concurrency, - totalInvocations, - new Closure(null) { - @Override - public Void call() { - try { - runnable.run(); - } catch (RuntimeException | Error e) { - throw e; - } catch (Throwable t) { - throw new RuntimeException(t); + /** + * Utility to easily run a piece of code in parallel, e.g. in a JUnit test like this: + * + *
      {@code
      +     * runConcurrently(
      +     *     10,
      +     *     100,
      +     *     () -> {
      +     *       Object something = computeSomething();
      +     *       Object other = computeOther();
      +     *       assertEquals(something, other);
      +     *     });
      +     * }
      + * + * @param concurrency the number of concurrent invocations + * @param totalInvocations the total number of invocations + * @param runnable the code to run + * @return {@code true} if everything went well + * @throws Throwable if anything went wrong + */ + public static boolean runConcurrently( + final int concurrency, final int totalInvocations, final ThrowingRunnable runnable) throws Throwable { + return runConcurrently(concurrency, totalInvocations, new Closure(null) { + @Override + public Void call() { + try { + runnable.run(); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException(t); + } + return null; } - return null; - } }); - } + } - public static boolean runConcurrently( - final int concurrency, final int totalInvocations, final Closure closure) - throws Throwable { - // There is no reason in creating more threads than invocations - int poolSize = Math.min(concurrency, totalInvocations); + public static boolean runConcurrently( + final int concurrency, final int totalInvocations, final Closure closure) throws Throwable { + // There is no reason in creating more threads than invocations + int poolSize = Math.min(concurrency, totalInvocations); - // If we are not running anything concurrently, then just call the closure directly - if (poolSize == 1) { - for (int c = 0; c < totalInvocations; c++) { - closure.call(); - } - return true; - } + // If we are not running anything concurrently, then just call the closure directly + if (poolSize == 1) { + for (int c = 0; c < totalInvocations; c++) { + closure.call(); + } + return true; + } - ExecutorService executor = Executors.newFixedThreadPool(poolSize); - final AtomicReference throwable = new AtomicReference<>(); - int each = totalInvocations / poolSize; - int remainder = totalInvocations % poolSize; - final CountDownLatch startBarrier = new CountDownLatch(poolSize + 1); - final CountDownLatch endBarrier = new CountDownLatch(poolSize + 1); - for (int i = 0; i < poolSize; i++) { - final int invocations = each + (remainder <= 0 ? 0 : 1); - executor.execute( - () -> { - try { - startBarrier.countDown(); - startBarrier.await(); - for (int c = 0; c < invocations && throwable.get() == null; c++) { + ExecutorService executor = Executors.newFixedThreadPool(poolSize); + final AtomicReference throwable = new AtomicReference<>(); + int each = totalInvocations / poolSize; + int remainder = totalInvocations % poolSize; + final CountDownLatch startBarrier = new CountDownLatch(poolSize + 1); + final CountDownLatch endBarrier = new CountDownLatch(poolSize + 1); + for (int i = 0; i < poolSize; i++) { + final int invocations = each + (remainder <= 0 ? 0 : 1); + executor.execute(() -> { try { - closure.call(); + startBarrier.countDown(); + startBarrier.await(); + for (int c = 0; c < invocations && throwable.get() == null; c++) { + try { + closure.call(); + } catch (Throwable t) { + throwable.compareAndSet(null, t); + } + } } catch (Throwable t) { - throwable.compareAndSet(null, t); + throwable.compareAndSet(null, t); + } finally { + try { + endBarrier.countDown(); + } catch (Throwable t) { + throwable.compareAndSet(null, t); + } } - } - } catch (Throwable t) { - throwable.compareAndSet(null, t); - } finally { - try { - endBarrier.countDown(); - } catch (Throwable t) { - throwable.compareAndSet(null, t); - } - } - }); - remainder--; - } - startBarrier.countDown(); - startBarrier.await(); - endBarrier.countDown(); - endBarrier.await(); - executor.shutdownNow(); - executor.awaitTermination(30, TimeUnit.SECONDS); - Throwable t = throwable.get(); - if (t != null) { - throw t; + }); + remainder--; + } + startBarrier.countDown(); + startBarrier.await(); + endBarrier.countDown(); + endBarrier.await(); + executor.shutdownNow(); + executor.awaitTermination(30, TimeUnit.SECONDS); + Throwable t = throwable.get(); + if (t != null) { + throw t; + } + return true; } - return true; - } } diff --git a/utils/test-utils/src/main/java/external/util/stacktrace/RecursiveRunner.java b/utils/test-utils/src/main/java/external/util/stacktrace/RecursiveRunner.java index 3c1900c64b3..0721bccf87d 100644 --- a/utils/test-utils/src/main/java/external/util/stacktrace/RecursiveRunner.java +++ b/utils/test-utils/src/main/java/external/util/stacktrace/RecursiveRunner.java @@ -3,24 +3,24 @@ /** This class is used to generate stacks not filtered by StackWalker in jmh benchmarks */ public class RecursiveRunner { - private final int deep; + private final int deep; - private final Runnable runnable; + private final Runnable runnable; - public RecursiveRunner(int deep, final Runnable runnable) { - this.deep = deep; - this.runnable = runnable; - } + public RecursiveRunner(int deep, final Runnable runnable) { + this.deep = deep; + this.runnable = runnable; + } - public void run() { - recursiveRun(0); - } + public void run() { + recursiveRun(0); + } - private void recursiveRun(int reps) { - if (reps > deep) { - runnable.run(); - } else { - recursiveRun(++reps); + private void recursiveRun(int reps) { + if (reps > deep) { + runnable.run(); + } else { + recursiveRun(++reps); + } } - } } diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PollingConditionsTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PollingConditionsTest.java index 64ea4c5fe7e..8e8b024d383 100644 --- a/utils/test-utils/src/test/java/datadog/trace/test/util/PollingConditionsTest.java +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PollingConditionsTest.java @@ -15,152 +15,137 @@ class PollingConditionsTest { - @Test - void passesOnFirstAttempt() { - AtomicInteger attempts = new AtomicInteger(); - new PollingConditions().eventually(attempts::incrementAndGet); - assertEquals(1, attempts.get()); - } - - @Test - void succeedsAfterSeveralRetries() { - AtomicInteger attempts = new AtomicInteger(); - new PollingConditions(2) - .delay(0.01) - .eventually( - () -> { - if (attempts.incrementAndGet() < 5) { + @Test + void passesOnFirstAttempt() { + AtomicInteger attempts = new AtomicInteger(); + new PollingConditions().eventually(attempts::incrementAndGet); + assertEquals(1, attempts.get()); + } + + @Test + void succeedsAfterSeveralRetries() { + AtomicInteger attempts = new AtomicInteger(); + new PollingConditions(2).delay(0.01).eventually(() -> { + if (attempts.incrementAndGet() < 5) { fail("not yet"); - } - }); - assertEquals(5, attempts.get()); - } - - @Test - void timesOutWithFormattedMessageAndUnderlyingCause() { - AssertionError error = + } + }); + assertEquals(5, attempts.get()); + } + + @Test + void timesOutWithFormattedMessageAndUnderlyingCause() { + AssertionError error = assertThrows( + AssertionError.class, + () -> new PollingConditions(0.1).delay(0.01).eventually(() -> assertEquals(1, 2, "still wrong"))); + + assertTrue(error.getMessage().startsWith("Condition not satisfied after"), error.getMessage()); + assertTrue(error.getMessage().contains("attempts"), error.getMessage()); + + Throwable cause = error.getCause(); + assertInstanceOf(AssertionError.class, cause); + assertTrue(cause.getMessage().contains("still wrong"), cause.getMessage()); + } + + @Test + void withinOverridesConfiguredTimeout() { + // The instance timeout is huge, but within() must use its own (tiny) timeout and fail fast. + long start = System.currentTimeMillis(); assertThrows( - AssertionError.class, - () -> - new PollingConditions(0.1) - .delay(0.01) - .eventually(() -> assertEquals(1, 2, "still wrong"))); - - assertTrue(error.getMessage().startsWith("Condition not satisfied after"), error.getMessage()); - assertTrue(error.getMessage().contains("attempts"), error.getMessage()); - - Throwable cause = error.getCause(); - assertInstanceOf(AssertionError.class, cause); - assertTrue(cause.getMessage().contains("still wrong"), cause.getMessage()); - } - - @Test - void withinOverridesConfiguredTimeout() { - // The instance timeout is huge, but within() must use its own (tiny) timeout and fail fast. - long start = System.currentTimeMillis(); - assertThrows( - AssertionError.class, - () -> new PollingConditions(60).delay(0.01).within(0.1, () -> fail("never"))); - long elapsed = System.currentTimeMillis() - start; - assertTrue( - elapsed < 5_000, "within() should honor its own short timeout, took " + elapsed + "ms"); - } - - @Test - void retriesWhenConditionThrowsCheckedException() { - AtomicInteger attempts = new AtomicInteger(); - new PollingConditions(2) - .delay(0.01) - .eventually( - () -> { - if (attempts.incrementAndGet() < 3) { + AssertionError.class, + () -> new PollingConditions(60).delay(0.01).within(0.1, () -> fail("never"))); + long elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed < 5_000, "within() should honor its own short timeout, took " + elapsed + "ms"); + } + + @Test + void retriesWhenConditionThrowsCheckedException() { + AtomicInteger attempts = new AtomicInteger(); + new PollingConditions(2).delay(0.01).eventually(() -> { + if (attempts.incrementAndGet() < 3) { throw new IOException("not ready"); - } - }); - assertEquals(3, attempts.get()); - } - - @Test - void factorMultipliesDelayBetweenAttempts() { - // Large timeout so the run ends by success (after 4 attempts), never by the deadline; the - // recording subclass captures the requested delays without actually sleeping, so this is - // deterministic rather than dependent on wall-clock scheduling. - RecordingPollingConditions conditions = new RecordingPollingConditions(100); - conditions.delay(0.02).factor(4); // 20ms base delay, multiplied by 4 after each attempt - - AtomicInteger attempts = new AtomicInteger(); - conditions.eventually( - () -> { - if (attempts.incrementAndGet() < 4) { - fail("not yet"); - } + } + }); + assertEquals(3, attempts.get()); + } + + @Test + void factorMultipliesDelayBetweenAttempts() { + // Large timeout so the run ends by success (after 4 attempts), never by the deadline; the + // recording subclass captures the requested delays without actually sleeping, so this is + // deterministic rather than dependent on wall-clock scheduling. + RecordingPollingConditions conditions = new RecordingPollingConditions(100); + conditions.delay(0.02).factor(4); // 20ms base delay, multiplied by 4 after each attempt + + AtomicInteger attempts = new AtomicInteger(); + conditions.eventually(() -> { + if (attempts.incrementAndGet() < 4) { + fail("not yet"); + } }); - assertEquals(4, attempts.get()); - // Three failed attempts -> three delays, each 4x the previous. - assertEquals(Arrays.asList(20L, 80L, 320L), conditions.requestedSleeps); - } - - @Test - void appliesInitialDelayBeforeFirstAttempt() { - AtomicInteger attempts = new AtomicInteger(); - long start = System.currentTimeMillis(); - new PollingConditions() - .timeout(2) - .initialDelay(0.05) - .delay(0.01) - .factor(1) - .eventually(attempts::incrementAndGet); - long elapsed = System.currentTimeMillis() - start; - assertEquals(1, attempts.get()); - assertTrue(elapsed >= 40, "initial delay should have been applied, took " + elapsed + "ms"); - } - - @Test - void interruptedWhileWaitingFailsAndRestoresInterruptFlag() { - // Pre-interrupt so the first inter-attempt sleep throws InterruptedException immediately. - Thread.currentThread().interrupt(); - try { - AssertionError error = - assertThrows( - AssertionError.class, - () -> new PollingConditions(2).delay(0.05).eventually(() -> fail("retry"))); - assertEquals("Interrupted while waiting for condition", error.getMessage()); - assertTrue( - Thread.currentThread().isInterrupted(), "interrupt flag should have been restored"); - } finally { - // Clear the flag so it does not leak into other tests sharing this thread. - Thread.interrupted(); + assertEquals(4, attempts.get()); + // Three failed attempts -> three delays, each 4x the previous. + assertEquals(Arrays.asList(20L, 80L, 320L), conditions.requestedSleeps); } - } - - @Test - void rejectsNegativeOrNanConfiguration() { - // Check conditions parameters - assertThrows(IllegalArgumentException.class, () -> new PollingConditions(-1)); - assertThrows(IllegalArgumentException.class, () -> new PollingConditions().timeout(-1)); - assertThrows(IllegalArgumentException.class, () -> new PollingConditions().initialDelay(-1)); - assertThrows(IllegalArgumentException.class, () -> new PollingConditions().factor(-2)); - // Check delay and within - IllegalArgumentException error = - assertThrows(IllegalArgumentException.class, () -> new PollingConditions().delay(-0.5)); - assertTrue(error.getMessage().contains("delay"), error.getMessage()); - assertTrue(error.getMessage().contains("-0.5"), error.getMessage()); - assertThrows( - IllegalArgumentException.class, () -> new PollingConditions().within(-1, () -> {})); - } - - /** Captures the requested delays instead of sleeping, for deterministic back-off assertions. */ - private static final class RecordingPollingConditions extends PollingConditions { - final List requestedSleeps = new ArrayList<>(); - - RecordingPollingConditions(double timeoutSeconds) { - super(timeoutSeconds); + + @Test + void appliesInitialDelayBeforeFirstAttempt() { + AtomicInteger attempts = new AtomicInteger(); + long start = System.currentTimeMillis(); + new PollingConditions() + .timeout(2) + .initialDelay(0.05) + .delay(0.01) + .factor(1) + .eventually(attempts::incrementAndGet); + long elapsed = System.currentTimeMillis() - start; + assertEquals(1, attempts.get()); + assertTrue(elapsed >= 40, "initial delay should have been applied, took " + elapsed + "ms"); } - @Override - void sleep(long millis) { - requestedSleeps.add(millis); + @Test + void interruptedWhileWaitingFailsAndRestoresInterruptFlag() { + // Pre-interrupt so the first inter-attempt sleep throws InterruptedException immediately. + Thread.currentThread().interrupt(); + try { + AssertionError error = assertThrows( + AssertionError.class, + () -> new PollingConditions(2).delay(0.05).eventually(() -> fail("retry"))); + assertEquals("Interrupted while waiting for condition", error.getMessage()); + assertTrue(Thread.currentThread().isInterrupted(), "interrupt flag should have been restored"); + } finally { + // Clear the flag so it does not leak into other tests sharing this thread. + Thread.interrupted(); + } + } + + @Test + void rejectsNegativeOrNanConfiguration() { + // Check conditions parameters + assertThrows(IllegalArgumentException.class, () -> new PollingConditions(-1)); + assertThrows(IllegalArgumentException.class, () -> new PollingConditions().timeout(-1)); + assertThrows(IllegalArgumentException.class, () -> new PollingConditions().initialDelay(-1)); + assertThrows(IllegalArgumentException.class, () -> new PollingConditions().factor(-2)); + // Check delay and within + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> new PollingConditions().delay(-0.5)); + assertTrue(error.getMessage().contains("delay"), error.getMessage()); + assertTrue(error.getMessage().contains("-0.5"), error.getMessage()); + assertThrows(IllegalArgumentException.class, () -> new PollingConditions().within(-1, () -> {})); + } + + /** Captures the requested delays instead of sleeping, for deterministic back-off assertions. */ + private static final class RecordingPollingConditions extends PollingConditions { + final List requestedSleeps = new ArrayList<>(); + + RecordingPollingConditions(double timeoutSeconds) { + super(timeoutSeconds); + } + + @Override + void sleep(long millis) { + requestedSleeps.add(millis); + } } - } } diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java index 610e5dad584..07fc01cae0c 100644 --- a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java @@ -17,63 +17,56 @@ * about why a command misbehaved, so the dispatch is driven directly here. */ class PortableCommandRunnerTest { - @Test - void echoes() throws Exception { - ByteArrayOutputStream output = new ByteArrayOutputStream(); + @Test + void echoes() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); - PortableCommandRunner.execute(new String[] {"echo", "value"}, emptyInput(), output); + PortableCommandRunner.execute(new String[] {"echo", "value"}, emptyInput(), output); - assertEquals("value" + System.lineSeparator(), new String(output.toByteArray(), UTF_8)); - } + assertEquals("value" + System.lineSeparator(), new String(output.toByteArray(), UTF_8)); + } - @Test - void copiesInput() throws Exception { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - InputStream input = new ByteArrayInputStream("payload".getBytes(UTF_8)); + @Test + void copiesInput() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + InputStream input = new ByteArrayInputStream("payload".getBytes(UTF_8)); - PortableCommandRunner.execute(new String[] {"cat"}, input, output); + PortableCommandRunner.execute(new String[] {"cat"}, input, output); - assertEquals("payload", new String(output.toByteArray(), UTF_8)); - } + assertEquals("payload", new String(output.toByteArray(), UTF_8)); + } - @Test - void sleeps() throws Exception { - long start = System.nanoTime(); + @Test + void sleeps() throws Exception { + long start = System.nanoTime(); - PortableCommandRunner.execute( - new String[] {"sleep", "50"}, emptyInput(), new ByteArrayOutputStream()); + PortableCommandRunner.execute(new String[] {"sleep", "50"}, emptyInput(), new ByteArrayOutputStream()); - assertTrue((System.nanoTime() - start) / 1_000_000 >= 40, "sleep returned immediately"); - } + assertTrue((System.nanoTime() - start) / 1_000_000 >= 40, "sleep returned immediately"); + } - @Test - void rejectsMissingCommand() { - assertThrows( - IllegalArgumentException.class, - () -> - PortableCommandRunner.execute( - new String[0], emptyInput(), new ByteArrayOutputStream())); - } + @Test + void rejectsMissingCommand() { + assertThrows( + IllegalArgumentException.class, + () -> PortableCommandRunner.execute(new String[0], emptyInput(), new ByteArrayOutputStream())); + } - @Test - void rejectsUnknownCommand() { - assertThrows( - IllegalArgumentException.class, - () -> - PortableCommandRunner.execute( - new String[] {"rm"}, emptyInput(), new ByteArrayOutputStream())); - } + @Test + void rejectsUnknownCommand() { + assertThrows( + IllegalArgumentException.class, + () -> PortableCommandRunner.execute(new String[] {"rm"}, emptyInput(), new ByteArrayOutputStream())); + } - @Test - void rejectsMissingArgument() { - assertThrows( - IllegalArgumentException.class, - () -> - PortableCommandRunner.execute( - new String[] {"echo"}, emptyInput(), new ByteArrayOutputStream())); - } + @Test + void rejectsMissingArgument() { + assertThrows( + IllegalArgumentException.class, + () -> PortableCommandRunner.execute(new String[] {"echo"}, emptyInput(), new ByteArrayOutputStream())); + } - private static InputStream emptyInput() { - return new ByteArrayInputStream(new byte[0]); - } + private static InputStream emptyInput() { + return new ByteArrayInputStream(new byte[0]); + } } diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java index 784145e8c8b..067e6272ba2 100644 --- a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java @@ -22,191 +22,188 @@ import org.tabletest.junit.TableTest; class PortableCommandTest { - private static final long TIMEOUT_SECONDS = 20; - - // Both strategies are exercised on every platform so that the emulated path, which only ships - // on Windows, is still verified by a POSIX CI run. - - @TableTest({ - "scenario | emulated", - "native command | false ", - "emulated child JVM | true " - }) - void testEcho(boolean emulated) throws Exception { - assumeStrategySupported(emulated); - - Result result = run(PortableCommand.echo("hello", emulated), null); - - assertEquals(0, result.exitCode, result.error); - assertEquals("hello" + System.lineSeparator(), result.output); - } - - @TableTest({ - "scenario | emulated", - "native command | false ", - "emulated child JVM | true " - }) - void testCat(boolean emulated) throws Exception { - assumeStrategySupported(emulated); - - Result result = run(PortableCommand.cat(emulated), "copied".getBytes(UTF_8)); - - assertEquals(0, result.exitCode, result.error); - assertEquals("copied", result.output); - } - - @TableTest({ - "scenario | emulated", - "native command | false ", - "emulated child JVM | true " - }) - void testSleep(boolean emulated) throws Exception { - assumeStrategySupported(emulated); - - long start = System.nanoTime(); - Result result = run(PortableCommand.sleep(1, emulated), null); - long elapsedMillis = (System.nanoTime() - start) / 1_000_000; - - assertEquals(0, result.exitCode, result.error); - // A floor slightly below the requested duration keeps this robust against clock granularity - // while still failing if sleep is a no-op. - assertTrue(elapsedMillis >= 900, "slept for only " + elapsedMillis + "ms"); - } - - @TableTest({ - "scenario | emulated", - "native command | false ", - "emulated child JVM | true " - }) - void testRunForever(boolean emulated) throws Exception { - assumeStrategySupported(emulated); - - Process process = new ProcessBuilder(PortableCommand.runForever(emulated)).start(); - try { - assertFalse(process.waitFor(1, SECONDS), "runForever() must not exit on its own"); - - process.destroyForcibly(); - - assertTrue(process.waitFor(TIMEOUT_SECONDS, SECONDS), "process outlived destroyForcibly()"); - } finally { - process.destroyForcibly(); + private static final long TIMEOUT_SECONDS = 20; + + // Both strategies are exercised on every platform so that the emulated path, which only ships + // on Windows, is still verified by a POSIX CI run. + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testEcho(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Result result = run(PortableCommand.echo("hello", emulated), null); + + assertEquals(0, result.exitCode, result.error); + assertEquals("hello" + System.lineSeparator(), result.output); } - } - - @Test - void publicApiSelectsStrategyForCurrentPlatform() { - boolean emulated = OperatingSystem.isWindows(); - - assertArrayEquals(PortableCommand.echo("v", emulated), PortableCommand.echo("v")); - assertArrayEquals(PortableCommand.cat(emulated), PortableCommand.cat()); - assertArrayEquals(PortableCommand.sleep(10, emulated), PortableCommand.sleep(10)); - assertArrayEquals(PortableCommand.runForever(emulated), PortableCommand.runForever()); - } - - @Test - void rejectsNegativeSleep() { - assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, false)); - assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, true)); - } - - @Test - void locatesJavaExecutableOfRunningJvm() { - Path executable = PortableCommand.javaExecutable(Paths.get(System.getProperty("java.home"))); - - assertTrue(Files.isRegularFile(executable), executable + " is not a file"); - } - - @Test - void locatesWindowsJavaExecutable(@TempDir Path javaHome) throws Exception { - Path bin = Files.createDirectory(javaHome.resolve("bin")); - Path executable = Files.createFile(bin.resolve("java.exe")); - - assertEquals(executable, PortableCommand.javaExecutable(javaHome)); - } - - @Test - void failsWhenJavaExecutableIsMissing(@TempDir Path javaHome) throws Exception { - Files.createDirectory(javaHome.resolve("bin")); - - assertThrows(IllegalStateException.class, () -> PortableCommand.javaExecutable(javaHome)); - } - - private static void assumeStrategySupported(boolean emulated) { - assumeTrue( - emulated || !OperatingSystem.isWindows(), - "native commands are only available on POSIX platforms"); - } - - private static Result run(String[] command, byte[] input) throws Exception { - Process process = new ProcessBuilder(command).start(); - try { - // Both streams are drained concurrently: waiting for the process to exit before reading - // deadlocks as soon as either pipe buffer fills. - Drain output = Drain.of(process.getInputStream()); - Drain error = Drain.of(process.getErrorStream()); - - try (OutputStream stdin = process.getOutputStream()) { - if (input != null) { - stdin.write(input); + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testCat(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Result result = run(PortableCommand.cat(emulated), "copied".getBytes(UTF_8)); + + assertEquals(0, result.exitCode, result.error); + assertEquals("copied", result.output); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testSleep(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + long start = System.nanoTime(); + Result result = run(PortableCommand.sleep(1, emulated), null); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + assertEquals(0, result.exitCode, result.error); + // A floor slightly below the requested duration keeps this robust against clock granularity + // while still failing if sleep is a no-op. + assertTrue(elapsedMillis >= 900, "slept for only " + elapsedMillis + "ms"); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testRunForever(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Process process = new ProcessBuilder(PortableCommand.runForever(emulated)).start(); + try { + assertFalse(process.waitFor(1, SECONDS), "runForever() must not exit on its own"); + + process.destroyForcibly(); + + assertTrue(process.waitFor(TIMEOUT_SECONDS, SECONDS), "process outlived destroyForcibly()"); + } finally { + process.destroyForcibly(); } - } - - assertTrue( - process.waitFor(TIMEOUT_SECONDS, SECONDS), - () -> "command timed out: " + String.join(" ", command)); - return new Result(process.exitValue(), output.text(), error.text()); - } finally { - process.destroyForcibly(); } - } - private static final class Result { - final int exitCode; - final String output; - final String error; + @Test + void publicApiSelectsStrategyForCurrentPlatform() { + boolean emulated = OperatingSystem.isWindows(); + + assertArrayEquals(PortableCommand.echo("v", emulated), PortableCommand.echo("v")); + assertArrayEquals(PortableCommand.cat(emulated), PortableCommand.cat()); + assertArrayEquals(PortableCommand.sleep(10, emulated), PortableCommand.sleep(10)); + assertArrayEquals(PortableCommand.runForever(emulated), PortableCommand.runForever()); + } + + @Test + void rejectsNegativeSleep() { + assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, false)); + assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, true)); + } + + @Test + void locatesJavaExecutableOfRunningJvm() { + Path executable = PortableCommand.javaExecutable(Paths.get(System.getProperty("java.home"))); + + assertTrue(Files.isRegularFile(executable), executable + " is not a file"); + } + + @Test + void locatesWindowsJavaExecutable(@TempDir Path javaHome) throws Exception { + Path bin = Files.createDirectory(javaHome.resolve("bin")); + Path executable = Files.createFile(bin.resolve("java.exe")); - Result(int exitCode, String output, String error) { - this.exitCode = exitCode; - this.output = output; - this.error = error; + assertEquals(executable, PortableCommand.javaExecutable(javaHome)); } - } - private static final class Drain extends Thread { - private final InputStream input; - private final ByteArrayOutputStream output = new ByteArrayOutputStream(); - private volatile IOException failure; + @Test + void failsWhenJavaExecutableIsMissing(@TempDir Path javaHome) throws Exception { + Files.createDirectory(javaHome.resolve("bin")); - private Drain(InputStream input) { - this.input = input; - setDaemon(true); + assertThrows(IllegalStateException.class, () -> PortableCommand.javaExecutable(javaHome)); } - static Drain of(InputStream input) { - Drain drain = new Drain(input); - drain.start(); - return drain; + private static void assumeStrategySupported(boolean emulated) { + assumeTrue(emulated || !OperatingSystem.isWindows(), "native commands are only available on POSIX platforms"); } - @Override - public void run() { - byte[] buffer = new byte[8192]; - try { - int read; - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); + private static Result run(String[] command, byte[] input) throws Exception { + Process process = new ProcessBuilder(command).start(); + try { + // Both streams are drained concurrently: waiting for the process to exit before reading + // deadlocks as soon as either pipe buffer fills. + Drain output = Drain.of(process.getInputStream()); + Drain error = Drain.of(process.getErrorStream()); + + try (OutputStream stdin = process.getOutputStream()) { + if (input != null) { + stdin.write(input); + } + } + + assertTrue( + process.waitFor(TIMEOUT_SECONDS, SECONDS), () -> "command timed out: " + String.join(" ", command)); + return new Result(process.exitValue(), output.text(), error.text()); + } finally { + process.destroyForcibly(); + } + } + + private static final class Result { + final int exitCode; + final String output; + final String error; + + Result(int exitCode, String output, String error) { + this.exitCode = exitCode; + this.output = output; + this.error = error; } - } catch (IOException e) { - failure = e; - } } - String text() throws Exception { - join(SECONDS.toMillis(TIMEOUT_SECONDS)); - if (failure != null) { - throw failure; - } - return new String(output.toByteArray(), UTF_8); + private static final class Drain extends Thread { + private final InputStream input; + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + private volatile IOException failure; + + private Drain(InputStream input) { + this.input = input; + setDaemon(true); + } + + static Drain of(InputStream input) { + Drain drain = new Drain(input); + drain.start(); + return drain; + } + + @Override + public void run() { + byte[] buffer = new byte[8192]; + try { + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } catch (IOException e) { + failure = e; + } + } + + String text() throws Exception { + join(SECONDS.toMillis(TIMEOUT_SECONDS)); + if (failure != null) { + throw failure; + } + return new String(output.toByteArray(), UTF_8); + } } - } } diff --git a/utils/time-utils/src/main/java/datadog/trace/api/time/ControllableTimeSource.java b/utils/time-utils/src/main/java/datadog/trace/api/time/ControllableTimeSource.java index 833314da88e..394ac8d074d 100644 --- a/utils/time-utils/src/main/java/datadog/trace/api/time/ControllableTimeSource.java +++ b/utils/time-utils/src/main/java/datadog/trace/api/time/ControllableTimeSource.java @@ -3,33 +3,33 @@ import java.util.concurrent.TimeUnit; public class ControllableTimeSource implements TimeSource { - private long currentTime = 0; - - public void advance(long nanosIncrement) { - currentTime += nanosIncrement; - } - - public void set(long nanos) { - currentTime = nanos; - } - - @Override - public long getNanoTicks() { - return currentTime; - } - - @Override - public long getCurrentTimeMillis() { - return TimeUnit.NANOSECONDS.toMillis(currentTime); - } - - @Override - public long getCurrentTimeMicros() { - return TimeUnit.NANOSECONDS.toMicros(currentTime); - } - - @Override - public long getCurrentTimeNanos() { - return currentTime; - } + private long currentTime = 0; + + public void advance(long nanosIncrement) { + currentTime += nanosIncrement; + } + + public void set(long nanos) { + currentTime = nanos; + } + + @Override + public long getNanoTicks() { + return currentTime; + } + + @Override + public long getCurrentTimeMillis() { + return TimeUnit.NANOSECONDS.toMillis(currentTime); + } + + @Override + public long getCurrentTimeMicros() { + return TimeUnit.NANOSECONDS.toMicros(currentTime); + } + + @Override + public long getCurrentTimeNanos() { + return currentTime; + } } diff --git a/utils/time-utils/src/main/java/datadog/trace/api/time/SystemTimeSource.java b/utils/time-utils/src/main/java/datadog/trace/api/time/SystemTimeSource.java index dfa5139056b..aebb9dc476f 100644 --- a/utils/time-utils/src/main/java/datadog/trace/api/time/SystemTimeSource.java +++ b/utils/time-utils/src/main/java/datadog/trace/api/time/SystemTimeSource.java @@ -3,27 +3,27 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS; public class SystemTimeSource implements TimeSource { - public static final TimeSource INSTANCE = new SystemTimeSource(); + public static final TimeSource INSTANCE = new SystemTimeSource(); - private SystemTimeSource() {} + private SystemTimeSource() {} - @Override - public long getNanoTicks() { - return System.nanoTime(); - } + @Override + public long getNanoTicks() { + return System.nanoTime(); + } - @Override - public long getCurrentTimeMillis() { - return System.currentTimeMillis(); - } + @Override + public long getCurrentTimeMillis() { + return System.currentTimeMillis(); + } - @Override - public long getCurrentTimeMicros() { - return MILLISECONDS.toMicros(getCurrentTimeMillis()); - } + @Override + public long getCurrentTimeMicros() { + return MILLISECONDS.toMicros(getCurrentTimeMillis()); + } - @Override - public long getCurrentTimeNanos() { - return MILLISECONDS.toNanos(getCurrentTimeMillis()); - } + @Override + public long getCurrentTimeNanos() { + return MILLISECONDS.toNanos(getCurrentTimeMillis()); + } } diff --git a/utils/time-utils/src/main/java/datadog/trace/api/time/TimeSource.java b/utils/time-utils/src/main/java/datadog/trace/api/time/TimeSource.java index dc785661fb0..8da682359ec 100644 --- a/utils/time-utils/src/main/java/datadog/trace/api/time/TimeSource.java +++ b/utils/time-utils/src/main/java/datadog/trace/api/time/TimeSource.java @@ -5,18 +5,18 @@ * platform. */ public interface TimeSource { - /** - * Returns monotonically increasing ticks from some arbitrary start point. Should only be used to - * measure durations. Negative numbers and zero are both valid return values - */ - long getNanoTicks(); + /** + * Returns monotonically increasing ticks from some arbitrary start point. Should only be used to + * measure durations. Negative numbers and zero are both valid return values + */ + long getNanoTicks(); - /** Milliseconds since the start of the epoch */ - long getCurrentTimeMillis(); + /** Milliseconds since the start of the epoch */ + long getCurrentTimeMillis(); - /** Microseconds since the start of the epoch */ - long getCurrentTimeMicros(); + /** Microseconds since the start of the epoch */ + long getCurrentTimeMicros(); - /** Nanoseconds since the start of the epoch. Valid for the time range 1/1/1970 +/- 270 years */ - long getCurrentTimeNanos(); + /** Nanoseconds since the start of the epoch. Valid for the time range 1/1/1970 +/- 270 years */ + long getCurrentTimeNanos(); } diff --git a/utils/time-utils/src/main/java/datadog/trace/api/time/TimeUtils.java b/utils/time-utils/src/main/java/datadog/trace/api/time/TimeUtils.java index 89b5a6176b8..d120fbbb3bd 100644 --- a/utils/time-utils/src/main/java/datadog/trace/api/time/TimeUtils.java +++ b/utils/time-utils/src/main/java/datadog/trace/api/time/TimeUtils.java @@ -6,32 +6,32 @@ public abstract class TimeUtils { - /** Number followed by an optional time unit of hours (h), minutes (m), or seconds (s). */ - private static final Pattern SIMPLE_DELAY_PATTERN = Pattern.compile("(\\d+)([HhMmSs]?)"); + /** Number followed by an optional time unit of hours (h), minutes (m), or seconds (s). */ + private static final Pattern SIMPLE_DELAY_PATTERN = Pattern.compile("(\\d+)([HhMmSs]?)"); - /** - * Parses the string as a simple delay, such as "30s" or "10m". - * - * @param delayString number followed by an optional time unit - * @return delay in seconds; -1 if the string cannot be parsed - */ - public static long parseSimpleDelay(String delayString) { - if (null != delayString) { - Matcher delayMatcher = SIMPLE_DELAY_PATTERN.matcher(delayString); - if (delayMatcher.matches()) { - long delay = Integer.parseInt(delayMatcher.group(1)); - String unit = delayMatcher.group(2); - if ("H".equalsIgnoreCase(unit)) { - return TimeUnit.HOURS.toSeconds(delay); - } else if ("M".equalsIgnoreCase(unit)) { - return TimeUnit.MINUTES.toSeconds(delay); - } else { - return delay; // already in seconds + /** + * Parses the string as a simple delay, such as "30s" or "10m". + * + * @param delayString number followed by an optional time unit + * @return delay in seconds; -1 if the string cannot be parsed + */ + public static long parseSimpleDelay(String delayString) { + if (null != delayString) { + Matcher delayMatcher = SIMPLE_DELAY_PATTERN.matcher(delayString); + if (delayMatcher.matches()) { + long delay = Integer.parseInt(delayMatcher.group(1)); + String unit = delayMatcher.group(2); + if ("H".equalsIgnoreCase(unit)) { + return TimeUnit.HOURS.toSeconds(delay); + } else if ("M".equalsIgnoreCase(unit)) { + return TimeUnit.MINUTES.toSeconds(delay); + } else { + return delay; // already in seconds + } + } } - } + return -1; // unrecognized } - return -1; // unrecognized - } - private TimeUtils() {} + private TimeUtils() {} } diff --git a/utils/time-utils/src/test/java/datadog/trace/api/time/TimeUtilsTest.java b/utils/time-utils/src/test/java/datadog/trace/api/time/TimeUtilsTest.java index 819c7e0c00f..9a3869e708d 100644 --- a/utils/time-utils/src/test/java/datadog/trace/api/time/TimeUtilsTest.java +++ b/utils/time-utils/src/test/java/datadog/trace/api/time/TimeUtilsTest.java @@ -7,43 +7,43 @@ public class TimeUtilsTest extends DDJavaSpecification { - @TableTest({ - "delayString | expected", - " | -1 ", - "'' | -1 ", - "foo | -1 ", - "-8 | -1 ", - "-1 | -1 ", - "0 | 0 ", - "1 | 1 ", - "2 | 2 ", - "3 | 3 ", - "0s | 0 ", - "1s | 1 ", - "2s | 2 ", - "3s | 3 ", - "0m | 0 ", - "1m | 60 ", - "2m | 120 ", - "3m | 180 ", - "0h | 0 ", - "1h | 3600 ", - "2h | 7200 ", - "3h | 10800 ", - "0S | 0 ", - "1S | 1 ", - "2S | 2 ", - "3S | 3 ", - "0M | 0 ", - "1M | 60 ", - "2M | 120 ", - "3M | 180 ", - "0H | 0 ", - "1H | 3600 ", - "2H | 7200 ", - "3H | 10800 " - }) - void testSimpleDelayParsing(String delayString, long expected) { - assertEquals(expected, TimeUtils.parseSimpleDelay(delayString)); - } + @TableTest({ + "delayString | expected", + " | -1 ", + "'' | -1 ", + "foo | -1 ", + "-8 | -1 ", + "-1 | -1 ", + "0 | 0 ", + "1 | 1 ", + "2 | 2 ", + "3 | 3 ", + "0s | 0 ", + "1s | 1 ", + "2s | 2 ", + "3s | 3 ", + "0m | 0 ", + "1m | 60 ", + "2m | 120 ", + "3m | 180 ", + "0h | 0 ", + "1h | 3600 ", + "2h | 7200 ", + "3h | 10800 ", + "0S | 0 ", + "1S | 1 ", + "2S | 2 ", + "3S | 3 ", + "0M | 0 ", + "1M | 60 ", + "2M | 120 ", + "3M | 180 ", + "0H | 0 ", + "1H | 3600 ", + "2H | 7200 ", + "3H | 10800 " + }) + void testSimpleDelayParsing(String delayString, long expected) { + assertEquals(expected, TimeUtils.parseSimpleDelay(delayString)); + } } diff --git a/utils/version-utils/src/main/java/datadog/common/version/VersionInfo.java b/utils/version-utils/src/main/java/datadog/common/version/VersionInfo.java index eb6829883b6..8291e45b854 100644 --- a/utils/version-utils/src/main/java/datadog/common/version/VersionInfo.java +++ b/utils/version-utils/src/main/java/datadog/common/version/VersionInfo.java @@ -10,32 +10,31 @@ @SuppressFBWarnings("OS_OPEN_STREAM") public class VersionInfo { - private static final Logger log = LoggerFactory.getLogger(VersionInfo.class); + private static final Logger log = LoggerFactory.getLogger(VersionInfo.class); - public static final String LIBRARY_VERSION_TAG = "library_version"; - public static final String PROFILER_VERSION_TAG = "profiler_version"; - public static final String VERSION; + public static final String LIBRARY_VERSION_TAG = "library_version"; + public static final String PROFILER_VERSION_TAG = "profiler_version"; + public static final String VERSION; - static { - String version = "unknown"; - try { - final InputStream is = - VersionInfo.class.getClassLoader().getResourceAsStream("version-utils.version"); - if (is != null) { - final BufferedReader reader = new BufferedReader(new InputStreamReader(is)); - final StringBuilder sb = new StringBuilder(); - final char[] buffer = new char[1 << 8]; - int read; - while ((read = reader.read(buffer, 0, buffer.length)) > 0) { - sb.append(buffer, 0, read); + static { + String version = "unknown"; + try { + final InputStream is = VersionInfo.class.getClassLoader().getResourceAsStream("version-utils.version"); + if (is != null) { + final BufferedReader reader = new BufferedReader(new InputStreamReader(is)); + final StringBuilder sb = new StringBuilder(); + final char[] buffer = new char[1 << 8]; + int read; + while ((read = reader.read(buffer, 0, buffer.length)) > 0) { + sb.append(buffer, 0, read); + } + version = sb.toString().trim(); + } else { + log.error("No version file found"); + } + } catch (final Exception e) { + log.error("Cannot read version file", e); } - version = sb.toString().trim(); - } else { - log.error("No version file found"); - } - } catch (final Exception e) { - log.error("Cannot read version file", e); + VERSION = version; } - VERSION = version; - } }